feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia
@@ -0,0 +1,97 @@
|
||||
from flask import Flask, request, session
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_cors import CORS
|
||||
from flask_babel import Babel, gettext as _
|
||||
from config.config import Config
|
||||
|
||||
# Initialize extensions
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
cors = CORS()
|
||||
babel = Babel()
|
||||
|
||||
def create_app(config_class=Config):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config_class)
|
||||
|
||||
# Initialize extensions with app
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
cors.init_app(app)
|
||||
|
||||
# Import models to ensure they are registered
|
||||
from app.models import user
|
||||
# Register REST API (v1) Blueprints with Stateless JWT support
|
||||
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
|
||||
from app.routes.api.dashboard import api_dashboard_bp
|
||||
from app.routes.api.admin import api_admin_bp
|
||||
from app.security import SecurityFilterChain
|
||||
|
||||
# Register API v1 blueprints
|
||||
app.register_blueprint(api_auth_bp)
|
||||
app.register_blueprint(api_classrooms_bp)
|
||||
app.register_blueprint(api_reservations_bp)
|
||||
app.register_blueprint(api_optimizer_bp)
|
||||
app.register_blueprint(api_dashboard_bp)
|
||||
app.register_blueprint(api_admin_bp, url_prefix='/api/v1')
|
||||
app.register_blueprint(api_admin_bp, url_prefix='/api/v1/admin', name='api_admin_prefixed')
|
||||
|
||||
# Initialize Enterprise Security Pipeline (SecurityFilterChain)
|
||||
SecurityFilterChain(app)
|
||||
|
||||
# Language selector function
|
||||
def get_locale():
|
||||
# 1. Si el usuario está autenticado y tiene idioma configurado explícitamente en su perfil, prevalece.
|
||||
from flask_login import current_user
|
||||
if current_user.is_authenticated:
|
||||
if getattr(current_user, 'preferred_language', None) and current_user.preferred_language in app.config['LANGUAGES']:
|
||||
return current_user.preferred_language
|
||||
# El idioma por defecto siempre será español a menos que se configure a mano
|
||||
return app.config['BABEL_DEFAULT_LOCALE']
|
||||
|
||||
# 2. Parámetro explícito en la URL (?lang=es o ?lang=en) (para usuarios no autenticados o login)
|
||||
lang = request.args.get('lang')
|
||||
if lang and lang in app.config['LANGUAGES']:
|
||||
session['language'] = lang
|
||||
return lang
|
||||
|
||||
# 3. Si ya hay un idioma guardado en la sesión
|
||||
if 'language' in session and session['language'] in app.config['LANGUAGES']:
|
||||
return session['language']
|
||||
|
||||
# 4. Primera vez que se accede a la web (sin sesión): respetar el idioma del navegador
|
||||
if request.accept_languages:
|
||||
for accepted_lang, _quality in request.accept_languages:
|
||||
primary = accepted_lang.split('-')[0].lower()
|
||||
if primary in app.config['LANGUAGES']:
|
||||
session['language'] = primary # Se guarda para la sesión actual
|
||||
return primary
|
||||
|
||||
# Fallback del sistema: Español
|
||||
return app.config['BABEL_DEFAULT_LOCALE']
|
||||
|
||||
|
||||
# Babel context processor
|
||||
@app.context_processor
|
||||
def inject_conf_vars():
|
||||
return dict(
|
||||
languages=app.config['LANGUAGES'],
|
||||
current_lang=str(get_locale())
|
||||
)
|
||||
|
||||
|
||||
# Configure Babel
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
|
||||
# Register localized date and datetime formatters for Jinja2 templates
|
||||
from flask_babel import format_date, format_datetime
|
||||
app.jinja_env.globals['format_date'] = format_date
|
||||
app.jinja_env.globals['format_datetime'] = format_datetime
|
||||
app.jinja_env.filters['format_date'] = format_date
|
||||
app.jinja_env.filters['format_datetime'] = format_datetime
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,17 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, BooleanField, SubmitField
|
||||
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)
|
||||
],
|
||||
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'))
|
||||
@@ -0,0 +1,28 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
class BuildingForm(FlaskForm):
|
||||
name = StringField(_l('Building Name'), validators=[
|
||||
DataRequired(),
|
||||
Length(min=2, max=100)
|
||||
])
|
||||
code = StringField(_l('Code'), validators=[
|
||||
Optional(),
|
||||
Length(max=50)
|
||||
])
|
||||
address = StringField(_l('Address'), validators=[
|
||||
Optional(),
|
||||
Length(max=255)
|
||||
])
|
||||
floors = IntegerField(_l('Number of Floors'), default=1, validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=1, max=100)
|
||||
])
|
||||
description = TextAreaField(_l('Description'), validators=[
|
||||
Optional(),
|
||||
Length(max=500)
|
||||
])
|
||||
is_active = BooleanField(_l('Active'), default=True)
|
||||
submit = SubmitField(_l('Save Building'))
|
||||
@@ -0,0 +1,56 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField, SelectField
|
||||
from wtforms.validators import DataRequired, InputRequired, Length, NumberRange, Optional, ValidationError
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
def validate_capacity(form, field):
|
||||
val = str(field.data or '').strip()
|
||||
if not val:
|
||||
raise ValidationError(_l('La capacidad es obligatoria (ingrese un número o el símbolo ∞).'))
|
||||
if val in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||
return
|
||||
try:
|
||||
num = int(val)
|
||||
if num < 0:
|
||||
raise ValidationError(_l('La capacidad no puede ser negativa.'))
|
||||
if num > 10000:
|
||||
raise ValidationError(_l('La capacidad máxima numérica es 10.000.'))
|
||||
except ValueError:
|
||||
raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.'))
|
||||
|
||||
class ClassroomForm(FlaskForm):
|
||||
building_id = SelectField(_l('Edificio'), validators=[DataRequired()])
|
||||
new_building_name = StringField(_l('Nombre del Nuevo Edificio'), validators=[Optional(), Length(max=100)])
|
||||
room_number = StringField(_l('Número / Nombre del Aula'), validators=[
|
||||
DataRequired(),
|
||||
Length(min=1, max=50)
|
||||
])
|
||||
floor = StringField(_l('Piso'), validators=[
|
||||
DataRequired(message=_l('El piso es obligatorio (ej: PB, 1, 2, 4°P)')),
|
||||
Length(min=1, max=50)
|
||||
])
|
||||
capacity = StringField(_l('Capacidad'), validators=[
|
||||
validate_capacity
|
||||
])
|
||||
description = TextAreaField(_l('Descripción'), validators=[
|
||||
Optional(),
|
||||
Length(max=500)
|
||||
])
|
||||
is_active = BooleanField(_l('Activa'), default=True)
|
||||
submit = SubmitField(_l('Guardar Aula'))
|
||||
|
||||
class ClassroomResourceForm(FlaskForm):
|
||||
resource_type = SelectField(_l('Resource Type'), choices=[
|
||||
('PROJECTOR', _l('Projector')),
|
||||
('WHITEBOARD', _l('Whiteboard')),
|
||||
('COMPUTER', _l('Computer')),
|
||||
('SOUND_SYSTEM', _l('Sound System')),
|
||||
('MICROPHONE', _l('Microphone')),
|
||||
('AIR_CONDITIONER', _l('Air Conditioner')),
|
||||
('OTHER', _l('Other'))
|
||||
], validators=[DataRequired()])
|
||||
brand = StringField(_l('Brand'), validators=[Optional(), Length(max=100)])
|
||||
model = StringField(_l('Model'), validators=[Optional(), Length(max=100)])
|
||||
specifications = TextAreaField(_l('Specifications'), validators=[Optional()])
|
||||
functional = BooleanField(_l('Functional'), default=True)
|
||||
submit = SubmitField(_l('Save Resource'))
|
||||
@@ -0,0 +1,49 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError
|
||||
from flask_babel import lazy_gettext as _l
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class ReservationForm(FlaskForm):
|
||||
classroom_id = SelectField(_l('Classroom'), coerce=int, validators=[DataRequired()])
|
||||
commission_id = SelectField(_l('Commission/Subject'), coerce=int, validators=[DataRequired()])
|
||||
start_time = DateTimeLocalField(_l('Start Time'), format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
|
||||
end_time = DateTimeLocalField(_l('End Time'), format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
|
||||
purpose = StringField(_l('Purpose'), validators=[
|
||||
DataRequired(),
|
||||
Length(min=3, max=500)
|
||||
])
|
||||
expected_attendees = IntegerField(_l('Expected Attendees'), validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=1, max=500)
|
||||
])
|
||||
notes = TextAreaField(_l('Notes'), validators=[
|
||||
Optional(),
|
||||
Length(max=1000)
|
||||
])
|
||||
virtual_link = StringField(_l('Virtual Classroom Link'), validators=[
|
||||
Optional(),
|
||||
Length(max=500)
|
||||
])
|
||||
submit = SubmitField(_l('Make Reservation'))
|
||||
|
||||
def __init__(self, *args, is_edit=False, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.is_edit = is_edit
|
||||
|
||||
def validate_end_time(self, field):
|
||||
if field.data <= self.start_time.data:
|
||||
raise ValidationError('End time must be after start time.')
|
||||
|
||||
# Check if duration is reasonable (max 8 hours)
|
||||
if field.data > self.start_time.data + timedelta(hours=8):
|
||||
raise ValidationError('Reservation cannot exceed 8 hours.')
|
||||
|
||||
def validate_start_time(self, field):
|
||||
# Don't allow new reservations in the past
|
||||
if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10):
|
||||
raise ValidationError('Start time cannot be in the past.')
|
||||
|
||||
# Don't allow reservations too far in the future (max 3 months)
|
||||
if field.data > datetime.now() + timedelta(days=90):
|
||||
raise ValidationError('Reservations cannot be made more than 3 months in advance.')
|
||||
@@ -0,0 +1,35 @@
|
||||
from .user import User
|
||||
from .building import Building
|
||||
from .classroom import Classroom, ClassroomResource
|
||||
from .career import Career
|
||||
from .academic_term import AcademicTerm
|
||||
from .subject import Subject, Commission
|
||||
from .reservation import Reservation, ReservationStatus
|
||||
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
|
||||
from .role import Role, Permission, SYSTEM_MODULES
|
||||
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
|
||||
from .audit_log import AuditLog
|
||||
from .enrollment import StudentEnrollment
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
'Building',
|
||||
'Classroom',
|
||||
'ClassroomResource',
|
||||
'Career',
|
||||
'AcademicTerm',
|
||||
'Subject',
|
||||
'Commission',
|
||||
'Reservation',
|
||||
'ReservationStatus',
|
||||
'GeneticAlgorithm',
|
||||
'ReservationOptimizer',
|
||||
'Role',
|
||||
'Permission',
|
||||
'SYSTEM_MODULES',
|
||||
'MilestoneType',
|
||||
'AcademicMilestone',
|
||||
'BASE_MILESTONE_TYPES',
|
||||
'AuditLog',
|
||||
'StudentEnrollment'
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class AcademicTerm(db.Model):
|
||||
"""Modelo para Ciclos Lectivos y Períodos Académicos"""
|
||||
__tablename__ = 'academic_terms'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # ej. 2026-2, 2026-1, 2025-2
|
||||
name = db.Column(db.String(150), nullable=False) # ej. Ciclo Lectivo 2026 - 2º Cuatrimestre
|
||||
year = db.Column(db.Integer, nullable=False, default=2026, index=True)
|
||||
period_type = db.Column(db.String(50), nullable=False, default='Segundo Cuatrimestre') # Primer Cuatrimestre, Segundo Cuatrimestre, Anual, Verano
|
||||
start_date = db.Column(db.Date, nullable=True)
|
||||
end_date = db.Column(db.Date, nullable=True)
|
||||
is_current = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'year': self.year,
|
||||
'period_type': self.period_type,
|
||||
'start_date': self.start_date.isoformat() if self.start_date else None,
|
||||
'end_date': self.end_date.isoformat() if self.end_date else None,
|
||||
'is_current': self.is_current,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AcademicTerm {self.code} - {self.name}>'
|
||||
@@ -0,0 +1,25 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class AuditLog(db.Model):
|
||||
"""
|
||||
Bitácora inmutable de auditoría para registro de eventos críticos
|
||||
y trazabilidad de operaciones institucionales.
|
||||
"""
|
||||
__tablename__ = 'audit_logs'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
user_email = db.Column(db.String(255), nullable=True)
|
||||
action = db.Column(db.String(50), nullable=False, index=True) # CREATE, UPDATE, DELETE, LOGIN, LOGOUT
|
||||
module = db.Column(db.String(50), nullable=False, index=True) # classrooms, reservations, auth, users
|
||||
entity_id = db.Column(db.Integer, nullable=True) # ID del registro afectado
|
||||
details = db.Column(db.Text, nullable=True) # Detalles de cambios o parámetros
|
||||
ip_address = db.Column(db.String(45), nullable=True) # Soporta IPv4 e IPv6
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relación con usuario
|
||||
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('audit_records', lazy='dynamic'))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AuditLog #{self.id} {self.action} on {self.module} by {self.user_email or 'System'}>"
|
||||
@@ -0,0 +1,45 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Building(db.Model):
|
||||
__tablename__ = 'buildings'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=True)
|
||||
address = db.Column(db.String(255), nullable=True)
|
||||
floors = db.Column(db.Integer, default=1, nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
|
||||
|
||||
@property
|
||||
def total_capacity(self):
|
||||
"""Total student capacity across active classrooms in this building"""
|
||||
return sum(c.capacity for c in self.classrooms if c.is_active)
|
||||
|
||||
@property
|
||||
def classrooms_count(self):
|
||||
"""Number of active classrooms"""
|
||||
return len([c for c in self.classrooms if c.is_active])
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'address': self.address,
|
||||
'floors': self.floors,
|
||||
'description': self.description,
|
||||
'is_active': self.is_active,
|
||||
'classrooms_count': self.classrooms_count,
|
||||
'total_capacity': self.total_capacity,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Building {self.name}>'
|
||||
@@ -0,0 +1,27 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Career(db.Model):
|
||||
__tablename__ = 'careers'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), unique=True, nullable=False, index=True)
|
||||
code = db.Column(db.String(50), nullable=True)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
subjects = db.relationship('Subject', backref='career_obj', lazy=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'description': self.description,
|
||||
'subjects_count': len(self.subjects) if self.subjects else 0,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Career {self.name}>'
|
||||
@@ -0,0 +1,217 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Classroom(db.Model):
|
||||
__tablename__ = 'classrooms'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
campus_id = db.Column(db.Integer, nullable=True) # Maps to campus_id in DB
|
||||
building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True)
|
||||
building = db.Column(db.String(100), nullable=False)
|
||||
code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB
|
||||
floor = db.Column(db.String(50), nullable=False, default='1')
|
||||
capacity = db.Column(db.Integer, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relación con Building (mapeada vía backref 'building_entity' en Building.classrooms)
|
||||
@property
|
||||
def building_rel(self):
|
||||
return self.building_entity
|
||||
|
||||
@property
|
||||
def building_name(self):
|
||||
"""Nombre del edificio desacoplado"""
|
||||
if self.building_rel:
|
||||
return self.building_rel.name
|
||||
return self.building or 'Edificio Central'
|
||||
|
||||
@property
|
||||
def floor_display(self):
|
||||
"""Formato amigable para el piso: PB, Planta Baja, Piso X"""
|
||||
f = str(self.floor).strip() if self.floor is not None else ''
|
||||
if f in ['0', 'PB', 'pb', 'Planta Baja', 'planta baja']:
|
||||
return 'Planta Baja'
|
||||
if f.lower().startswith('piso') or '°' in f:
|
||||
return f
|
||||
return f"Piso {f}"
|
||||
|
||||
@property
|
||||
def is_virtual(self):
|
||||
"""Indica si el aula es virtual"""
|
||||
return (self.code and 'VIRTUAL' in self.code.upper()) or (self.building and 'VIRTUAL' in self.building.upper())
|
||||
|
||||
@property
|
||||
def location_display(self):
|
||||
"""Ubicación completa y limpia: Edificio · Piso · Aula"""
|
||||
if self.is_virtual:
|
||||
return '🌐 Campus Virtual'
|
||||
return f"{self.building_name} · {self.floor_display} · {self.code}"
|
||||
|
||||
# Virtual properties for compatibility
|
||||
@property
|
||||
def name(self):
|
||||
"""Generate name from building and code"""
|
||||
return f"{self.building_name}-{self.code}"
|
||||
|
||||
@property
|
||||
def campus_location(self):
|
||||
"""Placeholder for campus location"""
|
||||
return self.building_rel.address if self.building_rel else None
|
||||
|
||||
# Virtual field for compatibility - combines building and code
|
||||
@property
|
||||
def room_number(self):
|
||||
return self.code
|
||||
|
||||
@room_number.setter
|
||||
def room_number(self, value):
|
||||
self.code = value
|
||||
|
||||
# Override code property for consistency
|
||||
@property
|
||||
def code_display(self):
|
||||
return f"{self.building_name} - {self.code}"
|
||||
|
||||
@property
|
||||
def capacity_display(self):
|
||||
"""Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero."""
|
||||
if self.capacity == 0 or self.is_virtual:
|
||||
return '∞'
|
||||
return str(self.capacity)
|
||||
|
||||
@property
|
||||
def is_unlimited(self):
|
||||
return self.capacity == 0 or self.is_virtual
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert classroom to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'building_id': self.building_id,
|
||||
'building': self.building_name,
|
||||
'code': self.code,
|
||||
'code_display': self.code_display,
|
||||
'name': self.name,
|
||||
'floor': self.floor,
|
||||
'floor_display': self.floor_display,
|
||||
'location_display': self.location_display,
|
||||
'capacity': self.capacity,
|
||||
'capacity_display': self.capacity_display,
|
||||
'is_virtual': self.is_virtual,
|
||||
'description': self.description,
|
||||
'campus_location': self.campus_location,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'resources': [resource.to_dict() for resource in self.resources] if hasattr(self, 'resources') else []
|
||||
}
|
||||
|
||||
def get_full_location(self):
|
||||
"""Get full classroom location"""
|
||||
if self.is_virtual:
|
||||
return 'Campus Virtual (Remoto)'
|
||||
return f"{self.building_name} - {self.floor_display}"
|
||||
|
||||
@staticmethod
|
||||
def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None):
|
||||
"""Find the best suitable classroom for a commission based on capacity and availability"""
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
|
||||
# Get all active classrooms with sufficient capacity
|
||||
suitable_classrooms = Classroom.query.filter(
|
||||
Classroom.is_active == True,
|
||||
Classroom.capacity >= max_students
|
||||
).order_by(Classroom.capacity.asc()).all() # Start with smallest suitable
|
||||
|
||||
# Check availability for each classroom
|
||||
available_classrooms = []
|
||||
for classroom in suitable_classrooms:
|
||||
# Check for conflicts
|
||||
conflicts = Reservation.find_conflicts(
|
||||
classroom.id, start_time, end_time, exclude_reservation_id
|
||||
)
|
||||
|
||||
if not conflicts:
|
||||
# Calculate efficiency score (how well the capacity matches)
|
||||
efficiency = classroom.capacity / max_students if max_students > 0 else float('inf')
|
||||
available_classrooms.append({
|
||||
'classroom': classroom,
|
||||
'efficiency': efficiency,
|
||||
'wasted_capacity': classroom.capacity - max_students
|
||||
})
|
||||
|
||||
# Sort by efficiency (closest capacity match first)
|
||||
available_classrooms.sort(key=lambda x: x['efficiency'])
|
||||
|
||||
return available_classrooms
|
||||
|
||||
@staticmethod
|
||||
def get_availability_status(start_time, end_time, exclude_reservation_id=None):
|
||||
"""Get all classrooms classified as available or occupied for a time slot"""
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
|
||||
all_classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
available = []
|
||||
occupied = []
|
||||
|
||||
for classroom in all_classrooms:
|
||||
conflicts = Reservation.find_conflicts(
|
||||
classroom.id, start_time, end_time, exclude_reservation_id
|
||||
)
|
||||
|
||||
classroom_info = {
|
||||
'classroom': classroom,
|
||||
'conflicts': conflicts
|
||||
}
|
||||
|
||||
if conflicts:
|
||||
occupied.append(classroom_info)
|
||||
else:
|
||||
available.append(classroom_info)
|
||||
|
||||
return {
|
||||
'available': available,
|
||||
'occupied': occupied,
|
||||
'total_available': len(available),
|
||||
'total_occupied': len(occupied)
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Classroom {self.code_display}>'
|
||||
|
||||
class ClassroomResource(db.Model):
|
||||
__tablename__ = 'classroom_resources'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
classroom_id = db.Column(db.Integer, db.ForeignKey('classrooms.id'), nullable=False)
|
||||
resource_type = db.Column(db.String(50), nullable=False) # PROJECTOR, WHITEBOARD, COMPUTER, etc.
|
||||
brand = db.Column(db.String(100))
|
||||
model = db.Column(db.String(100))
|
||||
specifications = db.Column(db.Text)
|
||||
functional = db.Column(db.Boolean, default=True)
|
||||
last_maintenance_date = db.Column(db.DateTime)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
classroom = db.relationship('Classroom', backref=db.backref('resources', lazy=True, cascade='all, delete-orphan'))
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert resource to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'classroom_id': self.classroom_id,
|
||||
'resource_type': self.resource_type,
|
||||
'brand': self.brand,
|
||||
'model': self.model,
|
||||
'specifications': self.specifications,
|
||||
'functional': self.functional,
|
||||
'last_maintenance_date': self.last_maintenance_date.isoformat() if self.last_maintenance_date else None,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Resource {self.resource_type} for Classroom {self.classroom_id}>'
|
||||
@@ -0,0 +1,49 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class StudentEnrollment(db.Model):
|
||||
"""Association table: student (User) ↔ Commission enrollment managed by Bedelía."""
|
||||
__tablename__ = 'student_enrollments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
student_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
commission_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('commissions.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
status = db.Column(db.String(20), nullable=False, default='activo') # activo | retirado | condicional
|
||||
enrolled_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Unique constraint: a student can only be enrolled once per commission
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('student_id', 'commission_id', name='uq_student_commission'),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
student = db.relationship('User', backref=db.backref('enrollments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
commission = db.relationship('Commission', backref=db.backref('enrollments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'student_id': self.student_id,
|
||||
'student_name': self.student.name if self.student else None,
|
||||
'student_email': self.student.email if self.student else None,
|
||||
'commission_id': self.commission_id,
|
||||
'status': self.status,
|
||||
'enrolled_at': self.enrolled_at.isoformat() if self.enrolled_at else None,
|
||||
'notes': self.notes,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<StudentEnrollment student={self.student_id} commission={self.commission_id} status={self.status}>'
|
||||
@@ -0,0 +1,435 @@
|
||||
import random
|
||||
import copy
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
from app import db
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.subject import Commission
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReservationRequest:
|
||||
"""Represents a reservation request from the teaching committee"""
|
||||
commission_id: int
|
||||
expected_attendees: int
|
||||
purpose: str
|
||||
preferred_start_time: datetime
|
||||
preferred_end_time: datetime
|
||||
priority: int = 1 # 1=highest, 5=lowest
|
||||
flexibility_hours: int = 2 # How flexible the start time can be
|
||||
subject_requirements: Dict = None # Special requirements for the subject
|
||||
|
||||
def __post_init__(self):
|
||||
if self.subject_requirements is None:
|
||||
self.subject_requirements = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Gene:
|
||||
"""Represents a single reservation assignment"""
|
||||
commission_id: int
|
||||
classroom_id: int
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
expected_attendees: int
|
||||
purpose: str
|
||||
|
||||
|
||||
class Individual:
|
||||
"""Represents a complete reservation schedule (chromosome)"""
|
||||
|
||||
def __init__(self, genes: List[Gene]):
|
||||
self.genes = genes
|
||||
self.fitness = 0.0
|
||||
self.conflicts = []
|
||||
|
||||
def calculate_fitness(self, classrooms: Dict[int, Classroom], requests: Dict[int, ReservationRequest]) -> float:
|
||||
"""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)
|
||||
|
||||
if not classroom or not request:
|
||||
continue
|
||||
|
||||
# Factor 1: Capacity efficiency (40% weight)
|
||||
capacity_score = self._calculate_capacity_score(classroom, gene.expected_attendees)
|
||||
score += capacity_score * 0.4
|
||||
|
||||
# Factor 2: Time preference satisfaction (30% weight)
|
||||
time_score = self._calculate_time_score(gene, request)
|
||||
score += time_score * 0.3
|
||||
|
||||
# Factor 3: Conflict penalty (20% weight)
|
||||
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
|
||||
|
||||
self.fitness = score
|
||||
return score
|
||||
|
||||
def _calculate_capacity_score(self, classroom: Classroom, expected_attendees: int) -> float:
|
||||
"""Calculate how well the classroom capacity matches the expected attendees"""
|
||||
if classroom.capacity < expected_attendees:
|
||||
return 0.0 # Overfilled classroom
|
||||
|
||||
# Calculate efficiency: closer capacity match = higher score
|
||||
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
|
||||
return 0.8
|
||||
elif waste_ratio <= 0.5: # Less than 50% waste
|
||||
return 0.6
|
||||
else:
|
||||
return 0.4
|
||||
|
||||
def _calculate_time_score(self, gene: Gene, request: ReservationRequest) -> float:
|
||||
"""Calculate how well the assigned time matches preferences"""
|
||||
# Calculate time difference from preferred time
|
||||
time_diff = abs((gene.start_time - request.preferred_start_time).total_seconds() / 3600)
|
||||
|
||||
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 / 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 (kept for backward compatibility)"""
|
||||
conflicts = 0
|
||||
for other in all_genes:
|
||||
if gene != other and gene.classroom_id == other.classroom_id:
|
||||
if self._times_overlap(gene.start_time, gene.end_time, other.start_time, other.end_time):
|
||||
conflicts += 1
|
||||
|
||||
if conflicts > 0:
|
||||
return -conflicts * 0.5 # Penalty for each conflict
|
||||
return 1.0
|
||||
|
||||
def _calculate_resource_score(self, classroom: Classroom, requirements: Dict) -> float:
|
||||
"""Check if classroom meets subject requirements"""
|
||||
if not requirements:
|
||||
return 1.0
|
||||
return 0.8
|
||||
|
||||
def _times_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool:
|
||||
"""Check if two time periods overlap"""
|
||||
return start1 < end2 and end1 > start2
|
||||
|
||||
|
||||
class GeneticAlgorithm:
|
||||
"""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,
|
||||
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,
|
||||
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}
|
||||
|
||||
# Initialize population
|
||||
population = self._initialize_population(requests, available_classrooms, start_date, end_date)
|
||||
|
||||
# Evolve population
|
||||
for generation in range(self.generations):
|
||||
# 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 = max(2, int(self.population_size * 0.1))
|
||||
new_population.extend(population[:elite_size])
|
||||
|
||||
# Crossover and mutation for remaining
|
||||
while len(new_population) < self.population_size:
|
||||
if random.random() < self.crossover_rate:
|
||||
parent1 = self._tournament_selection(population)
|
||||
parent2 = self._tournament_selection(population)
|
||||
child1, child2 = self._crossover(parent1, parent2)
|
||||
|
||||
# Mutate children
|
||||
if random.random() < self.mutation_rate:
|
||||
child1 = self._mutate(child1, available_classrooms, start_date, end_date)
|
||||
if random.random() < self.mutation_rate:
|
||||
child2 = self._mutate(child2, available_classrooms, start_date, end_date)
|
||||
|
||||
new_population.extend([child1, child2])
|
||||
else:
|
||||
# Direct copy with potential mutation
|
||||
selected = self._tournament_selection(population)
|
||||
if random.random() < self.mutation_rate:
|
||||
selected = self._mutate(selected, available_classrooms, start_date, end_date)
|
||||
new_population.append(selected)
|
||||
|
||||
population = new_population[:self.population_size]
|
||||
|
||||
# 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]
|
||||
|
||||
def _initialize_population(self, requests: List[ReservationRequest],
|
||||
classrooms: List[Classroom],
|
||||
start_date: datetime, end_date: datetime) -> List[Individual]:
|
||||
"""Create initial population with random assignments"""
|
||||
population = []
|
||||
|
||||
for _ in range(self.population_size):
|
||||
genes = []
|
||||
for request in requests:
|
||||
# Random classroom selection (with capacity constraint)
|
||||
suitable_classrooms = [c for c in classrooms if c.capacity >= request.expected_attendees]
|
||||
if not suitable_classrooms:
|
||||
continue
|
||||
|
||||
classroom = random.choice(suitable_classrooms)
|
||||
|
||||
# Random time slot (within flexible window)
|
||||
time_offset = random.uniform(-request.flexibility_hours, request.flexibility_hours)
|
||||
start_time = request.preferred_start_time + timedelta(hours=time_offset)
|
||||
duration = request.preferred_end_time - request.preferred_start_time
|
||||
end_time = start_time + duration
|
||||
|
||||
gene = Gene(
|
||||
commission_id=request.commission_id,
|
||||
classroom_id=classroom.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
expected_attendees=request.expected_attendees,
|
||||
purpose=request.purpose
|
||||
)
|
||||
genes.append(gene)
|
||||
|
||||
population.append(Individual(genes))
|
||||
|
||||
return population
|
||||
|
||||
def _tournament_selection(self, population: List[Individual], tournament_size: int = 3) -> Individual:
|
||||
"""Select individual using tournament selection"""
|
||||
tournament = random.sample(population, min(tournament_size, len(population)))
|
||||
return max(tournament, key=lambda x: x.fitness)
|
||||
|
||||
def _crossover(self, parent1: Individual, parent2: Individual) -> Tuple[Individual, Individual]:
|
||||
"""Perform crossover between two parents"""
|
||||
# Simple one-point crossover
|
||||
if len(parent1.genes) <= 1 or len(parent2.genes) <= 1:
|
||||
return Individual(copy.deepcopy(parent1.genes)), Individual(copy.deepcopy(parent2.genes))
|
||||
|
||||
crossover_point = random.randint(1, min(len(parent1.genes), len(parent2.genes)) - 1)
|
||||
|
||||
child1_genes = copy.deepcopy(parent1.genes[:crossover_point] + parent2.genes[crossover_point:])
|
||||
child2_genes = copy.deepcopy(parent2.genes[:crossover_point] + parent1.genes[crossover_point:])
|
||||
|
||||
return Individual(child1_genes), Individual(child2_genes)
|
||||
|
||||
def _mutate(self, individual: Individual, classrooms: List[Classroom],
|
||||
start_date: datetime, end_date: datetime) -> Individual:
|
||||
"""Apply mutation to an individual"""
|
||||
if not individual.genes:
|
||||
return individual
|
||||
|
||||
# Random gene mutation
|
||||
gene_to_mutate = random.choice(individual.genes)
|
||||
|
||||
# Mutation types
|
||||
mutation_type = random.choice(['classroom', 'time'])
|
||||
|
||||
if mutation_type == 'classroom':
|
||||
# Change classroom
|
||||
suitable_classrooms = [c for c in classrooms if c.capacity >= gene_to_mutate.expected_attendees]
|
||||
if suitable_classrooms:
|
||||
gene_to_mutate.classroom_id = random.choice(suitable_classrooms).id
|
||||
|
||||
elif mutation_type == 'time':
|
||||
# Slightly adjust time
|
||||
time_adjustment = timedelta(hours=random.uniform(-1, 1))
|
||||
gene_to_mutate.start_time += time_adjustment
|
||||
gene_to_mutate.end_time += time_adjustment
|
||||
|
||||
return individual
|
||||
|
||||
|
||||
class ReservationOptimizer:
|
||||
"""High-level interface for the reservation optimization system"""
|
||||
|
||||
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,
|
||||
progress_callback = None) -> Dict:
|
||||
"""Optimize reservation schedule for given commissions"""
|
||||
|
||||
# Default date range if not provided
|
||||
if not start_date:
|
||||
start_date = datetime.utcnow()
|
||||
if not end_date:
|
||||
end_date = start_date + timedelta(days=30)
|
||||
|
||||
# Get commissions and create requests
|
||||
commissions = Commission.query.filter(
|
||||
Commission.id.in_(commission_ids),
|
||||
Commission.active == True
|
||||
).all()
|
||||
|
||||
requests = []
|
||||
for commission in commissions:
|
||||
# Calculate preferred time based on semester/year
|
||||
preferred_time = self._calculate_preferred_time(commission)
|
||||
|
||||
request = ReservationRequest(
|
||||
commission_id=commission.id,
|
||||
expected_attendees=commission.max_students,
|
||||
purpose=f"Regular class - {commission.subject.name if commission.subject else 'Unknown subject'}",
|
||||
preferred_start_time=preferred_time['start'],
|
||||
preferred_end_time=preferred_time['end'],
|
||||
priority=1, # All regular classes have same priority
|
||||
flexibility_hours=2
|
||||
)
|
||||
requests.append(request)
|
||||
|
||||
# Get available classrooms
|
||||
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
|
||||
# Run genetic algorithm
|
||||
best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date, progress_callback=progress_callback)
|
||||
|
||||
# Convert to reservation format
|
||||
optimized_reservations = []
|
||||
for gene in best_individual.genes:
|
||||
reservation_data = {
|
||||
'commission_id': gene.commission_id,
|
||||
'classroom_id': gene.classroom_id,
|
||||
'user_id': admin_user_id,
|
||||
'start_time': gene.start_time.isoformat() if gene.start_time else None,
|
||||
'end_time': gene.end_time.isoformat() if gene.end_time else None,
|
||||
'purpose': gene.purpose,
|
||||
'expected_attendees': gene.expected_attendees,
|
||||
'status': ReservationStatus.PENDING.value # Convert enum to string
|
||||
}
|
||||
optimized_reservations.append(reservation_data)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'fitness_score': best_individual.fitness,
|
||||
'reservations': optimized_reservations,
|
||||
'conflicts': best_individual.conflicts,
|
||||
'total_requests': len(requests),
|
||||
'assigned_reservations': len(optimized_reservations)
|
||||
}
|
||||
|
||||
def _calculate_preferred_time(self, commission: Commission) -> Dict:
|
||||
"""Calculate preferred time slots for a commission"""
|
||||
# This is a simplified version - would need to be based on actual semester schedule
|
||||
current_date = datetime.utcnow()
|
||||
|
||||
# Default to weekday mornings for regular classes
|
||||
days_ahead = (0 - current_date.weekday()) % 7 # Next Monday
|
||||
if days_ahead == 0: # If today is Monday, use next week
|
||||
days_ahead = 7
|
||||
|
||||
preferred_date = current_date + timedelta(days=days_ahead)
|
||||
|
||||
return {
|
||||
'start': preferred_date.replace(hour=9, minute=0, second=0, microsecond=0),
|
||||
'end': preferred_date.replace(hour=11, minute=0, second=0, microsecond=0)
|
||||
}
|
||||
|
||||
def apply_optimized_reservations(self, reservation_data: List[Dict]) -> List[Reservation]:
|
||||
"""Apply the optimized reservations to the database"""
|
||||
applied_reservations = []
|
||||
|
||||
for data in reservation_data:
|
||||
# Check for conflicts before creating
|
||||
conflicts = Reservation.find_conflicts(
|
||||
data['classroom_id'],
|
||||
data['start_time'],
|
||||
data['end_time']
|
||||
)
|
||||
|
||||
if not conflicts:
|
||||
reservation = Reservation(**data)
|
||||
db.session.add(reservation)
|
||||
applied_reservations.append(reservation)
|
||||
else:
|
||||
# Log conflict - could be handled differently
|
||||
print(f"Conflict detected for reservation data: {data}")
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
return applied_reservations
|
||||
@@ -0,0 +1,180 @@
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
class MilestoneType(db.Model):
|
||||
"""
|
||||
Catálogo dinámico y administrable de tipificaciones de hitos y evaluaciones académicas.
|
||||
(Parciales, Trabajos Prácticos, Recuperatorios, Finales, etc.)
|
||||
"""
|
||||
__tablename__ = 'milestone_types'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False) # Ej: "Parcial 1", "Trabajo Práctico 1"
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # Ej: "parcial_1", "tp_1"
|
||||
color = db.Column(db.String(30), default='#0d6efd') # Hex color para badges y calendario
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
sort_order = db.Column(db.Integer, default=0) # Orden de aparición en selects
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False) # Activo / Inactivo
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relación uno-a-muchos con AcademicMilestone
|
||||
milestones = db.relationship('AcademicMilestone', backref='type_obj', lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'color': self.color or '#0d6efd',
|
||||
'description': self.description or '',
|
||||
'sort_order': self.sort_order or 0,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<MilestoneType {self.code}: {self.name}>'
|
||||
|
||||
|
||||
class AcademicMilestone(db.Model):
|
||||
"""
|
||||
Hito o evento académico programado para una materia/comisión (Examen, TP, Entrega).
|
||||
"""
|
||||
__tablename__ = 'academic_milestones'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(150), nullable=True)
|
||||
materia_codigo = db.Column(db.String(50), nullable=False, index=True) # Código de materia (ej: INGA0003)
|
||||
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=True)
|
||||
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='SET NULL'), nullable=True)
|
||||
milestone_type_id = db.Column(db.Integer, db.ForeignKey('milestone_types.id'), nullable=False)
|
||||
date = db.Column(db.DateTime, nullable=False) # Fecha y hora del hito
|
||||
classroom = db.Column(db.String(100), nullable=True) # Aula o enlace virtual asignado
|
||||
link = db.Column(db.String(255), nullable=True) # Link Teams / Meet / Zoom si aplica
|
||||
notes = db.Column(db.Text, nullable=True) # Temario, consignas o notas
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relación con Asignatura y Comisión
|
||||
subject = db.relationship('Subject', backref=db.backref('milestones', lazy='dynamic'))
|
||||
commission = db.relationship('Commission', backref=db.backref('academic_milestones', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
|
||||
@property
|
||||
def tipo(self):
|
||||
return self.type_obj.name if self.type_obj else 'Otro'
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
return self.type_obj.color if self.type_obj and self.type_obj.color else '#0d6efd'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'title': self.title or self.tipo,
|
||||
'materiaCodigo': self.materia_codigo,
|
||||
'subject_id': self.subject_id,
|
||||
'commission_id': self.commission_id,
|
||||
'tipo': self.tipo,
|
||||
'milestone_type_id': self.milestone_type_id,
|
||||
'tipo_info': self.type_obj.to_dict() if self.type_obj else None,
|
||||
'color': self.color,
|
||||
'fecha': self.date.isoformat() if self.date else None,
|
||||
'aula': self.classroom,
|
||||
'link': self.link,
|
||||
'notas': self.notes,
|
||||
'is_active': self.is_active
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AcademicMilestone {self.materia_codigo} - {self.tipo} ({self.date})>'
|
||||
|
||||
|
||||
# Datos semilla estándar para inicialización
|
||||
BASE_MILESTONE_TYPES = [
|
||||
{
|
||||
'name': 'Parcial 1',
|
||||
'code': 'parcial_1',
|
||||
'color': '#0d6efd',
|
||||
'description': 'Primer examen parcial de la cursada',
|
||||
'sort_order': 1
|
||||
},
|
||||
{
|
||||
'name': 'Parcial 2',
|
||||
'code': 'parcial_2',
|
||||
'color': '#0b5ed7',
|
||||
'description': 'Segundo examen parcial de la cursada',
|
||||
'sort_order': 2
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Practico 1',
|
||||
'code': 'tp_1',
|
||||
'color': '#198754',
|
||||
'description': 'Primera entrega o evaluacion practica',
|
||||
'sort_order': 3
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Practico 2',
|
||||
'code': 'tp_2',
|
||||
'color': '#157347',
|
||||
'description': 'Segunda entrega o evaluacion practica',
|
||||
'sort_order': 4
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Practico 3',
|
||||
'code': 'tp_3',
|
||||
'color': '#0f5132',
|
||||
'description': 'Tercera entrega o proyecto integrador',
|
||||
'sort_order': 5
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio Parcial 1',
|
||||
'code': 'recu_p1',
|
||||
'color': '#fd7e14',
|
||||
'description': 'Instancia de recuperacion para el Parcial 1',
|
||||
'sort_order': 6
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio Parcial 2',
|
||||
'code': 'recu_p2',
|
||||
'color': '#e76b08',
|
||||
'description': 'Instancia de recuperacion para el Parcial 2',
|
||||
'sort_order': 7
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 1',
|
||||
'code': 'recu_tp1',
|
||||
'color': '#ffc107',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 1',
|
||||
'sort_order': 8
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 2',
|
||||
'code': 'recu_tp2',
|
||||
'color': '#e0a800',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 2',
|
||||
'sort_order': 9
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 3',
|
||||
'code': 'recu_tp3',
|
||||
'color': '#c69500',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 3',
|
||||
'sort_order': 10
|
||||
},
|
||||
{
|
||||
'name': 'Final',
|
||||
'code': 'final',
|
||||
'color': '#dc3545',
|
||||
'description': 'Examen final regular o libre',
|
||||
'sort_order': 11
|
||||
},
|
||||
{
|
||||
'name': 'Otro',
|
||||
'code': 'otro',
|
||||
'color': '#6c757d',
|
||||
'description': 'Taller, seminario, clase especial o hito no tipificado',
|
||||
'sort_order': 12
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,169 @@
|
||||
from app import db
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from sqlalchemy.types import TypeDecorator, String
|
||||
|
||||
class StatusString(str):
|
||||
"""String wrapper providing .value property for backward compatibility with Enum usage"""
|
||||
@property
|
||||
def value(self):
|
||||
return str(self)
|
||||
|
||||
def __eq__(self, other):
|
||||
if hasattr(other, 'value'):
|
||||
return str(self) == str(other.value)
|
||||
return str(self) == str(other)
|
||||
|
||||
class StatusType(TypeDecorator):
|
||||
impl = String(20)
|
||||
cache_ok = True
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
return StatusString(value) if value is not None else None
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if hasattr(value, 'value'):
|
||||
return str(value.value)
|
||||
return str(value) if value is not None else None
|
||||
|
||||
class ReservationStatus(Enum):
|
||||
PENDING = 'PENDING'
|
||||
CONFIRMED = 'CONFIRMED'
|
||||
CANCELLED = 'CANCELLED'
|
||||
COMPLETED = 'COMPLETED'
|
||||
NO_SHOW = 'NO_SHOW'
|
||||
|
||||
class Reservation(db.Model):
|
||||
__tablename__ = 'reservations'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
classroom_id = db.Column(db.Integer, db.ForeignKey('classrooms.id'), nullable=False)
|
||||
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id'), nullable=False)
|
||||
user_id = db.Column('teacher_id', db.Integer, db.ForeignKey('users.id'), nullable=False) # Maps to teacher_id in DB
|
||||
|
||||
start_time = db.Column(db.DateTime, nullable=False)
|
||||
end_time = db.Column(db.DateTime, nullable=False)
|
||||
purpose = db.Column(db.String(500), nullable=False)
|
||||
status = db.Column(StatusType, default='PENDING', nullable=False) # Maps to VARCHAR(20) with StatusString
|
||||
expected_attendees = db.Column(db.Integer, nullable=False)
|
||||
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||
virtual_link = db.Column(db.String(500), nullable=True) # Zoom, Meet, Teams, etc.
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Virtual property to convert status string to enum
|
||||
@property
|
||||
def status_enum(self):
|
||||
"""Get status as enum"""
|
||||
try:
|
||||
return ReservationStatus(str(self.status))
|
||||
except (ValueError, AttributeError):
|
||||
return ReservationStatus.PENDING
|
||||
|
||||
@property
|
||||
def subject_code(self):
|
||||
"""Get exact subject code (e.g. INGA0003, ASIG00165)"""
|
||||
if self.commission and self.commission.subject:
|
||||
return self.commission.subject.code
|
||||
return ''
|
||||
|
||||
@property
|
||||
def subject_name(self):
|
||||
"""Get subject name"""
|
||||
if self.commission and self.commission.subject:
|
||||
return self.commission.subject.name
|
||||
return self.purpose or ''
|
||||
|
||||
@property
|
||||
def classroom_name(self):
|
||||
"""Get classroom code or name"""
|
||||
if self.classroom:
|
||||
return self.classroom.code
|
||||
return ''
|
||||
|
||||
@property
|
||||
def effective_virtual_link(self):
|
||||
"""Devuelve el enlace explícito de la reserva o hereda el de la comisión"""
|
||||
if self.virtual_link and self.virtual_link.strip():
|
||||
return self.virtual_link.strip()
|
||||
if self.commission and self.commission.virtual_link:
|
||||
return self.commission.virtual_link.strip()
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_virtual(self):
|
||||
"""Verifica si la reserva es virtual por aula o por contar con enlace virtual"""
|
||||
if self.classroom and self.classroom.is_virtual:
|
||||
return True
|
||||
if self.effective_virtual_link:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Relationships
|
||||
classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
|
||||
commission = db.relationship('Commission', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
|
||||
# User already defines the relationship back to reservation
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert reservation to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'classroom_id': self.classroom_id,
|
||||
'commission_id': self.commission_id,
|
||||
'user_id': self.user_id,
|
||||
'start_time': self.start_time.isoformat() if self.start_time else None,
|
||||
'end_time': self.end_time.isoformat() if self.end_time else None,
|
||||
'purpose': self.purpose,
|
||||
'status': self.status, # Already a string in DB
|
||||
'expected_attendees': self.expected_attendees,
|
||||
'notes': self.notes,
|
||||
'virtual_link': self.effective_virtual_link,
|
||||
'is_virtual': self.is_virtual,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'classroom': self.classroom.to_dict() if self.classroom else None,
|
||||
'commission': self.commission.to_dict() if self.commission else None,
|
||||
'user': self.user.to_dict() if self.user else None
|
||||
}
|
||||
|
||||
def is_confirmed(self):
|
||||
"""Check if reservation is confirmed"""
|
||||
return self.status == ReservationStatus.CONFIRMED.value
|
||||
|
||||
def can_be_cancelled(self):
|
||||
"""Check if reservation can be cancelled (24 hours before start)"""
|
||||
from datetime import timedelta
|
||||
return self.start_time > datetime.utcnow() + timedelta(hours=24)
|
||||
|
||||
def check_conflict(self, other_reservation):
|
||||
"""Check if this reservation conflicts with another"""
|
||||
if self.classroom_id != other_reservation.classroom_id:
|
||||
return False
|
||||
if self.status != ReservationStatus.CONFIRMED.value:
|
||||
return False
|
||||
if other_reservation.status != ReservationStatus.CONFIRMED.value:
|
||||
return False
|
||||
|
||||
# Check time overlap
|
||||
return (self.start_time < other_reservation.end_time and
|
||||
self.end_time > other_reservation.start_time)
|
||||
|
||||
@staticmethod
|
||||
def find_conflicts(classroom_id, start_time, end_time, exclude_reservation_id=None):
|
||||
"""Find conflicting reservations for a specific classroom and time slot"""
|
||||
query = Reservation.query.filter(
|
||||
Reservation.classroom_id == classroom_id,
|
||||
Reservation.status == ReservationStatus.CONFIRMED.value,
|
||||
Reservation.start_time < end_time,
|
||||
Reservation.end_time > start_time
|
||||
)
|
||||
|
||||
if exclude_reservation_id:
|
||||
query = query.filter(Reservation.id != exclude_reservation_id)
|
||||
|
||||
return query.all()
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Reservation {self.id}>'
|
||||
@@ -0,0 +1,163 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
# Definition of system modules available for RBAC permissions
|
||||
SYSTEM_MODULES = [
|
||||
{
|
||||
'id': 'classrooms',
|
||||
'name': 'Aulas y Espacios',
|
||||
'name_en': 'Classrooms & Spaces',
|
||||
'icon': 'bi-door-open',
|
||||
'description': 'Gestión de aulas físicas, aulas virtuales (∞) y capacidades.'
|
||||
},
|
||||
{
|
||||
'id': 'buildings',
|
||||
'name': 'Edificios y Sedes',
|
||||
'name_en': 'Buildings & Campuses',
|
||||
'icon': 'bi-buildings',
|
||||
'description': 'Administración de sede central, campus virtual, pisos y ubicaciones.'
|
||||
},
|
||||
{
|
||||
'id': 'reservations',
|
||||
'name': 'Reservas de Aulas',
|
||||
'name_en': 'Classroom Reservations',
|
||||
'icon': 'bi-calendar-check',
|
||||
'description': 'Creación, modificación, aprobación, confirmación y cancelación de reservas.'
|
||||
},
|
||||
{
|
||||
'id': 'schedule',
|
||||
'name': 'Cartelera y Cronograma',
|
||||
'name_en': 'Schedule & Billboard',
|
||||
'icon': 'bi-calendar3',
|
||||
'description': 'Visualización de calendario interactivo, cartelera de hoy y comisiones.'
|
||||
},
|
||||
{
|
||||
'id': 'academic',
|
||||
'name': 'Gestión Académica',
|
||||
'name_en': 'Academic Management',
|
||||
'icon': 'bi-book',
|
||||
'description': 'Administración de carreras, asignaturas, códigos y comisiones.'
|
||||
},
|
||||
{
|
||||
'id': 'import',
|
||||
'name': 'Sincronización Sheets',
|
||||
'name_en': 'Sheets Sync & Import',
|
||||
'icon': 'bi-file-earmark-spreadsheet',
|
||||
'description': 'Importación y sincronización de datos académicos desde Google Sheets.'
|
||||
},
|
||||
{
|
||||
'id': 'optimizer',
|
||||
'name': 'Optimizador IA',
|
||||
'name_en': 'AI Room Optimizer',
|
||||
'icon': 'bi-cpu',
|
||||
'description': 'Ejecución y configuración del algoritmo genético de asignación.'
|
||||
},
|
||||
{
|
||||
'id': 'users',
|
||||
'name': 'Usuarios y Accesos RBAC',
|
||||
'name_en': 'Users & Access RBAC',
|
||||
'icon': 'bi-shield-lock',
|
||||
'description': 'Administración de cuentas, roles y matriz granular de permisos.'
|
||||
},
|
||||
{
|
||||
'id': 'milestone_types',
|
||||
'name': 'Tipificaciones de Hitos',
|
||||
'name_en': 'Milestone Types',
|
||||
'icon': 'bi-tags',
|
||||
'description': 'Catálogo dinámico de tipos de evaluaciones, parciales, TPs y eventos.'
|
||||
},
|
||||
{
|
||||
'id': 'metrics',
|
||||
'name': 'Métricas de Ocupación',
|
||||
'name_en': 'Occupancy Metrics',
|
||||
'icon': 'bi-graph-up',
|
||||
'description': 'Visualización y análisis de porcentajes de ocupación por aula (día, semana y mes).'
|
||||
},
|
||||
{
|
||||
'id': 'enrollment',
|
||||
'name': 'Inscripciones y Cursadas',
|
||||
'name_en': 'Enrollments & Courses',
|
||||
'icon': 'bi-diagram-3',
|
||||
'description': 'Gestión de inscripciones de alumnos a comisiones y asignación de docentes.'
|
||||
}
|
||||
]
|
||||
|
||||
ACCESS_LEVELS = ['none', 'read', 'read_write']
|
||||
|
||||
class Role(db.Model):
|
||||
__tablename__ = 'roles'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||
description = db.Column(db.String(255))
|
||||
is_system = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
permissions = db.relationship('Permission', backref='role', lazy='joined', cascade='all, delete-orphan')
|
||||
users = db.relationship('User', backref='role_obj', lazy='select')
|
||||
|
||||
def get_permission(self, module):
|
||||
"""Get access level string for a given module ('none', 'read', 'read_write')"""
|
||||
for p in self.permissions:
|
||||
if p.module == module:
|
||||
return p.access_level
|
||||
return 'none'
|
||||
|
||||
def has_permission(self, module, min_level='read'):
|
||||
"""
|
||||
Check if role has sufficient permission level.
|
||||
Hierarchy: 'read_write' > 'read' > 'none'
|
||||
"""
|
||||
current_level = self.get_permission(module)
|
||||
if current_level == 'none':
|
||||
return False
|
||||
if min_level == 'read':
|
||||
return current_level in ['read', 'read_write']
|
||||
if min_level == 'read_write':
|
||||
return current_level == 'read_write'
|
||||
return False
|
||||
|
||||
def set_permission(self, module, access_level):
|
||||
"""Set or update permission level for a module"""
|
||||
if access_level not in ACCESS_LEVELS:
|
||||
access_level = 'none'
|
||||
|
||||
for p in self.permissions:
|
||||
if p.module == module:
|
||||
p.access_level = access_level
|
||||
return p
|
||||
|
||||
new_perm = Permission(role=self, module=module, access_level=access_level)
|
||||
db.session.add(new_perm)
|
||||
return new_perm
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'is_system': self.is_system,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'user_count': len(self.users) if self.users else 0,
|
||||
'permissions': {p.module: p.access_level for p in self.permissions}
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Role {self.name}>'
|
||||
|
||||
|
||||
class Permission(db.Model):
|
||||
__tablename__ = 'permissions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
module = db.Column(db.String(50), nullable=False)
|
||||
access_level = db.Column(db.String(20), default='none', nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('role_id', 'module', name='uq_role_module'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Permission role={self.role_id} module={self.module} level={self.access_level}>'
|
||||
@@ -0,0 +1,118 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Subject(db.Model):
|
||||
__tablename__ = 'subjects'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
department = db.Column(db.String(100), nullable=False, default='')
|
||||
credits = db.Column(db.Integer, nullable=False, default=4)
|
||||
career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
|
||||
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert subject to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'department': self.department,
|
||||
'credits': self.credits,
|
||||
'career_id': self.career_id,
|
||||
'career_name': self.career_obj.name if self.career_obj else None,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Subject {self.code}>'
|
||||
|
||||
class Commission(db.Model):
|
||||
__tablename__ = 'commissions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
|
||||
code = db.Column(db.String(50), nullable=False)
|
||||
semester = db.Column(db.String(20), nullable=False)
|
||||
year = db.Column(db.Integer, nullable=False)
|
||||
teacher_id = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
max_students = db.Column(db.Integer, nullable=False)
|
||||
current_students = db.Column(db.Integer, default=0)
|
||||
schedule = db.Column(db.String(255))
|
||||
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
|
||||
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Virtual property for compatibility
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.active
|
||||
|
||||
@is_active.setter
|
||||
def is_active(self, value):
|
||||
self.active = value
|
||||
|
||||
# Relationships
|
||||
pass # Reservation already defines the relationship back to commission
|
||||
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
|
||||
|
||||
# Virtual field for teacher_name
|
||||
@property
|
||||
def teacher_name(self):
|
||||
return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD"
|
||||
|
||||
# Unique constraint
|
||||
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert commission to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'subject_id': self.subject_id,
|
||||
'code': self.code,
|
||||
'semester': self.semester,
|
||||
'year': self.year,
|
||||
'teacher_name': self.teacher_name,
|
||||
'max_students': self.max_students,
|
||||
'current_students': self.current_students,
|
||||
'schedule': self.schedule,
|
||||
'shift': self.shift,
|
||||
'virtual_link': self.virtual_link,
|
||||
'active': self.active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'full_code': self.get_full_code(),
|
||||
'subject': self.subject.to_dict() if self.subject else None
|
||||
}
|
||||
|
||||
def get_full_code(self):
|
||||
"""Get full commission code"""
|
||||
subj = self.subject.code if self.subject else 'N/A'
|
||||
code = self.code or ''
|
||||
sem = str(self.semester or '').strip()
|
||||
yr = str(self.year or '').strip()
|
||||
|
||||
if yr and yr in sem:
|
||||
return f"{subj}-{code}-{sem}"
|
||||
elif sem and yr:
|
||||
return f"{subj}-{code}-{sem}{yr}"
|
||||
elif sem:
|
||||
return f"{subj}-{code}-{sem}"
|
||||
elif yr:
|
||||
return f"{subj}-{code}-{yr}"
|
||||
return f"{subj}-{code}"
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Commission {self.get_full_code()}>'
|
||||
@@ -0,0 +1,145 @@
|
||||
from app import db
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
role = db.Column(db.String(50), nullable=False)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_login = db.Column(db.DateTime)
|
||||
preferred_language = db.Column(db.String(10), default=None, nullable=True)
|
||||
theme_preference = db.Column(db.String(10), default='auto', nullable=False)
|
||||
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='SET NULL'), nullable=True)
|
||||
|
||||
# Virtual fields for future compatibility
|
||||
@property
|
||||
def first_name(self):
|
||||
return self.name.split()[0] if self.name else ''
|
||||
|
||||
@property
|
||||
def last_name(self):
|
||||
parts = self.name.split()
|
||||
return ' '.join(parts[1:]) if len(parts) > 1 else ''
|
||||
|
||||
# Relationships
|
||||
reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def set_password(self, password):
|
||||
"""Set password hash"""
|
||||
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
|
||||
def check_password(self, password):
|
||||
"""Check password"""
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def is_admin(self):
|
||||
"""Check if user is admin"""
|
||||
if self.role_obj and self.role_obj.name.lower() in ['admin', 'administrador']:
|
||||
return True
|
||||
return str(self.role).upper() in ['ADMIN', 'ADMINISTRADOR']
|
||||
|
||||
def get_institutional_role(self):
|
||||
"""Returns normalized institutional role: 'admin', 'bedelia', 'docente', 'alumno'"""
|
||||
raw = ''
|
||||
if self.role_obj and self.role_obj.name:
|
||||
raw = self.role_obj.name.lower().strip()
|
||||
elif self.role:
|
||||
raw = str(self.role).lower().strip()
|
||||
|
||||
if 'admin' in raw:
|
||||
return 'admin'
|
||||
if 'bedel' in raw:
|
||||
return 'bedelia'
|
||||
if 'docent' in raw or 'prof' in raw or 'teacher' in raw:
|
||||
return 'docente'
|
||||
if 'alumn' in raw or 'estud' in raw or 'student' in raw:
|
||||
return 'alumno'
|
||||
return 'docente'
|
||||
|
||||
def is_bedelia(self):
|
||||
return self.get_institutional_role() == 'bedelia'
|
||||
|
||||
def is_docente(self):
|
||||
return self.get_institutional_role() == 'docente'
|
||||
|
||||
def is_alumno(self):
|
||||
return self.get_institutional_role() == 'alumno'
|
||||
|
||||
@property
|
||||
def role_badge_display(self):
|
||||
role_map = {
|
||||
'admin': {'label': 'Administrador', 'class': 'badge-admin', 'icon': 'bi-shield-check', 'color': 'primary'},
|
||||
'bedelia': {'label': 'Bedelía', 'class': 'badge-bedelia', 'icon': 'bi-building-gear', 'color': 'warning'},
|
||||
'docente': {'label': 'Profesor / Docente', 'class': 'badge-docente', 'icon': 'bi-person-video3', 'color': 'info'},
|
||||
'alumno': {'label': 'Alumno / Estudiante', 'class': 'badge-alumno', 'icon': 'bi-mortarboard', 'color': 'success'}
|
||||
}
|
||||
return role_map.get(self.get_institutional_role(), {'label': self.role or 'Docente', 'class': 'bg-secondary', 'icon': 'bi-person', 'color': 'secondary'})
|
||||
|
||||
def has_permission(self, module, min_level='read'):
|
||||
"""Check if user has granular permission on a given module"""
|
||||
if self.is_admin():
|
||||
return True
|
||||
if self.role_obj:
|
||||
return self.role_obj.has_permission(module, min_level)
|
||||
return False
|
||||
|
||||
def can_manage(self):
|
||||
"""Check if user can access the Management / Admin section"""
|
||||
return (
|
||||
self.is_admin() or
|
||||
self.is_bedelia() or
|
||||
self.has_permission('users', 'read') or
|
||||
self.has_permission('milestone_types', 'read') or
|
||||
self.has_permission('import', 'read') or
|
||||
self.has_permission('reservations', 'read_write') or
|
||||
self.has_permission('classrooms', 'read_write') or
|
||||
self.has_permission('buildings', 'read_write') or
|
||||
self.has_permission('metrics', 'read')
|
||||
)
|
||||
|
||||
def can_edit_reservation(self, reservation):
|
||||
"""Check if user has permission to edit a given reservation"""
|
||||
if not reservation:
|
||||
return False
|
||||
if self.is_admin():
|
||||
return True
|
||||
role_name = (self.role_obj.name if self.role_obj else self.role or '').lower()
|
||||
if role_name in ['admin', 'administrador', 'bedelia', 'bedelía', 'operador']:
|
||||
return True
|
||||
if self.has_permission('reservations', 'read_write'):
|
||||
# El docente creador puede modificar su propia reserva
|
||||
if reservation.user_id == self.id:
|
||||
return True
|
||||
# Bedelía o roles de gestión con permiso de escritura total
|
||||
if role_name in ['bedelia', 'bedelía', 'operador']:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def update_last_login(self):
|
||||
"""Update last login timestamp"""
|
||||
self.last_login = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert user to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'email': self.email,
|
||||
'name': self.name,
|
||||
'role': self.role,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'last_login': self.last_login.isoformat() if self.last_login else None,
|
||||
'preferred_language': self.preferred_language,
|
||||
'theme_preference': self.theme_preference
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.email}>'
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Módulo de Repositorios para persistencia y consultas desacopladas."""
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.repositories.reservation_repository import ReservationRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
|
||||
__all__ = [
|
||||
'BaseRepository',
|
||||
'ClassroomRepository',
|
||||
'ReservationRepository',
|
||||
'UserRepository'
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
from typing import TypeVar, Generic, Type, Optional, List
|
||||
from app import db
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
class BaseRepository(Generic[T]):
|
||||
"""Repositorio base con operaciones CRUD genéricas y seguras."""
|
||||
|
||||
def __init__(self, model_class: Type[T]):
|
||||
self.model_class = model_class
|
||||
|
||||
def get_by_id(self, entity_id: int) -> Optional[T]:
|
||||
"""Obtiene una entidad por su ID numérico."""
|
||||
if not entity_id:
|
||||
return None
|
||||
return db.session.get(self.model_class, entity_id)
|
||||
|
||||
def get_all(self, order_by=None) -> List[T]:
|
||||
"""Obtiene todas las entidades opcionalmente ordenadas."""
|
||||
query = self.model_class.query
|
||||
if order_by is not None:
|
||||
query = query.order_by(order_by)
|
||||
return query.all()
|
||||
|
||||
def save(self, entity: T, commit: bool = True) -> T:
|
||||
"""Agrega o actualiza una entidad en la sesión."""
|
||||
db.session.add(entity)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return entity
|
||||
|
||||
def delete(self, entity: T, commit: bool = True) -> bool:
|
||||
"""Elimina una entidad de la sesión."""
|
||||
if entity:
|
||||
db.session.delete(entity)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def commit(self):
|
||||
"""Confirma la transacción actual."""
|
||||
db.session.commit()
|
||||
|
||||
def rollback(self):
|
||||
"""Revierte la transacción actual."""
|
||||
db.session.rollback()
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import func
|
||||
from app.models.classroom import Classroom
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
from app import db
|
||||
|
||||
class ClassroomRepository(BaseRepository[Classroom]):
|
||||
"""Repositorio especializado en operaciones de persistencia y filtrado de aulas."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(Classroom)
|
||||
|
||||
def get_by_code_and_building(self, building_id: int, code: str, floor: str) -> Optional[Classroom]:
|
||||
"""Busca un aula por edificio, código y piso para verificar duplicados."""
|
||||
return Classroom.query.filter_by(
|
||||
building_id=building_id,
|
||||
code=code.strip(),
|
||||
floor=str(floor).strip()
|
||||
).first()
|
||||
|
||||
def get_active_classrooms(self, building_id: Optional[int] = None, floor: Optional[str] = None,
|
||||
only_virtual: bool = False, only_physical: bool = False) -> List[Classroom]:
|
||||
"""Retorna aulas activas con filtros opcionales de edificio, piso y modalidad."""
|
||||
query = Classroom.query.filter_by(is_active=True)
|
||||
|
||||
if building_id:
|
||||
query = query.filter_by(building_id=building_id)
|
||||
|
||||
if floor:
|
||||
query = query.filter_by(floor=str(floor).strip())
|
||||
|
||||
classrooms = query.order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
|
||||
if only_virtual:
|
||||
return [c for c in classrooms if c.is_virtual]
|
||||
elif only_physical:
|
||||
return [c for c in classrooms if not c.is_virtual]
|
||||
|
||||
return classrooms
|
||||
|
||||
def get_virtual_classrooms(self) -> List[Classroom]:
|
||||
"""Obtiene todas las salas virtuales activas."""
|
||||
return [c for c in self.get_active_classrooms() if c.is_virtual]
|
||||
|
||||
def get_physical_classrooms(self) -> List[Classroom]:
|
||||
"""Obtiene todas las aulas físicas activas."""
|
||||
return [c for c in self.get_active_classrooms() if not c.is_virtual]
|
||||
|
||||
def search(self, term: str) -> List[Classroom]:
|
||||
"""Búsqueda de aulas por código, edificio o descripción."""
|
||||
if not term:
|
||||
return self.get_all(order_by=Classroom.code.asc())
|
||||
term_clean = f"%{term.strip()}%"
|
||||
return Classroom.query.filter(
|
||||
Classroom.is_active == True,
|
||||
(Classroom.code.ilike(term_clean)) |
|
||||
(Classroom.building.ilike(term_clean)) |
|
||||
(Classroom.description.ilike(term_clean))
|
||||
).order_by(Classroom.code.asc()).all()
|
||||
@@ -0,0 +1,61 @@
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date, time
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy import and_, or_
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
from app import db
|
||||
|
||||
class ReservationRepository(BaseRepository[Reservation]):
|
||||
"""Repositorio para la persistencia y consulta de reservas de aulas."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(Reservation)
|
||||
|
||||
def find_conflicts(self, classroom_id: int, start_time: datetime, end_time: datetime,
|
||||
exclude_reservation_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""
|
||||
Encuentra reservas activas que se solapan en el mismo aula y franja horaria.
|
||||
Condición de solapamiento: (start_time < existing.end_time) AND (end_time > existing.start_time).
|
||||
"""
|
||||
query = Reservation.query.filter(
|
||||
Reservation.classroom_id == classroom_id,
|
||||
Reservation.status != ReservationStatus.CANCELLED.value,
|
||||
Reservation.start_time < end_time,
|
||||
Reservation.end_time > start_time
|
||||
)
|
||||
|
||||
if exclude_reservation_id:
|
||||
query = query.filter(Reservation.id != exclude_reservation_id)
|
||||
|
||||
return query.all()
|
||||
|
||||
def get_by_date_range(self, start_dt: datetime, end_dt: datetime,
|
||||
classroom_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Obtiene reservas dentro de una ventana temporal con carga ansiosa de relaciones."""
|
||||
query = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission),
|
||||
joinedload(Reservation.user)
|
||||
).filter(
|
||||
Reservation.start_time >= start_dt,
|
||||
Reservation.start_time <= end_dt,
|
||||
Reservation.status != ReservationStatus.CANCELLED.value
|
||||
)
|
||||
|
||||
if classroom_id:
|
||||
query = query.filter(Reservation.classroom_id == classroom_id)
|
||||
|
||||
return query.order_by(Reservation.start_time.asc()).all()
|
||||
|
||||
def get_by_teacher(self, teacher_id: int) -> List[Reservation]:
|
||||
"""Obtiene reservas asignadas a un docente."""
|
||||
return Reservation.query.filter_by(
|
||||
user_id=teacher_id
|
||||
).order_by(Reservation.start_time.desc()).all()
|
||||
|
||||
def get_by_commission(self, commission_id: int) -> List[Reservation]:
|
||||
"""Obtiene todas las reservas asociadas a una comisión."""
|
||||
return Reservation.query.filter_by(
|
||||
commission_id=commission_id
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
@@ -0,0 +1,34 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import func
|
||||
from app.models.user import User
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
|
||||
class UserRepository(BaseRepository[User]):
|
||||
"""Repositorio para la gestión de usuarios, credenciales y roles."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(User)
|
||||
|
||||
def get_by_email(self, email: str) -> Optional[User]:
|
||||
"""Obtiene un usuario por su dirección de email normalizada."""
|
||||
if not email:
|
||||
return None
|
||||
return User.query.filter(func.lower(User.email) == email.strip().lower()).first()
|
||||
|
||||
def get_active_users(self) -> List[User]:
|
||||
"""Obtiene todos los usuarios con cuenta activa."""
|
||||
return User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||
|
||||
def get_by_role(self, role_name: str) -> List[User]:
|
||||
"""Obtiene usuarios por nombre de rol."""
|
||||
return User.query.filter(
|
||||
User.is_active == True,
|
||||
func.lower(User.role) == role_name.strip().lower()
|
||||
).order_by(User.name.asc()).all()
|
||||
|
||||
def get_teachers(self) -> List[User]:
|
||||
"""Obtiene lista de docentes y profesores activos."""
|
||||
return User.query.filter(
|
||||
User.is_active == True,
|
||||
User.role.in_(['TEACHER', 'DOCENTE', 'PROFESOR', 'teacher', 'docente'])
|
||||
).order_by(User.name.asc()).all()
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,10 @@
|
||||
from .main import main_bp
|
||||
from .auth import auth_bp
|
||||
from .classrooms import classrooms_bp
|
||||
from .buildings import buildings_bp
|
||||
from .schedule import schedule_bp
|
||||
from .genetic_algorithm import genetic_bp, genetic_web_bp
|
||||
from .admin import admin_bp
|
||||
from .my_subjects import my_subjects_bp
|
||||
|
||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp', 'my_subjects_bp']
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Paquete de rutas API RESTful (v1) con soporte Stateless JWT."""
|
||||
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_optimizer_bp'
|
||||
]
|
||||
@@ -0,0 +1,930 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from datetime import datetime
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
from app.models.user import User
|
||||
from app.models.role import Role, Permission, SYSTEM_MODULES
|
||||
from app.models.subject import Subject, Commission
|
||||
from app.models.career import Career
|
||||
from app.models.academic_term import AcademicTerm
|
||||
from app.models.milestone import MilestoneType, AcademicMilestone
|
||||
from app.models.audit_log import AuditLog
|
||||
from app import db
|
||||
|
||||
api_admin_bp = Blueprint('api_admin', __name__)
|
||||
|
||||
@api_admin_bp.route('/users', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_users():
|
||||
search = request.args.get('search', '').strip().lower()
|
||||
role_id = request.args.get('role', type=int)
|
||||
status = request.args.get('status', '').strip().lower()
|
||||
|
||||
query = User.query
|
||||
if search:
|
||||
query = query.filter((User.name.ilike(f'%{search}%')) | (User.email.ilike(f'%{search}%')))
|
||||
if role_id:
|
||||
query = query.filter(User.role_id == role_id)
|
||||
if status == 'active':
|
||||
query = query.filter(User.is_active == True)
|
||||
elif status == 'inactive':
|
||||
query = query.filter(User.is_active == False)
|
||||
|
||||
users = query.order_by(User.id.asc()).all()
|
||||
roles = Role.query.order_by(Role.name.asc()).all()
|
||||
|
||||
users_data = []
|
||||
for u in users:
|
||||
users_data.append({
|
||||
'id': u.id,
|
||||
'name': u.name,
|
||||
'first_name': u.name.split(' ')[0] if u.name else 'U',
|
||||
'email': u.email,
|
||||
'is_active': u.is_active,
|
||||
'role': u.role,
|
||||
'role_obj': {
|
||||
'id': u.role_obj.id,
|
||||
'name': u.role_obj.name
|
||||
} if u.role_obj else {'id': 1, 'name': u.role or 'Admin'},
|
||||
'last_login': getattr(u, 'last_login', None)
|
||||
})
|
||||
|
||||
roles_data = [{'id': r.id, 'name': r.name} for r in roles]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'total': len(users_data),
|
||||
'users': users_data,
|
||||
'roles': roles_data
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/roles', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_roles():
|
||||
roles = Role.query.order_by(Role.id.asc()).all()
|
||||
modules = [
|
||||
{'id': 'dashboard', 'name': 'Dashboard', 'icon': 'bi-speedometer2'},
|
||||
{'id': 'classrooms', 'name': 'Aulas y Espacios', 'icon': 'bi-door-open'},
|
||||
{'id': 'schedule', 'name': 'Cronograma', 'icon': 'bi-calendar3'},
|
||||
{'id': 'subjects', 'name': 'Asignaturas', 'icon': 'bi-book'},
|
||||
{'id': 'careers', 'name': 'Carreras', 'icon': 'bi-mortarboard'},
|
||||
{'id': 'commissions', 'name': 'Comisiones', 'icon': 'bi-diagram-3'},
|
||||
{'id': 'users', 'name': 'Usuarios', 'icon': 'bi-people'},
|
||||
{'id': 'roles', 'name': 'Roles (RBAC)', 'icon': 'bi-shield-check'},
|
||||
{'id': 'optimizer', 'name': 'Optimizador IA', 'icon': 'bi-cpu'},
|
||||
{'id': 'audit', 'name': 'Auditoría', 'icon': 'bi-clipboard-data'}
|
||||
]
|
||||
|
||||
roles_data = []
|
||||
for r in roles:
|
||||
perms_map = {}
|
||||
for p in r.permissions:
|
||||
perms_map[p.module] = p.access_level
|
||||
|
||||
roles_data.append({
|
||||
'id': r.id,
|
||||
'name': r.name,
|
||||
'description': r.description or f'Rol institucional de {r.name}',
|
||||
'is_system': getattr(r, 'is_system', True),
|
||||
'users_count': len(r.users),
|
||||
'users': [{'id': u.id, 'name': u.name} for u in r.users],
|
||||
'permissions': perms_map
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'roles': roles_data,
|
||||
'modules': modules
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/subjects', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_subjects():
|
||||
search = request.args.get('search', '').strip().lower()
|
||||
career_id = request.args.get('career_id', type=int)
|
||||
active = request.args.get('active', '').strip().lower()
|
||||
|
||||
query = Subject.query
|
||||
if search:
|
||||
query = query.filter((Subject.name.ilike(f'%{search}%')) | (Subject.code.ilike(f'%{search}%')) | (Subject.department.ilike(f'%{search}%')))
|
||||
if career_id:
|
||||
query = query.filter(Subject.career_id == career_id)
|
||||
if active == 'true':
|
||||
query = query.filter(Subject.is_active == True)
|
||||
elif active == 'false':
|
||||
query = query.filter(Subject.is_active == False)
|
||||
|
||||
subjects = query.order_by(Subject.id.asc()).all()
|
||||
careers = Career.query.order_by(Career.name.asc()).all()
|
||||
|
||||
subjects_data = []
|
||||
for s in subjects:
|
||||
subjects_data.append({
|
||||
'id': s.id,
|
||||
'code': s.code,
|
||||
'name': s.name,
|
||||
'career_id': s.career_id,
|
||||
'career_name': s.career_obj.name if s.career_obj else (s.department or 'General'),
|
||||
'department': s.department or 'General',
|
||||
'credits': getattr(s, 'credits', 4),
|
||||
'is_active': s.is_active,
|
||||
'commissions_count': len(s.commissions),
|
||||
'commissions': [{'id': c.id, 'code': c.code, 'schedule': c.schedule} for c in s.commissions]
|
||||
})
|
||||
|
||||
careers_data = [{'id': c.id, 'name': c.name} for c in careers]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'total': len(subjects_data),
|
||||
'subjects': subjects_data,
|
||||
'careers': careers_data
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/careers', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_careers():
|
||||
search = request.args.get('search', '').strip().lower()
|
||||
query = Career.query
|
||||
if search:
|
||||
query = query.filter(Career.name.ilike(f'%{search}%'))
|
||||
|
||||
careers = query.order_by(Career.name.asc()).all()
|
||||
careers_data = []
|
||||
for c in careers:
|
||||
careers_data.append({
|
||||
'id': c.id,
|
||||
'name': c.name,
|
||||
'code': getattr(c, 'code', f'CAR-{c.id}'),
|
||||
'degree_level': getattr(c, 'degree_level', 'Grado / Tecnicatura'),
|
||||
'is_active': getattr(c, 'is_active', True),
|
||||
'subjects_count': len(c.subjects),
|
||||
'subjects': [{'id': s.id, 'name': s.name, 'code': s.code} for s in c.subjects[:5]]
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'total': len(careers_data),
|
||||
'careers': careers_data
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/commissions', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_commissions():
|
||||
search = request.args.get('search', '').strip().lower()
|
||||
subject_id = request.args.get('subject_id', type=int)
|
||||
shift = request.args.get('shift', '').strip()
|
||||
active = request.args.get('active', '').strip().lower()
|
||||
|
||||
query = Commission.query
|
||||
if subject_id:
|
||||
query = query.filter(Commission.subject_id == subject_id)
|
||||
if shift:
|
||||
query = query.filter(Commission.shift == shift)
|
||||
if active == 'true':
|
||||
query = query.filter(Commission.active == True)
|
||||
elif active == 'false':
|
||||
query = query.filter(Commission.active == False)
|
||||
|
||||
commissions = query.order_by(Commission.id.asc()).all()
|
||||
subjects = Subject.query.order_by(Subject.name.asc()).all()
|
||||
|
||||
commissions_data = []
|
||||
for c in commissions:
|
||||
if search:
|
||||
s_name = c.subject.name.lower() if c.subject else ''
|
||||
c_code = c.code.lower()
|
||||
if search not in s_name and search not in c_code:
|
||||
continue
|
||||
|
||||
commissions_data.append({
|
||||
'id': c.id,
|
||||
'code': c.code,
|
||||
'subject_id': c.subject_id,
|
||||
'subject_name': c.subject.name if c.subject else 'Sin materia',
|
||||
'subject_code': c.subject.code if c.subject else '-',
|
||||
'schedule': c.schedule or 'A coordinar',
|
||||
'shift': c.shift or 'Mañana',
|
||||
'max_students': c.max_students or 35,
|
||||
'current_students': getattr(c, 'current_students', 0) or 0,
|
||||
'active': c.active,
|
||||
'virtual_link': getattr(c, 'virtual_link', '') or ''
|
||||
})
|
||||
|
||||
subjects_data = [{'id': s.id, 'name': s.name, 'code': s.code} for s in subjects]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'total': len(commissions_data),
|
||||
'commissions': commissions_data,
|
||||
'subjects': subjects_data
|
||||
}), 200
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# USERS CRUD
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@api_admin_bp.route('/users', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_user():
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
email = data.get('email', '').strip().lower()
|
||||
name = data.get('name', '').strip()
|
||||
password = data.get('password', '').strip()
|
||||
role_id = data.get('role_id')
|
||||
is_active = data.get('is_active', True)
|
||||
if isinstance(is_active, str):
|
||||
is_active = is_active.lower() in ['true', '1', 'on']
|
||||
|
||||
if not email or not name:
|
||||
return jsonify({'error': 'ValidationError', 'message': 'El nombre y el email son obligatorios.'}), 400
|
||||
|
||||
if User.query.filter(User.email.ilike(email)).first():
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe un usuario con el email {email}.'}), 409
|
||||
|
||||
role_obj = None
|
||||
if role_id:
|
||||
role_obj = Role.query.get(int(role_id))
|
||||
role_name = role_obj.name if role_obj else data.get('role', 'Docente')
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
name=name,
|
||||
role=role_name,
|
||||
role_id=int(role_id) if role_id else None,
|
||||
is_active=bool(is_active)
|
||||
)
|
||||
user.set_password(password or 'edu-space2026')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Usuario creado exitosamente.',
|
||||
'user': {'id': user.id, 'name': user.name, 'email': user.email, 'role': user.role, 'is_active': user.is_active}
|
||||
}), 201
|
||||
|
||||
@api_admin_bp.route('/users/<int:id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_user(id):
|
||||
user = User.query.get_or_404(id)
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
|
||||
if 'name' in data and data['name']:
|
||||
user.name = data['name'].strip()
|
||||
if 'email' in data and data['email']:
|
||||
email = data['email'].strip().lower()
|
||||
existing = User.query.filter(User.email.ilike(email), User.id != id).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Conflict', 'message': f'El email {email} ya está registrado por otro usuario.'}), 409
|
||||
user.email = email
|
||||
if 'password' in data and data['password']:
|
||||
user.set_password(data['password'].strip())
|
||||
if 'role_id' in data and data['role_id']:
|
||||
role_obj = Role.query.get(int(data['role_id']))
|
||||
if role_obj:
|
||||
user.role_id = role_obj.id
|
||||
user.role = role_obj.name
|
||||
if 'is_active' in data:
|
||||
val = data['is_active']
|
||||
user.is_active = val in [True, 'true', '1', 'on']
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Usuario actualizado correctamente.',
|
||||
'user': {'id': user.id, 'name': user.name, 'email': user.email, 'role': user.role, 'is_active': user.is_active}
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/users/<int:id>/toggle', methods=['POST'])
|
||||
@jwt_required
|
||||
def toggle_user(id):
|
||||
user = User.query.get_or_404(id)
|
||||
user.is_active = not user.is_active
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'id': user.id,
|
||||
'is_active': user.is_active,
|
||||
'message': f'Usuario {"activado" if user.is_active else "desactivado"} correctamente.'
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/users/<int:id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def delete_user(id):
|
||||
user = User.query.get_or_404(id)
|
||||
has_reservations = len(user.reservations) if user.reservations else 0
|
||||
if has_reservations > 0:
|
||||
user.is_active = False
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'El usuario tiene reservas asociadas. Se ha desactivado en su lugar.'
|
||||
}), 200
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Usuario eliminado permanentemente.'
|
||||
}), 200
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# ROLES CRUD
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@api_admin_bp.route('/roles', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_role():
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
name = data.get('name', '').strip()
|
||||
description = data.get('description', '').strip()
|
||||
permissions = data.get('permissions', {})
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'ValidationError', 'message': 'El nombre del rol es obligatorio.'}), 400
|
||||
|
||||
if Role.query.filter(Role.name.ilike(name)).first():
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe un rol llamado {name}.'}), 409
|
||||
|
||||
role = Role(name=name, description=description, is_system=False)
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
|
||||
for mod, level in permissions.items():
|
||||
role.set_permission(mod, level)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Rol creado exitosamente.',
|
||||
'role': role.to_dict()
|
||||
}), 201
|
||||
|
||||
@api_admin_bp.route('/roles/<int:id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_role(id):
|
||||
role = Role.query.get_or_404(id)
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
|
||||
if 'name' in data and data['name']:
|
||||
name = data['name'].strip()
|
||||
existing = Role.query.filter(Role.name.ilike(name), Role.id != id).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe otro rol llamado {name}.'}), 409
|
||||
role.name = name
|
||||
|
||||
if 'description' in data:
|
||||
role.description = data['description'].strip()
|
||||
|
||||
if 'permissions' in data and isinstance(data['permissions'], dict):
|
||||
for mod, level in data['permissions'].items():
|
||||
role.set_permission(mod, level)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Rol actualizado correctamente.',
|
||||
'role': role.to_dict()
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/roles/<int:id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def delete_role(id):
|
||||
role = Role.query.get_or_404(id)
|
||||
if role.is_system:
|
||||
return jsonify({'error': 'Forbidden', 'message': 'No se pueden eliminar roles de sistema.'}), 403
|
||||
if role.users and len(role.users) > 0:
|
||||
return jsonify({'error': 'Conflict', 'message': f'El rol tiene {len(role.users)} usuarios asignados. Reasígnelos primero.'}), 409
|
||||
|
||||
db.session.delete(role)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Rol eliminado exitosamente.'
|
||||
}), 200
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# CAREERS CRUD
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@api_admin_bp.route('/careers', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_career():
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
name = data.get('name', '').strip()
|
||||
code = data.get('code', '').strip()
|
||||
description = data.get('description', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'ValidationError', 'message': 'El nombre de la carrera es obligatorio.'}), 400
|
||||
|
||||
if Career.query.filter(Career.name.ilike(name)).first():
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe una carrera con el nombre {name}.'}), 409
|
||||
|
||||
career = Career(name=name, code=code or None, description=description or None)
|
||||
db.session.add(career)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Carrera creada exitosamente.',
|
||||
'career': career.to_dict()
|
||||
}), 201
|
||||
|
||||
@api_admin_bp.route('/careers/<int:id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_career(id):
|
||||
career = Career.query.get_or_404(id)
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
|
||||
if 'name' in data and data['name']:
|
||||
name = data['name'].strip()
|
||||
existing = Career.query.filter(Career.name.ilike(name), Career.id != id).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe otra carrera con el nombre {name}.'}), 409
|
||||
career.name = name
|
||||
|
||||
if 'code' in data:
|
||||
career.code = data['code'].strip() or None
|
||||
|
||||
if 'description' in data:
|
||||
career.description = data['description'].strip() or None
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Carrera actualizada correctamente.',
|
||||
'career': career.to_dict()
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/careers/<int:id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def delete_career(id):
|
||||
career = Career.query.get_or_404(id)
|
||||
if career.subjects and len(career.subjects) > 0:
|
||||
return jsonify({
|
||||
'error': 'Conflict',
|
||||
'message': f'No se puede eliminar la carrera porque tiene {len(career.subjects)} asignaturas vinculadas.'
|
||||
}), 409
|
||||
|
||||
db.session.delete(career)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Carrera eliminada permanentemente.'
|
||||
}), 200
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# SUBJECTS CRUD
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@api_admin_bp.route('/subjects', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_subject():
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
code = data.get('code', '').strip().upper()
|
||||
name = data.get('name', '').strip()
|
||||
department = data.get('department', '').strip()
|
||||
credits = int(data.get('credits', 4) or 4)
|
||||
career_id = data.get('career_id')
|
||||
career_id = int(career_id) if career_id else None
|
||||
description = data.get('description', '').strip()
|
||||
is_active = data.get('is_active', True)
|
||||
if isinstance(is_active, str):
|
||||
is_active = is_active.lower() in ['true', '1', 'on']
|
||||
|
||||
if not code or not name:
|
||||
return jsonify({'error': 'ValidationError', 'message': 'El código y nombre de la asignatura son obligatorios.'}), 400
|
||||
|
||||
if Subject.query.filter_by(code=code).first():
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe una asignatura con el código {code}.'}), 409
|
||||
|
||||
subject = Subject(
|
||||
code=code,
|
||||
name=name,
|
||||
department=department,
|
||||
credits=credits,
|
||||
career_id=career_id,
|
||||
description=description,
|
||||
is_active=bool(is_active)
|
||||
)
|
||||
db.session.add(subject)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Asignatura creada exitosamente.',
|
||||
'subject': subject.to_dict()
|
||||
}), 201
|
||||
|
||||
@api_admin_bp.route('/subjects/<int:id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_subject(id):
|
||||
subject = Subject.query.get_or_404(id)
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
|
||||
if 'code' in data and data['code']:
|
||||
code = data['code'].strip().upper()
|
||||
existing = Subject.query.filter(Subject.code == code, Subject.id != id).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe otra asignatura con el código {code}.'}), 409
|
||||
subject.code = code
|
||||
|
||||
if 'name' in data and data['name']:
|
||||
subject.name = data['name'].strip()
|
||||
|
||||
if 'department' in data:
|
||||
subject.department = data['department'].strip()
|
||||
|
||||
if 'credits' in data and data['credits'] is not None:
|
||||
subject.credits = int(data['credits'])
|
||||
|
||||
if 'career_id' in data:
|
||||
c_id = data['career_id']
|
||||
subject.career_id = int(c_id) if c_id else None
|
||||
|
||||
if 'description' in data:
|
||||
subject.description = data['description'].strip()
|
||||
|
||||
if 'is_active' in data:
|
||||
val = data['is_active']
|
||||
subject.is_active = val in [True, 'true', '1', 'on']
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Asignatura actualizada correctamente.',
|
||||
'subject': subject.to_dict()
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/subjects/<int:id>/toggle', methods=['POST'])
|
||||
@jwt_required
|
||||
def toggle_subject(id):
|
||||
subject = Subject.query.get_or_404(id)
|
||||
subject.is_active = not subject.is_active
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'id': subject.id,
|
||||
'is_active': subject.is_active,
|
||||
'message': f'Asignatura {"activada" if subject.is_active else "desactivada"} correctamente.'
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/subjects/<int:id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def delete_subject(id):
|
||||
subject = Subject.query.get_or_404(id)
|
||||
if subject.commissions and len(subject.commissions) > 0:
|
||||
subject.is_active = False
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'La asignatura tiene comisiones activas. Se ha desactivado en su lugar.'
|
||||
}), 200
|
||||
|
||||
db.session.delete(subject)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Asignatura eliminada permanentemente.'
|
||||
}), 200
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# COMMISSIONS CRUD
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@api_admin_bp.route('/commissions', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_commission():
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
subject_id = data.get('subject_id')
|
||||
code = data.get('code', '').strip()
|
||||
semester = data.get('semester', '1C').strip()
|
||||
year = int(data.get('year', datetime.now().year) or datetime.now().year)
|
||||
teacher_id = data.get('teacher_id')
|
||||
teacher_id = int(teacher_id) if teacher_id else None
|
||||
max_students = int(data.get('max_students', 35) or 35)
|
||||
schedule = data.get('schedule', '').strip()
|
||||
shift = data.get('shift', 'Mañana').strip()
|
||||
virtual_link = data.get('virtual_link', '').strip()
|
||||
active = data.get('active', True)
|
||||
if isinstance(active, str):
|
||||
active = active.lower() in ['true', '1', 'on']
|
||||
|
||||
if not subject_id or not code:
|
||||
return jsonify({'error': 'ValidationError', 'message': 'La asignatura y código de comisión son obligatorios.'}), 400
|
||||
|
||||
commission = Commission(
|
||||
subject_id=int(subject_id),
|
||||
code=code,
|
||||
semester=semester,
|
||||
year=year,
|
||||
teacher_id=teacher_id,
|
||||
max_students=max_students,
|
||||
schedule=schedule,
|
||||
shift=shift,
|
||||
virtual_link=virtual_link,
|
||||
active=bool(active)
|
||||
)
|
||||
db.session.add(commission)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Comisión creada exitosamente.',
|
||||
'commission': commission.to_dict()
|
||||
}), 201
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_commission(id):
|
||||
comm = Commission.query.get_or_404(id)
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
|
||||
if 'code' in data and data['code']:
|
||||
comm.code = data['code'].strip()
|
||||
if 'semester' in data and data['semester']:
|
||||
comm.semester = data['semester'].strip()
|
||||
if 'year' in data and data['year']:
|
||||
comm.year = int(data['year'])
|
||||
if 'teacher_id' in data:
|
||||
t_id = data['teacher_id']
|
||||
comm.teacher_id = int(t_id) if t_id else None
|
||||
if 'max_students' in data and data['max_students']:
|
||||
comm.max_students = int(data['max_students'])
|
||||
if 'schedule' in data:
|
||||
comm.schedule = data['schedule'].strip()
|
||||
if 'shift' in data:
|
||||
comm.shift = data['shift'].strip()
|
||||
if 'virtual_link' in data:
|
||||
comm.virtual_link = data['virtual_link'].strip()
|
||||
if 'active' in data:
|
||||
val = data['active']
|
||||
comm.active = val in [True, 'true', '1', 'on']
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Comisión actualizada correctamente.',
|
||||
'commission': comm.to_dict()
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/toggle', methods=['POST'])
|
||||
@jwt_required
|
||||
def toggle_commission(id):
|
||||
comm = Commission.query.get_or_404(id)
|
||||
comm.active = not comm.active
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'id': comm.id,
|
||||
'active': comm.active,
|
||||
'message': f'Comisión {"activada" if comm.active else "desactivada"} correctamente.'
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/assign-teacher', methods=['POST'])
|
||||
@jwt_required
|
||||
def assign_commission_teacher(id):
|
||||
comm = Commission.query.get_or_404(id)
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
t_id = data.get('teacher_id')
|
||||
comm.teacher_id = int(t_id) if t_id else None
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'id': comm.id,
|
||||
'teacher_id': comm.teacher_id,
|
||||
'teacher_name': comm.teacher_name,
|
||||
'message': 'Docente asignado correctamente.'
|
||||
}), 200
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# ACADEMIC TERMS CRUD
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@api_admin_bp.route('/academic-terms', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_academic_terms():
|
||||
terms = AcademicTerm.query.order_by(AcademicTerm.year.desc(), AcademicTerm.code.desc()).all()
|
||||
terms_data = []
|
||||
for t in terms:
|
||||
comm_count = Commission.query.filter_by(year=t.year).count()
|
||||
terms_data.append({
|
||||
'id': t.id,
|
||||
'code': t.code,
|
||||
'name': t.name,
|
||||
'year': t.year,
|
||||
'period_type': t.period_type,
|
||||
'start_date': t.start_date.isoformat() if t.start_date else None,
|
||||
'end_date': t.end_date.isoformat() if t.end_date else None,
|
||||
'is_current': t.is_current,
|
||||
'is_active': t.is_active,
|
||||
'commissions_count': comm_count
|
||||
})
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'total': len(terms_data),
|
||||
'terms': terms_data
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/academic-terms', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_academic_term():
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
code = data.get('code', '').strip()
|
||||
name = data.get('name', '').strip()
|
||||
year = int(data.get('year', datetime.now().year) or datetime.now().year)
|
||||
period_type = data.get('period_type', 'Segundo Cuatrimestre').strip()
|
||||
is_current = data.get('is_current', False)
|
||||
if isinstance(is_current, str):
|
||||
is_current = is_current.lower() in ['true', '1', 'on']
|
||||
|
||||
if not code or not name:
|
||||
return jsonify({'error': 'ValidationError', 'message': 'Código y nombre son requeridos.'}), 400
|
||||
|
||||
existing = AcademicTerm.query.filter_by(code=code).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe un ciclo con el código {code}.'}), 409
|
||||
|
||||
if is_current:
|
||||
AcademicTerm.query.update({'is_current': False})
|
||||
|
||||
term = AcademicTerm(
|
||||
code=code,
|
||||
name=name,
|
||||
year=year,
|
||||
period_type=period_type,
|
||||
is_current=bool(is_current),
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(term)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Ciclo lectivo creado exitosamente.',
|
||||
'term': term.to_dict()
|
||||
}), 201
|
||||
|
||||
@api_admin_bp.route('/academic-terms/<int:id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_academic_term(id):
|
||||
term = AcademicTerm.query.get_or_404(id)
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
|
||||
if 'code' in data and data['code']:
|
||||
code = data['code'].strip()
|
||||
existing = AcademicTerm.query.filter(AcademicTerm.code == code, AcademicTerm.id != id).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe otro ciclo con el código {code}.'}), 409
|
||||
term.code = code
|
||||
|
||||
if 'name' in data and data['name']:
|
||||
term.name = data['name'].strip()
|
||||
if 'year' in data and data['year']:
|
||||
term.year = int(data['year'])
|
||||
if 'period_type' in data:
|
||||
term.period_type = data['period_type'].strip()
|
||||
if 'is_current' in data:
|
||||
is_cur = data['is_current'] in [True, 'true', '1', 'on']
|
||||
if is_cur:
|
||||
AcademicTerm.query.update({'is_current': False})
|
||||
term.is_current = is_cur
|
||||
if 'is_active' in data:
|
||||
term.is_active = data['is_active'] in [True, 'true', '1', 'on']
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Ciclo lectivo actualizado correctamente.',
|
||||
'term': term.to_dict()
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/academic-terms/<int:id>/set-current', methods=['POST'])
|
||||
@jwt_required
|
||||
def set_current_academic_term(id):
|
||||
term = AcademicTerm.query.get_or_404(id)
|
||||
AcademicTerm.query.update({'is_current': False})
|
||||
term.is_current = True
|
||||
term.is_active = True
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'id': term.id,
|
||||
'message': f'"{term.name}" establecido como ciclo activo.'
|
||||
}), 200
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# MILESTONE TYPES CRUD
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@api_admin_bp.route('/milestone-types', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_milestone_types():
|
||||
types = MilestoneType.query.order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'total': len(types),
|
||||
'milestone_types': [t.to_dict() for t in types]
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/milestone-types', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_milestone_type():
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
name = data.get('name', '').strip()
|
||||
code = data.get('code', '').strip().lower().replace(' ', '_')
|
||||
color = data.get('color', '#0d6efd').strip()
|
||||
description = data.get('description', '').strip()
|
||||
sort_order = int(data.get('sort_order', 0) or 0)
|
||||
is_active = data.get('is_active', True)
|
||||
if isinstance(is_active, str):
|
||||
is_active = is_active.lower() in ['true', '1', 'on']
|
||||
|
||||
if not name or not code:
|
||||
return jsonify({'error': 'ValidationError', 'message': 'Nombre y código son obligatorios.'}), 400
|
||||
|
||||
existing = MilestoneType.query.filter_by(code=code).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe una tipificación con el código {code}.'}), 409
|
||||
|
||||
mt = MilestoneType(
|
||||
name=name,
|
||||
code=code,
|
||||
color=color,
|
||||
description=description,
|
||||
sort_order=sort_order,
|
||||
is_active=bool(is_active)
|
||||
)
|
||||
db.session.add(mt)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Tipificación de hito creada correctamente.',
|
||||
'milestone_type': mt.to_dict()
|
||||
}), 201
|
||||
|
||||
@api_admin_bp.route('/milestone-types/<int:id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_milestone_type(id):
|
||||
mt = MilestoneType.query.get_or_404(id)
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
|
||||
if 'name' in data and data['name']:
|
||||
mt.name = data['name'].strip()
|
||||
if 'code' in data and data['code']:
|
||||
code = data['code'].strip().lower().replace(' ', '_')
|
||||
existing = MilestoneType.query.filter(MilestoneType.code == code, MilestoneType.id != id).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe otra tipificación con el código {code}.'}), 409
|
||||
mt.code = code
|
||||
if 'color' in data:
|
||||
mt.color = data['color'].strip()
|
||||
if 'description' in data:
|
||||
mt.description = data['description'].strip()
|
||||
if 'sort_order' in data:
|
||||
mt.sort_order = int(data['sort_order'])
|
||||
if 'is_active' in data:
|
||||
mt.is_active = data['is_active'] in [True, 'true', '1', 'on']
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Tipificación actualizada correctamente.',
|
||||
'milestone_type': mt.to_dict()
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/milestone-types/<int:id>/toggle', methods=['POST'])
|
||||
@jwt_required
|
||||
def toggle_milestone_type(id):
|
||||
mt = MilestoneType.query.get_or_404(id)
|
||||
mt.is_active = not mt.is_active
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'id': mt.id,
|
||||
'is_active': mt.is_active,
|
||||
'message': f'Tipificación {"activada" if mt.is_active else "desactivada"} correctamente.'
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/milestone-types/<int:id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def delete_milestone_type(id):
|
||||
mt = MilestoneType.query.get_or_404(id)
|
||||
milestone_count = AcademicMilestone.query.filter_by(milestone_type_id=id).count()
|
||||
if milestone_count > 0:
|
||||
mt.is_active = False
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'La tipificación tiene {milestone_count} hitos registrados. Se ha desactivado en su lugar.'
|
||||
}), 200
|
||||
|
||||
db.session.delete(mt)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Tipificación eliminada permanentemente.'
|
||||
}), 200
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
from flask import Blueprint, request, jsonify, g, make_response
|
||||
from pydantic import ValidationError
|
||||
import jwt
|
||||
|
||||
from app.services.user_service import UserService
|
||||
from app.services.jwt_service import JWTService
|
||||
from app.schemas.auth_dto import LoginDTO, RefreshTokenDTO
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
|
||||
api_auth_bp = Blueprint('api_auth', __name__, url_prefix='/api/v1/auth')
|
||||
user_service = UserService()
|
||||
|
||||
@api_auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
"""
|
||||
Endpoint de autenticación para obtener par de tokens (Access + Refresh).
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = LoginDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
user = user_service.authenticate(dto.email, dto.password)
|
||||
if not user:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Credenciales de acceso incorrectas o cuenta inactiva.'
|
||||
}), 401
|
||||
|
||||
tokens = JWTService.generate_tokens(user)
|
||||
profile = user_service.get_profile_data(user)
|
||||
|
||||
response_data = {
|
||||
'access_token': tokens['access_token'],
|
||||
'refresh_token': tokens['refresh_token'],
|
||||
'token_type': tokens['token_type'],
|
||||
'expires_in': tokens['expires_in'],
|
||||
'user': profile
|
||||
}
|
||||
|
||||
resp = make_response(jsonify(response_data), 200)
|
||||
# Almacenar refresh token en cookie segura HttpOnly
|
||||
resp.set_cookie(
|
||||
'refresh_token',
|
||||
tokens['refresh_token'],
|
||||
httponly=True,
|
||||
samesite='Lax',
|
||||
max_age=7 * 24 * 3600,
|
||||
path='/api/v1/auth/refresh'
|
||||
)
|
||||
return resp
|
||||
|
||||
@api_auth_bp.route('/refresh', methods=['POST'])
|
||||
def refresh():
|
||||
"""
|
||||
Renovación silenciosa del Access Token utilizando el Refresh Token.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
refresh_token = data.get('refresh_token') or request.cookies.get('refresh_token')
|
||||
|
||||
if not refresh_token:
|
||||
return jsonify({
|
||||
'error': 'BadRequest',
|
||||
'message': 'Refresh token no suministrado en el cuerpo ni en cookies.'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
payload = JWTService.decode_token(refresh_token, expected_type='refresh')
|
||||
user_id = int(payload.get('sub'))
|
||||
user = user_service.get_by_id(user_id)
|
||||
|
||||
if not user or not user.is_active:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Usuario no encontrado o inactivo.'
|
||||
}), 401
|
||||
|
||||
new_access_token = JWTService.create_access_token(user)
|
||||
return jsonify({
|
||||
'access_token': new_access_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': int(JWTService.ACCESS_TOKEN_EXPIRES.total_seconds())
|
||||
}), 200
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'TokenExpired', 'message': 'El refresh token ha expirado. Reingrese credenciales.'}), 401
|
||||
except jwt.InvalidTokenError as e:
|
||||
return jsonify({'error': 'InvalidToken', 'message': str(e)}), 401
|
||||
|
||||
@api_auth_bp.route('/logout', methods=['POST'])
|
||||
@jwt_required
|
||||
def logout():
|
||||
"""
|
||||
Cierre de sesión: revoca el token de acceso activo y limpia cookies.
|
||||
"""
|
||||
JWTService.revoke_token(g.jwt_token)
|
||||
resp = make_response(jsonify({'message': 'Sesión finalizada y token revocado exitosamente.'}), 200)
|
||||
resp.delete_cookie('refresh_token', path='/api/v1/auth/refresh')
|
||||
return resp
|
||||
|
||||
@api_auth_bp.route('/me', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_current_user():
|
||||
"""
|
||||
Retorna el perfil y los permisos del usuario autenticado vía JWT.
|
||||
"""
|
||||
profile = user_service.get_profile_data(g.jwt_user)
|
||||
return jsonify(profile), 200
|
||||
@@ -0,0 +1,119 @@
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.services.classroom_service import ClassroomService
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
|
||||
from app.utils.jwt_decorators import jwt_required, jwt_role_required
|
||||
|
||||
api_classrooms_bp = Blueprint('api_classrooms', __name__, url_prefix='/api/v1/classrooms')
|
||||
classroom_service = ClassroomService()
|
||||
|
||||
@api_classrooms_bp.route('', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_classrooms():
|
||||
"""Lista aulas con filtros opcionales (building_id, floor, mode)."""
|
||||
building_id = request.args.get('building_id', type=int)
|
||||
floor = request.args.get('floor')
|
||||
mode = request.args.get('mode', '').lower()
|
||||
|
||||
only_virtual = mode == 'virtual'
|
||||
only_physical = mode == 'physical'
|
||||
|
||||
classrooms = classroom_service.list_classrooms(
|
||||
building_id=building_id,
|
||||
floor=floor,
|
||||
only_virtual=only_virtual,
|
||||
only_physical=only_physical
|
||||
)
|
||||
|
||||
result = []
|
||||
for c in classrooms:
|
||||
result.append({
|
||||
'id': c.id,
|
||||
'code': c.code,
|
||||
'building': c.building_name,
|
||||
'building_id': c.building_id,
|
||||
'floor': c.floor,
|
||||
'floor_display': c.floor_display,
|
||||
'capacity': c.capacity,
|
||||
'is_virtual': c.is_virtual,
|
||||
'location_display': c.location_display,
|
||||
'description': c.description,
|
||||
'is_active': c.is_active
|
||||
})
|
||||
|
||||
return jsonify({'total': len(result), 'classrooms': result}), 200
|
||||
|
||||
@api_classrooms_bp.route('/<int:classroom_id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_classroom(classroom_id: int):
|
||||
"""Obtiene el detalle de un aula por ID."""
|
||||
c = classroom_service.get_classroom(classroom_id)
|
||||
if not c:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404
|
||||
|
||||
return jsonify({
|
||||
'id': c.id,
|
||||
'code': c.code,
|
||||
'building': c.building_name,
|
||||
'building_id': c.building_id,
|
||||
'floor': c.floor,
|
||||
'floor_display': c.floor_display,
|
||||
'capacity': c.capacity,
|
||||
'is_virtual': c.is_virtual,
|
||||
'location_display': c.location_display,
|
||||
'description': c.description,
|
||||
'is_active': c.is_active
|
||||
}), 200
|
||||
|
||||
@api_classrooms_bp.route('', methods=['POST'])
|
||||
@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA')
|
||||
def create_classroom():
|
||||
"""Crea una nueva aula validando el contrato con ClassroomCreateDTO."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = ClassroomCreateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
classroom = classroom_service.create_classroom(dto)
|
||||
return jsonify({
|
||||
'message': 'Aula creada exitosamente.',
|
||||
'id': classroom.id,
|
||||
'code': classroom.code,
|
||||
'location_display': classroom.location_display
|
||||
}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 400
|
||||
|
||||
@api_classrooms_bp.route('/<int:classroom_id>', methods=['PUT'])
|
||||
@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA')
|
||||
def update_classroom(classroom_id: int):
|
||||
"""Actualiza los datos de un aula."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = ClassroomUpdateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
classroom = classroom_service.update_classroom(classroom_id, dto)
|
||||
return jsonify({
|
||||
'message': 'Aula actualizada exitosamente.',
|
||||
'id': classroom.id,
|
||||
'code': classroom.code,
|
||||
'capacity': classroom.capacity
|
||||
}), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 400
|
||||
|
||||
@api_classrooms_bp.route('/<int:classroom_id>', methods=['DELETE'])
|
||||
@jwt_role_required('ADMIN', 'ADMINISTRADOR')
|
||||
def delete_classroom(classroom_id: int):
|
||||
"""Elimina o desactiva un aula."""
|
||||
deleted = classroom_service.delete_classroom(classroom_id)
|
||||
if not deleted:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404
|
||||
|
||||
return jsonify({'message': f'Aula {classroom_id} eliminada/desactivada correctamente.'}), 200
|
||||
@@ -0,0 +1,183 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject
|
||||
from app.models.user import User
|
||||
from app.models.reservation import Reservation
|
||||
from app.models.audit_log import AuditLog
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func
|
||||
from app import db
|
||||
|
||||
from app.models.milestone import AcademicMilestone
|
||||
from app.models.subject import Commission
|
||||
|
||||
api_dashboard_bp = Blueprint('api_dashboard', __name__, url_prefix='/api/v1/dashboard')
|
||||
|
||||
def serialize_reservation(r):
|
||||
subject_name = (r.commission.subject.name if (r.commission and r.commission.subject) else r.purpose) or 'Actividad Académica'
|
||||
subject_code = (r.commission.subject.code if (r.commission and r.commission.subject) else '')
|
||||
classroom_code = r.classroom.code if r.classroom else 'Sin Aula'
|
||||
is_virtual = ('virtual' in classroom_code.lower()) or (getattr(r.classroom, 'is_virtual', False) if r.classroom else False)
|
||||
return {
|
||||
'id': r.id,
|
||||
'start_time': r.start_time.strftime('%H:%M') if r.start_time else '',
|
||||
'end_time': r.end_time.strftime('%H:%M') if r.end_time else '',
|
||||
'date_display': r.start_time.strftime('%d/%m') if r.start_time else '',
|
||||
'classroom': {
|
||||
'id': r.classroom_id,
|
||||
'code': classroom_code,
|
||||
'is_virtual': is_virtual,
|
||||
'location_display': getattr(r.classroom, 'location_display', f'Sede Central · {classroom_code}') if r.classroom else 'Sin Aula'
|
||||
},
|
||||
'subject_name': subject_name,
|
||||
'subject_code': subject_code,
|
||||
'shift': r.shift or 'Mañana',
|
||||
'commission': {
|
||||
'id': r.commission_id,
|
||||
'code': r.commission.code if r.commission else 'COM-1'
|
||||
},
|
||||
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
|
||||
'user': {
|
||||
'id': r.user_id,
|
||||
'name': r.user.name if r.user else 'Docente'
|
||||
},
|
||||
'virtual_link': r.virtual_link or ('https://meet.google.com/edu-space-' + classroom_code.lower().replace(' ', '-') if is_virtual else '')
|
||||
}
|
||||
|
||||
def serialize_milestone(m):
|
||||
return {
|
||||
'id': m.id,
|
||||
'title': m.title,
|
||||
'materia_codigo': m.materia_codigo,
|
||||
'subject_name': m.subject.name if (hasattr(m, 'subject') and m.subject) else m.materia_codigo,
|
||||
'date': m.date.strftime('%d/%m/%Y') if m.date else '',
|
||||
'type_obj': {
|
||||
'name': m.type_obj.name if m.type_obj else 'Evaluación',
|
||||
'color': m.type_obj.color if m.type_obj else '#0d6efd'
|
||||
},
|
||||
'classroom': m.classroom or 'Sede Central',
|
||||
'link': m.link or '',
|
||||
'notes': m.notes or ''
|
||||
}
|
||||
|
||||
@api_dashboard_bp.route('/stats', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_stats():
|
||||
"""Retorna estadísticas clave y datos operativos para el Dashboard."""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
try:
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||
total_users = User.query.filter_by(is_active=True).count()
|
||||
|
||||
# Aulas presenciales y virtuales
|
||||
virtual_classrooms = Classroom.query.filter_by(is_active=True).filter(
|
||||
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
|
||||
).count()
|
||||
physical_classrooms = max(1, total_classrooms - virtual_classrooms)
|
||||
|
||||
today_reservations_q = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
today_reservations = len(today_reservations_q)
|
||||
|
||||
pending_reservations_q = Reservation.query.filter(
|
||||
Reservation.status == 'PENDING'
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
pending_count = len(pending_reservations_q)
|
||||
|
||||
upcoming_reservations_q = Reservation.query.filter(
|
||||
Reservation.start_time >= datetime.utcnow()
|
||||
).order_by(Reservation.start_time.asc()).limit(10).all()
|
||||
|
||||
milestones_q = AcademicMilestone.query.filter(
|
||||
AcademicMilestone.is_active == True
|
||||
).order_by(AcademicMilestone.date.asc()).limit(10).all()
|
||||
|
||||
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
|
||||
audit_data = [{
|
||||
'created_at': a.created_at.strftime('%Y-%m-%d %H:%M') if a.created_at else None,
|
||||
'action_type': a.action_type,
|
||||
'ip_address': a.ip_address,
|
||||
'user_email': getattr(a.user, 'email', 'System') if hasattr(a, 'user') else 'System'
|
||||
} for a in recent_audits]
|
||||
|
||||
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
|
||||
|
||||
pending_reservations = [serialize_reservation(r) for r in pending_reservations_q]
|
||||
today_schedule_list = [serialize_reservation(r) for r in today_reservations_q]
|
||||
upcoming_reservations = [serialize_reservation(r) for r in upcoming_reservations_q]
|
||||
milestones = [serialize_milestone(m) for m in milestones_q]
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching stats: {e}")
|
||||
total_classrooms = 0
|
||||
total_subjects = 0
|
||||
total_users = 0
|
||||
today_reservations = 0
|
||||
pending_count = 0
|
||||
physical_classrooms = 0
|
||||
virtual_classrooms = 0
|
||||
occupancy_rate = 0
|
||||
audit_data = []
|
||||
pending_reservations = []
|
||||
today_schedule_list = []
|
||||
upcoming_reservations = []
|
||||
milestones = []
|
||||
|
||||
# Mocked monthly data for chart since complex SQL group by month might differ based on dialect (SQLite vs Postgres)
|
||||
monthly_data = [
|
||||
{"month": "Ene", "reservations": 120},
|
||||
{"month": "Feb", "reservations": 180},
|
||||
{"month": "Mar", "reservations": 250},
|
||||
{"month": "Abr", "reservations": 220},
|
||||
{"month": "May", "reservations": 300},
|
||||
{"month": "Jun", "reservations": 310},
|
||||
{"month": "Jul", "reservations": 200},
|
||||
{"month": "Ago", "reservations": 350},
|
||||
{"month": "Sep", "reservations": 420},
|
||||
{"month": "Oct", "reservations": 480},
|
||||
{"month": "Nov", "reservations": 520},
|
||||
{"month": "Dic", "reservations": 210}
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'stats': {
|
||||
'total_classrooms': total_classrooms,
|
||||
'active_subjects': total_subjects,
|
||||
'total_active_users': total_users,
|
||||
'today_reservations': today_reservations,
|
||||
'pending_count': pending_count,
|
||||
'physical_classrooms': physical_classrooms,
|
||||
'virtual_classrooms': virtual_classrooms,
|
||||
'occupancy_rate': occupancy_rate
|
||||
},
|
||||
'audit_logs': audit_data,
|
||||
'monthly_data': monthly_data,
|
||||
'pending_reservations': pending_reservations,
|
||||
'today_schedule_list': today_schedule_list,
|
||||
'upcoming_reservations': upcoming_reservations,
|
||||
'milestones': milestones
|
||||
}), 200
|
||||
|
||||
@api_dashboard_bp.route('/sync-sheets', methods=['POST'])
|
||||
@jwt_required
|
||||
def sync_google_sheets():
|
||||
"""Ejecuta la sincronización real con las hojas de cálculo de Google Sheets."""
|
||||
from app.services.sheets_importer import GoogleSheetsImporter
|
||||
try:
|
||||
importer = GoogleSheetsImporter()
|
||||
stats = importer.sync()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'stats': stats
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,187 @@
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from datetime import datetime, timedelta, date
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.services.reservation_service import ReservationService
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
||||
from app.utils.jwt_decorators import jwt_required, jwt_role_required
|
||||
|
||||
api_reservations_bp = Blueprint('api_reservations', __name__, url_prefix='/api/v1/reservations')
|
||||
reservation_service = ReservationService()
|
||||
|
||||
@api_reservations_bp.route('', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_reservations():
|
||||
"""Consulta de reservas por rango de fechas y/o aula."""
|
||||
start_str = request.args.get('start_date')
|
||||
end_str = request.args.get('end_date')
|
||||
classroom_id = request.args.get('classroom_id', type=int)
|
||||
|
||||
now = datetime.utcnow()
|
||||
try:
|
||||
start_dt = datetime.strptime(start_str, '%Y-%m-%d') if start_str else now.replace(hour=0, minute=0, second=0)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de start_date (debe ser YYYY-MM-DD).'}), 400
|
||||
|
||||
try:
|
||||
end_dt = datetime.strptime(end_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59) if end_str else start_dt + timedelta(days=7)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de end_date (debe ser YYYY-MM-DD).'}), 400
|
||||
|
||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||
floor = request.args.get('floor')
|
||||
shift = request.args.get('shift')
|
||||
search = request.args.get('search')
|
||||
|
||||
reservations = reservation_service.list_reservations(start_dt, end_dt, classroom_id=classroom_id)
|
||||
result = []
|
||||
for r in reservations:
|
||||
is_virt = bool(r.is_virtual or (r.classroom and r.classroom.is_virtual))
|
||||
if hide_virtual and is_virt:
|
||||
continue
|
||||
if floor and r.classroom and str(r.classroom.floor).strip().lower() != str(floor).strip().lower():
|
||||
continue
|
||||
if shift and r.shift and shift.lower() not in r.shift.lower():
|
||||
continue
|
||||
if search:
|
||||
s_lower = search.lower()
|
||||
subj_n = (r.subject_name or '').lower()
|
||||
subj_c = (r.subject_code or '').lower()
|
||||
c_name = (r.classroom.code if r.classroom else '').lower()
|
||||
u_name = (r.user.name if r.user else '').lower()
|
||||
if s_lower not in subj_n and s_lower not in subj_c and s_lower not in c_name and s_lower not in u_name:
|
||||
continue
|
||||
|
||||
result.append({
|
||||
'id': r.id,
|
||||
'classroom_id': r.classroom_id,
|
||||
'classroom_name': r.classroom.code if r.classroom else None,
|
||||
'classroom': {
|
||||
'id': r.classroom.id,
|
||||
'code': r.classroom.code,
|
||||
'code_display': getattr(r.classroom, 'code_display', r.classroom.code),
|
||||
'floor': r.classroom.floor,
|
||||
'floor_display': getattr(r.classroom, 'floor_display', f"Piso {r.classroom.floor}"),
|
||||
'building': getattr(r.classroom, 'building_name', r.classroom.building),
|
||||
'is_virtual': getattr(r.classroom, 'is_virtual', False),
|
||||
'location_display': getattr(r.classroom, 'location_display', r.classroom.code)
|
||||
} if r.classroom else None,
|
||||
'commission_id': r.commission_id,
|
||||
'commission': {
|
||||
'id': r.commission.id,
|
||||
'code': r.commission.code,
|
||||
'virtual_link': r.commission.virtual_link,
|
||||
'max_students': r.commission.max_students
|
||||
} if r.commission else None,
|
||||
'teacher_name': r.user.name if r.user else None,
|
||||
'user': {
|
||||
'id': r.user.id,
|
||||
'name': r.user.name,
|
||||
'email': r.user.email
|
||||
} if r.user else None,
|
||||
'start_time': r.start_time.isoformat(),
|
||||
'end_time': r.end_time.isoformat(),
|
||||
'purpose': r.purpose,
|
||||
'subject_name': r.subject_name,
|
||||
'subject_code': r.subject_code,
|
||||
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
|
||||
'shift': r.shift or 'Mañana',
|
||||
'virtual_link': r.virtual_link,
|
||||
'effective_virtual_link': r.effective_virtual_link,
|
||||
'is_virtual': is_virt,
|
||||
'expected_attendees': r.expected_attendees or 30,
|
||||
'notes': r.notes or ''
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'total': len(result),
|
||||
'start_date': start_dt.strftime('%Y-%m-%d'),
|
||||
'end_date': end_dt.strftime('%Y-%m-%d'),
|
||||
'reservations': result
|
||||
}), 200
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_reservation(reservation_id: int):
|
||||
"""Obtiene el detalle de una reserva específica."""
|
||||
r = reservation_service.get_reservation(reservation_id)
|
||||
if not r:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||
|
||||
return jsonify({
|
||||
'id': r.id,
|
||||
'classroom_id': r.classroom_id,
|
||||
'classroom_name': r.classroom.code if r.classroom else None,
|
||||
'location_display': r.classroom.location_display if r.classroom else None,
|
||||
'commission_id': r.commission_id,
|
||||
'teacher_id': r.user_id,
|
||||
'teacher_name': r.user.name if r.user else None,
|
||||
'start_time': r.start_time.isoformat(),
|
||||
'end_time': r.end_time.isoformat(),
|
||||
'purpose': r.purpose,
|
||||
'status': str(r.status),
|
||||
'shift': r.shift,
|
||||
'virtual_link': r.virtual_link,
|
||||
'expected_attendees': r.expected_attendees,
|
||||
'notes': r.notes
|
||||
}), 200
|
||||
|
||||
@api_reservations_bp.route('', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_reservation():
|
||||
"""Crea una reserva aplicando validación DTO y detección de colisiones."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Inyectar usuario autenticado si no se especifica
|
||||
if 'user_id' not in data:
|
||||
data['user_id'] = g.jwt_user.id
|
||||
|
||||
try:
|
||||
dto = ReservationCreateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
reservation = reservation_service.create_reservation(dto)
|
||||
return jsonify({
|
||||
'message': 'Reserva creada exitosamente.',
|
||||
'id': reservation.id,
|
||||
'classroom_id': reservation.classroom_id,
|
||||
'start_time': reservation.start_time.isoformat(),
|
||||
'end_time': reservation.end_time.isoformat(),
|
||||
'status': str(reservation.status)
|
||||
}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_reservation(reservation_id: int):
|
||||
"""Modifica una reserva existente."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = ReservationUpdateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
reservation = reservation_service.update_reservation(reservation_id, dto)
|
||||
return jsonify({
|
||||
'message': 'Reserva actualizada exitosamente.',
|
||||
'id': reservation.id,
|
||||
'start_time': reservation.start_time.isoformat(),
|
||||
'end_time': reservation.end_time.isoformat(),
|
||||
'status': str(reservation.status)
|
||||
}), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def cancel_reservation(reservation_id: int):
|
||||
"""Cancela una reserva."""
|
||||
cancelled = reservation_service.cancel_reservation(reservation_id)
|
||||
if not cancelled:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||
|
||||
return jsonify({'message': f'Reserva {reservation_id} cancelada correctamente.'}), 200
|
||||
@@ -0,0 +1,149 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, jsonify
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.forms.auth import LoginForm
|
||||
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 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():
|
||||
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:
|
||||
flash(_('Tu cuenta ha sido desactivada. Por favor contacta al administrador.'), 'error')
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
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']:
|
||||
session['language'] = user.preferred_language
|
||||
else:
|
||||
session.pop('language', None)
|
||||
|
||||
session.pop('login_lang_explicit', None)
|
||||
|
||||
next_page = request.args.get('next')
|
||||
if not next_page or not next_page.startswith('/'):
|
||||
next_page = url_for('main.dashboard')
|
||||
|
||||
user_full_name = f'{user.first_name} {user.last_name}'.strip() or user.name
|
||||
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)
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
"""Handle user logout"""
|
||||
logout_user()
|
||||
# Al cerrar sesión, se limpian las cookies de idioma para que el login detecte el navegador o use español
|
||||
session.pop('language', None)
|
||||
session.pop('login_lang_explicit', None)
|
||||
flash(_('Has cerrado sesión correctamente.'), 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@auth_bp.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def profile():
|
||||
"""User profile page with language and theme settings"""
|
||||
if request.method == 'POST':
|
||||
lang = request.form.get('preferred_language')
|
||||
if lang in ['es', 'en']:
|
||||
current_user.preferred_language = lang
|
||||
session['language'] = lang
|
||||
else:
|
||||
# Cadena vacía o None restablece la detección automática del navegador
|
||||
current_user.preferred_language = None
|
||||
session.pop('language', None)
|
||||
|
||||
theme = request.form.get('theme_preference')
|
||||
if theme in ['auto', 'light', 'dark']:
|
||||
current_user.theme_preference = theme
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Preferencias de usuario actualizadas correctamente.'), 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
return render_template('auth/profile.html')
|
||||
|
||||
@auth_bp.route('/api/user/theme', methods=['POST'])
|
||||
@login_required
|
||||
def update_theme_api():
|
||||
"""Update user theme preference via fetch/AJAX from navbar toggle"""
|
||||
data = request.get_json(silent=True) or request.form
|
||||
theme = data.get('theme') if data else None
|
||||
if theme in ['auto', 'light', 'dark']:
|
||||
current_user.theme_preference = theme
|
||||
db.session.commit()
|
||||
return jsonify({'status': 'success', 'theme': theme})
|
||||
return jsonify({'status': 'error', 'message': 'Invalid theme'}), 400
|
||||
|
||||
|
||||
|
||||
@auth_bp.route('/change_password', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
"""Change user password"""
|
||||
if request.method == 'POST':
|
||||
current_password = request.form.get('current_password')
|
||||
new_password = request.form.get('new_password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
|
||||
if not current_user.check_password(current_password):
|
||||
flash('Current password is incorrect.', 'error')
|
||||
return redirect(url_for('auth.change_password'))
|
||||
|
||||
if new_password != confirm_password:
|
||||
flash('New passwords do not match.', 'error')
|
||||
return redirect(url_for('auth.change_password'))
|
||||
|
||||
if len(new_password) < 6:
|
||||
flash('Password must be at least 6 characters long.', 'error')
|
||||
return redirect(url_for('auth.change_password'))
|
||||
|
||||
current_user.set_password(new_password)
|
||||
db.session.commit()
|
||||
|
||||
flash('Password changed successfully!', 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
return render_template('auth/change_password.html')
|
||||
@@ -0,0 +1,193 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.building import Building
|
||||
from app.models.classroom import Classroom
|
||||
from app.forms.building import BuildingForm
|
||||
from app.utils.decorators import permission_required
|
||||
from datetime import datetime
|
||||
|
||||
buildings_bp = Blueprint('buildings', __name__, url_prefix='/buildings')
|
||||
|
||||
@buildings_bp.route('/')
|
||||
@login_required
|
||||
def list_buildings():
|
||||
"""List all buildings with statistics"""
|
||||
search = request.args.get('search', '').strip()
|
||||
status_filter = request.args.get('status', '')
|
||||
|
||||
query = Building.query
|
||||
if search:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Building.name.ilike(f'%{search}%'),
|
||||
Building.code.ilike(f'%{search}%'),
|
||||
Building.address.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
if status_filter == 'active':
|
||||
query = query.filter(Building.is_active == True)
|
||||
elif status_filter == 'inactive':
|
||||
query = query.filter(Building.is_active == False)
|
||||
|
||||
buildings = query.order_by(Building.name.asc()).all()
|
||||
|
||||
# Calculate global building stats
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
total_capacity = sum(c.capacity for c in Classroom.query.filter_by(is_active=True).all())
|
||||
|
||||
return render_template(
|
||||
'buildings/list.html',
|
||||
buildings=buildings,
|
||||
total_classrooms=total_classrooms,
|
||||
total_capacity=total_capacity,
|
||||
search=search,
|
||||
status_filter=status_filter,
|
||||
title='Building Management'
|
||||
)
|
||||
|
||||
@buildings_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def add_building():
|
||||
"""Add new building"""
|
||||
form = BuildingForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Check unique name
|
||||
existing = Building.query.filter(
|
||||
db.func.lower(Building.name) == form.name.data.lower()
|
||||
).first()
|
||||
if existing:
|
||||
flash('Ya existe un edificio con este nombre.', 'danger')
|
||||
return render_template('buildings/form.html', form=form, title='Add Building')
|
||||
|
||||
building = Building(
|
||||
name=form.name.data.strip(),
|
||||
code=form.code.data.strip().upper() if form.code.data else None,
|
||||
address=form.address.data.strip() if form.address.data else None,
|
||||
floors=form.floors.data,
|
||||
description=form.description.data.strip() if form.description.data else None,
|
||||
is_active=form.is_active.data
|
||||
)
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" creado con éxito.', 'success')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al crear el edificio: {str(e)}', 'danger')
|
||||
|
||||
return render_template('buildings/form.html', form=form, title='Add Building')
|
||||
|
||||
@buildings_bp.route('/<int:id>')
|
||||
@login_required
|
||||
def view_building(id):
|
||||
"""View building details and its classrooms by floor"""
|
||||
building = Building.query.get_or_404(id)
|
||||
|
||||
# Group active classrooms by floor
|
||||
classrooms = Classroom.query.filter_by(building_id=building.id).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
# Fallback to name matching if building_id was not set
|
||||
if not classrooms:
|
||||
classrooms = Classroom.query.filter_by(building=building.name).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
|
||||
classrooms_by_floor = {}
|
||||
for c in classrooms:
|
||||
floor_key = c.floor
|
||||
if floor_key not in classrooms_by_floor:
|
||||
classrooms_by_floor[floor_key] = []
|
||||
classrooms_by_floor[floor_key].append(c)
|
||||
|
||||
return render_template(
|
||||
'buildings/view.html',
|
||||
building=building,
|
||||
classrooms=classrooms,
|
||||
classrooms_by_floor=classrooms_by_floor,
|
||||
title=f'Building - {building.name}'
|
||||
)
|
||||
|
||||
@buildings_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def edit_building(id):
|
||||
"""Edit building"""
|
||||
building = Building.query.get_or_404(id)
|
||||
form = BuildingForm(obj=building)
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Check unique name excluding current
|
||||
existing = Building.query.filter(
|
||||
db.func.lower(Building.name) == form.name.data.lower(),
|
||||
Building.id != id
|
||||
).first()
|
||||
if existing:
|
||||
flash('Ya existe otro edificio con este nombre.', 'danger')
|
||||
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
|
||||
|
||||
old_name = building.name
|
||||
new_name = form.name.data.strip()
|
||||
|
||||
building.name = new_name
|
||||
building.code = form.code.data.strip().upper() if form.code.data else None
|
||||
building.address = form.address.data.strip() if form.address.data else None
|
||||
building.floors = form.floors.data
|
||||
building.description = form.description.data.strip() if form.description.data else None
|
||||
building.is_active = form.is_active.data
|
||||
building.updated_at = datetime.utcnow()
|
||||
|
||||
# If name changed, update string building on classrooms
|
||||
if old_name != new_name:
|
||||
Classroom.query.filter_by(building_id=building.id).update({'building': new_name})
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" actualizado con éxito.', 'success')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al actualizar el edificio: {str(e)}', 'danger')
|
||||
|
||||
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
|
||||
|
||||
@buildings_bp.route('/<int:id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def toggle_building(id):
|
||||
"""Toggle building active status"""
|
||||
building = Building.query.get_or_404(id)
|
||||
try:
|
||||
building.is_active = not building.is_active
|
||||
building.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
status_text = 'activado' if building.is_active else 'desactivado'
|
||||
flash(f'Edificio "{building.name}" {status_text} con éxito.', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al cambiar estado del edificio: {str(e)}', 'danger')
|
||||
return redirect(url_for('buildings.list_buildings'))
|
||||
|
||||
@buildings_bp.route('/<int:id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def delete_building(id):
|
||||
"""Delete building if it has no classrooms assigned"""
|
||||
building = Building.query.get_or_404(id)
|
||||
has_classrooms = Classroom.query.filter(
|
||||
db.or_(Classroom.building_id == building.id, Classroom.building == building.name)
|
||||
).count()
|
||||
|
||||
if has_classrooms > 0:
|
||||
flash(f'No se puede eliminar el edificio "{building.name}" porque tiene {has_classrooms} aulas asociadas. Primero reasigna o elimina las aulas.', 'danger')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
|
||||
try:
|
||||
db.session.delete(building)
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" eliminado correctamente.', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al eliminar edificio: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('buildings.list_buildings'))
|
||||
@@ -0,0 +1,513 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime, date
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.user import User
|
||||
from app.forms.classroom import ClassroomForm
|
||||
from app import db
|
||||
from sqlalchemy import text
|
||||
|
||||
classrooms_bp = Blueprint('classrooms', __name__)
|
||||
|
||||
@classrooms_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_classroom():
|
||||
"""Add a new classroom with dynamic building creation and floor suggestion"""
|
||||
from app.models.building import Building
|
||||
import re
|
||||
from sqlalchemy import func
|
||||
|
||||
form = ClassroomForm()
|
||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
|
||||
|
||||
# Map of existing floors per building for datalist
|
||||
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
building = None
|
||||
if form.building_id.data == '__new__':
|
||||
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
|
||||
if not new_building_name:
|
||||
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||
|
||||
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
|
||||
if not building:
|
||||
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
|
||||
building = Building(name=new_building_name, code=code_slug, is_active=True)
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
else:
|
||||
building = Building.query.get_or_404(int(form.building_id.data))
|
||||
|
||||
room_code = form.room_number.data.strip()
|
||||
floor_val = str(form.floor.data).strip()
|
||||
|
||||
# Prevent duplicate room in same building & floor
|
||||
existing_classroom = Classroom.query.filter_by(
|
||||
building_id=building.id,
|
||||
code=room_code,
|
||||
floor=floor_val
|
||||
).first()
|
||||
|
||||
if existing_classroom:
|
||||
flash(f'Ya existe un aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||
|
||||
cap_raw = str(form.capacity.data or '').strip()
|
||||
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||
cap_val = 9999
|
||||
else:
|
||||
cap_val = int(cap_raw)
|
||||
|
||||
from app.services.classroom_service import ClassroomService
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
dto = ClassroomCreateDTO(
|
||||
building_id=building.id,
|
||||
code=room_code,
|
||||
floor=floor_val,
|
||||
capacity=cap_val,
|
||||
description=form.description.data,
|
||||
is_active=form.is_active.data
|
||||
)
|
||||
classroom_service = ClassroomService()
|
||||
classroom = classroom_service.create_classroom(dto)
|
||||
|
||||
# Registrar en bitácora inmutable de auditoría
|
||||
AuditService.log('CREATE', 'classrooms', entity_id=classroom.id, details={'code': classroom.code, 'building': building.name})
|
||||
|
||||
flash(f'Aula {classroom.location_display} creada exitosamente.', 'success')
|
||||
return redirect(url_for('classrooms.list_classrooms'))
|
||||
|
||||
except ValueError as ve:
|
||||
flash(str(ve), 'error')
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al crear el aula: {str(e)}', 'error')
|
||||
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||
|
||||
|
||||
@classrooms_bp.route('/', endpoint='index')
|
||||
@classrooms_bp.route('/list')
|
||||
@login_required
|
||||
def list_classrooms():
|
||||
"""List all classrooms with separation between Physical spaces and Virtual on-demand rooms"""
|
||||
try:
|
||||
from app.models.building import Building
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.subject import Commission, Subject
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
active_only = request.args.get('active_only', 'true').lower() == 'true'
|
||||
search = request.args.get('search', '', type=str).strip()
|
||||
building_filter = request.args.get('building_id', '', type=str).strip()
|
||||
floor_filter = request.args.get('floor', '', type=str).strip()
|
||||
view_mode = request.args.get('view_mode', '').strip().lower()
|
||||
|
||||
# Identificar edificio virtual y aula virtual base
|
||||
virtual_building = Building.query.filter(
|
||||
db.or_(Building.code == 'VIRTUAL', Building.name.ilike('%Virtual%'))
|
||||
).first()
|
||||
virtual_bldg_id = virtual_building.id if virtual_building else None
|
||||
|
||||
# Si el usuario seleccionó "Campus Virtual" en el filtro de edificios, conmutar a pestaña virtual
|
||||
if building_filter and str(building_filter) == str(virtual_bldg_id):
|
||||
view_mode = 'virtual'
|
||||
elif not view_mode:
|
||||
view_mode = 'physical'
|
||||
|
||||
# Fechas operativas (Día, Semana, Mes)
|
||||
today = datetime.utcnow().date()
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
today_end = datetime.combine(today, datetime.max.time())
|
||||
|
||||
week_start = datetime.combine(today - timedelta(days=today.weekday()), datetime.min.time())
|
||||
week_end = datetime.combine(week_start.date() + timedelta(days=6), datetime.max.time())
|
||||
|
||||
_, last_day = calendar.monthrange(today.year, today.month)
|
||||
month_start = datetime(today.year, today.month, 1, 0, 0, 0)
|
||||
month_end = datetime(today.year, today.month, last_day, 23, 59, 59)
|
||||
|
||||
date_filters = {
|
||||
'today': today.strftime('%Y-%m-%d'),
|
||||
'week_start': week_start.strftime('%Y-%m-%d'),
|
||||
'week_end': week_end.strftime('%Y-%m-%d'),
|
||||
'month_start': month_start.strftime('%Y-%m-%d'),
|
||||
'month_end': month_end.strftime('%Y-%m-%d')
|
||||
}
|
||||
|
||||
# --- AULAS FÍSICAS (Presenciales) ---
|
||||
physical_query = Classroom.query
|
||||
if virtual_bldg_id:
|
||||
physical_query = physical_query.filter(Classroom.building_id != virtual_bldg_id)
|
||||
else:
|
||||
physical_query = physical_query.filter(~Classroom.code.ilike('%VIRTUAL%'))
|
||||
|
||||
if active_only:
|
||||
physical_query = physical_query.filter_by(is_active=True)
|
||||
|
||||
physical_count = physical_query.count()
|
||||
|
||||
if building_filter and building_filter.isdigit() and str(building_filter) != str(virtual_bldg_id):
|
||||
physical_query = physical_query.filter(Classroom.building_id == int(building_filter))
|
||||
|
||||
if floor_filter:
|
||||
flr = str(floor_filter).strip()
|
||||
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
||||
physical_query = physical_query.filter(db.or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
||||
else:
|
||||
physical_query = physical_query.filter(Classroom.floor == flr)
|
||||
|
||||
if search and view_mode == 'physical':
|
||||
physical_query = physical_query.filter(
|
||||
db.or_(
|
||||
Classroom.building.ilike(f'%{search}%'),
|
||||
Classroom.code.ilike(f'%{search}%'),
|
||||
Classroom.description.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
classrooms = physical_query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
|
||||
page=page, per_page=20, error_out=False
|
||||
)
|
||||
|
||||
# Edificios y Pisos estrictamente físicos para el filtro
|
||||
buildings = Building.query.filter_by(is_active=True)
|
||||
if virtual_bldg_id:
|
||||
buildings = buildings.filter(Building.id != virtual_bldg_id)
|
||||
buildings = buildings.order_by(Building.name.asc()).all()
|
||||
|
||||
floors = [f[0] for f in db.session.query(Classroom.floor).filter(
|
||||
Classroom.is_active == True,
|
||||
Classroom.building_id != virtual_bldg_id if virtual_bldg_id else True,
|
||||
Classroom.floor.isnot(None)
|
||||
).distinct().order_by(Classroom.floor.asc()).all()]
|
||||
|
||||
# Métricas de ocupación física
|
||||
visible_ids = [c.id for c in classrooms.items]
|
||||
month_reservations = Reservation.query.filter(
|
||||
Reservation.classroom_id.in_(visible_ids),
|
||||
Reservation.start_time >= month_start,
|
||||
Reservation.start_time <= month_end,
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
||||
).all() if visible_ids else []
|
||||
|
||||
DAILY_OP_MINS = 15 * 60
|
||||
WEEKLY_OP_MINS = 6 * 15 * 60
|
||||
MONTHLY_OP_MINS = 24 * 15 * 60
|
||||
|
||||
occupancy_metrics = {}
|
||||
for c in classrooms.items:
|
||||
c_res = [r for r in month_reservations if r.classroom_id == c.id]
|
||||
day_res = [r for r in c_res if today_start <= r.start_time <= today_end]
|
||||
day_mins = sum((min(r.end_time, today_end) - max(r.start_time, today_start)).total_seconds() / 60 for r in day_res)
|
||||
day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100)))
|
||||
|
||||
week_res = [r for r in c_res if week_start <= r.start_time <= week_end]
|
||||
week_mins = sum((min(r.end_time, week_end) - max(r.start_time, week_start)).total_seconds() / 60 for r in week_res)
|
||||
week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100)))
|
||||
|
||||
month_mins = sum((min(r.end_time, month_end) - max(r.start_time, month_start)).total_seconds() / 60 for r in c_res)
|
||||
month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100)))
|
||||
|
||||
occupancy_metrics[c.id] = {
|
||||
'day_pct': day_pct,
|
||||
'day_count': len(day_res),
|
||||
'week_pct': week_pct,
|
||||
'week_count': len(week_res),
|
||||
'month_pct': month_pct,
|
||||
'month_count': len(c_res)
|
||||
}
|
||||
|
||||
# --- AULAS VIRTUALES (Campus Virtual a Demanda - Infinitas) ---
|
||||
vc = Classroom.query.filter(
|
||||
Classroom.building_id == virtual_bldg_id if virtual_bldg_id else Classroom.code.ilike('%VIRTUAL%')
|
||||
).first()
|
||||
vc_id = vc.id if vc else 1
|
||||
|
||||
all_vres = Reservation.query.filter(Reservation.classroom_id == vc_id).all() if vc_id else []
|
||||
|
||||
# Agrupar las salas virtuales por comisión
|
||||
comm_dict = {}
|
||||
for r in all_vres:
|
||||
c = r.commission
|
||||
cid = c.id if c else 0
|
||||
if cid not in comm_dict:
|
||||
vlink = (c.virtual_link if c and c.virtual_link else None) or r.effective_virtual_link
|
||||
comm_dict[cid] = {
|
||||
'commission_id': cid,
|
||||
'commission_code': c.code if c else 'General',
|
||||
'subject_name': c.subject.name if (c and c.subject) else (r.purpose or 'Clase Virtual'),
|
||||
'subject_code': c.subject.code if (c and c.subject) else '',
|
||||
'career_name': c.subject.career.name if (c and c.subject and hasattr(c.subject, 'career') and c.subject.career) else '',
|
||||
'max_students': c.max_students if c else (r.expected_attendees or 0),
|
||||
'virtual_link': vlink,
|
||||
'reservations': []
|
||||
}
|
||||
comm_dict[cid]['reservations'].append(r)
|
||||
if not comm_dict[cid]['virtual_link'] and r.effective_virtual_link:
|
||||
comm_dict[cid]['virtual_link'] = r.effective_virtual_link
|
||||
|
||||
virtual_rooms = []
|
||||
for cid, data in comm_dict.items():
|
||||
res_list = data['reservations']
|
||||
day_cnt = sum(1 for r in res_list if today_start <= r.start_time <= today_end)
|
||||
week_cnt = sum(1 for r in res_list if week_start <= r.start_time <= week_end)
|
||||
month_cnt = sum(1 for r in res_list if month_start <= r.start_time <= month_end)
|
||||
shift = res_list[0].shift if res_list else 'A Demanda'
|
||||
|
||||
if search and view_mode == 'virtual':
|
||||
s_low = search.lower()
|
||||
matches = (
|
||||
s_low in data['subject_name'].lower() or
|
||||
s_low in data['commission_code'].lower() or
|
||||
s_low in data['subject_code'].lower() or
|
||||
s_low in data['career_name'].lower()
|
||||
)
|
||||
if not matches:
|
||||
continue
|
||||
|
||||
virtual_rooms.append({
|
||||
'commission_id': data['commission_id'],
|
||||
'commission_code': data['commission_code'],
|
||||
'subject_name': data['subject_name'],
|
||||
'subject_code': data['subject_code'],
|
||||
'career_name': data['career_name'],
|
||||
'max_students': data['max_students'],
|
||||
'virtual_link': data['virtual_link'],
|
||||
'shift': shift,
|
||||
'day_count': day_cnt,
|
||||
'week_count': week_cnt,
|
||||
'month_count': month_cnt,
|
||||
'total_count': len(res_list),
|
||||
'reservations': sorted(res_list, key=lambda x: x.start_time)
|
||||
})
|
||||
|
||||
virtual_rooms.sort(key=lambda x: (x['subject_name'], x['commission_code']))
|
||||
|
||||
global_virtual_metrics = {
|
||||
'today_count': sum(1 for r in all_vres if today_start <= r.start_time <= today_end),
|
||||
'week_count': sum(1 for r in all_vres if week_start <= r.start_time <= week_end),
|
||||
'month_count': sum(1 for r in all_vres if month_start <= r.start_time <= month_end),
|
||||
'total_count': len(all_vres),
|
||||
'commissions_count': len(comm_dict)
|
||||
}
|
||||
|
||||
return render_template('classrooms/list.html',
|
||||
classrooms=classrooms,
|
||||
buildings=buildings,
|
||||
floors=floors,
|
||||
building_filter=building_filter,
|
||||
floor_filter=floor_filter,
|
||||
active_only=active_only,
|
||||
search=search,
|
||||
view_mode=view_mode,
|
||||
physical_count=physical_count,
|
||||
virtual_count=len(comm_dict),
|
||||
virtual_rooms=virtual_rooms,
|
||||
virtual_classroom_id=vc_id,
|
||||
global_virtual_metrics=global_virtual_metrics,
|
||||
occupancy_metrics=occupancy_metrics,
|
||||
date_filters=date_filters,
|
||||
title='Classrooms')
|
||||
|
||||
except Exception as e:
|
||||
flash(f'Error loading classrooms: {str(e)}', 'error')
|
||||
return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
|
||||
|
||||
|
||||
@classrooms_bp.route('/virtual-room/<int:commission_id>/update-link', methods=['POST'])
|
||||
@login_required
|
||||
def update_virtual_link(commission_id):
|
||||
"""Actualizar enlace de videollamada para una comisión virtual"""
|
||||
from app.models.subject import Commission
|
||||
from flask_babel import gettext as _
|
||||
comm = Commission.query.get_or_404(commission_id)
|
||||
new_link = request.form.get('virtual_link', '').strip() or None
|
||||
comm.virtual_link = new_link
|
||||
db.session.commit()
|
||||
flash(_('Enlace de videollamada para %(code)s actualizado correctamente.', code=comm.get_full_code()), 'success')
|
||||
return redirect(request.referrer or url_for('classrooms.list_classrooms', view_mode='virtual'))
|
||||
|
||||
|
||||
@classrooms_bp.route('/<int:id>')
|
||||
@login_required
|
||||
def view_classroom(id):
|
||||
"""View classroom details"""
|
||||
try:
|
||||
classroom = Classroom.query.get_or_404(id)
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
|
||||
upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.classroom_id == id,
|
||||
Reservation.start_time > datetime.utcnow(),
|
||||
Reservation.status == ReservationStatus.CONFIRMED.value
|
||||
).order_by(Reservation.start_time).limit(10).all()
|
||||
|
||||
return render_template('classrooms/view.html',
|
||||
classroom=classroom,
|
||||
upcoming_reservations=upcoming_reservations,
|
||||
title=f'Classroom {classroom.code_display}')
|
||||
|
||||
except Exception as e:
|
||||
flash(f'Error loading classroom: {str(e)}', 'error')
|
||||
return redirect(url_for('classrooms.list_classrooms'))
|
||||
|
||||
|
||||
@classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_classroom(id):
|
||||
"""Edit classroom with decoupled building and floor"""
|
||||
from app.models.building import Building
|
||||
import re
|
||||
from sqlalchemy import func
|
||||
|
||||
classroom = Classroom.query.get_or_404(id)
|
||||
form = ClassroomForm(obj=classroom)
|
||||
|
||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
|
||||
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
|
||||
|
||||
if request.method == 'GET':
|
||||
form.building_id.data = str(classroom.building_id or '')
|
||||
form.room_number.data = classroom.code
|
||||
form.floor.data = classroom.floor
|
||||
if classroom.capacity == 0 or classroom.is_virtual:
|
||||
form.capacity.data = '∞'
|
||||
else:
|
||||
form.capacity.data = str(classroom.capacity)
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
building = None
|
||||
if form.building_id.data == '__new__':
|
||||
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
|
||||
if not new_building_name:
|
||||
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
|
||||
|
||||
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
|
||||
if not building:
|
||||
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
|
||||
building = Building(name=new_building_name, code=code_slug, is_active=True)
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
else:
|
||||
building = Building.query.get_or_404(int(form.building_id.data))
|
||||
|
||||
room_code = form.room_number.data.strip()
|
||||
floor_val = str(form.floor.data).strip()
|
||||
|
||||
# Check duplicate excluding current
|
||||
existing_classroom = Classroom.query.filter(
|
||||
Classroom.building_id == building.id,
|
||||
Classroom.code == room_code,
|
||||
Classroom.floor == floor_val,
|
||||
Classroom.id != id
|
||||
).first()
|
||||
|
||||
if existing_classroom:
|
||||
flash(f'Ya existe otra aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
|
||||
|
||||
cap_raw = str(form.capacity.data or '').strip()
|
||||
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||
cap_val = 0
|
||||
else:
|
||||
cap_val = int(cap_raw)
|
||||
|
||||
classroom.building_id = building.id
|
||||
classroom.building = building.name
|
||||
classroom.code = room_code
|
||||
classroom.floor = floor_val
|
||||
classroom.capacity = cap_val
|
||||
classroom.description = form.description.data
|
||||
classroom.is_active = form.is_active.data
|
||||
classroom.updated_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Aula {classroom.location_display} actualizada correctamente.', 'success')
|
||||
return redirect(url_for('classrooms.view_classroom', id=classroom.id))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error updating classroom: {str(e)}', 'error')
|
||||
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
|
||||
|
||||
@classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_classroom(id):
|
||||
"""Toggle classroom active status"""
|
||||
classroom = Classroom.query.get_or_404(id)
|
||||
|
||||
try:
|
||||
classroom.is_active = not classroom.is_active
|
||||
classroom.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
status = 'activated' if classroom.is_active else 'deactivated'
|
||||
flash(f'Classroom {classroom.code_display} has been {status} successfully!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error updating classroom status: {str(e)}', 'error')
|
||||
|
||||
return redirect(request.referrer or url_for('classrooms.list_classrooms'))
|
||||
|
||||
# API endpoints for AJAX requests
|
||||
@classrooms_bp.route('/api/available')
|
||||
@login_required
|
||||
def get_available_classrooms():
|
||||
"""Get available classrooms for a specific time slot"""
|
||||
start_time = request.args.get('start_time')
|
||||
end_time = request.args.get('end_time')
|
||||
|
||||
if not start_time or not end_time:
|
||||
return jsonify({'error': 'Start time and end time are required'}), 400
|
||||
|
||||
try:
|
||||
from datetime import datetime
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
|
||||
start_dt = datetime.fromisoformat(start_time)
|
||||
end_dt = datetime.fromisoformat(end_time)
|
||||
|
||||
# Find all classrooms
|
||||
all_classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
available_classrooms = []
|
||||
|
||||
for classroom in all_classrooms:
|
||||
# Check if classroom has conflicting reservations
|
||||
conflicts = Reservation.query.filter(
|
||||
Reservation.classroom_id == classroom.id,
|
||||
Reservation.status == ReservationStatus.CONFIRMED.value,
|
||||
Reservation.start_time < end_dt,
|
||||
Reservation.end_time > start_dt
|
||||
).all()
|
||||
|
||||
if not conflicts:
|
||||
available_classrooms.append({
|
||||
'id': classroom.id,
|
||||
'code': classroom.code,
|
||||
'building': classroom.building,
|
||||
'room_number': classroom.code,
|
||||
'floor': classroom.floor,
|
||||
'capacity': classroom.capacity,
|
||||
'code_display': classroom.code_display
|
||||
})
|
||||
|
||||
return jsonify({'classrooms': available_classrooms})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -0,0 +1,255 @@
|
||||
from flask import Blueprint, request, jsonify, current_app, render_template
|
||||
from datetime import datetime, timedelta
|
||||
from app.models.genetic_algorithm import ReservationOptimizer
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app import db
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
genetic_bp = Blueprint('genetic_algorithm', __name__, url_prefix='/api/genetic')
|
||||
|
||||
|
||||
@genetic_bp.route('/optimize', methods=['POST'])
|
||||
@login_required
|
||||
def optimize_reservations():
|
||||
"""Optimize reservations using genetic algorithm"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'commission_ids' not in data:
|
||||
return jsonify({'error': 'Missing commission_ids in request'}), 400
|
||||
|
||||
commission_ids = data['commission_ids']
|
||||
|
||||
if not isinstance(commission_ids, list) or not commission_ids:
|
||||
return jsonify({'error': 'commission_ids must be a non-empty list'}), 400
|
||||
|
||||
# Optional parameters
|
||||
start_date = None
|
||||
end_date = None
|
||||
|
||||
if 'start_date' in data:
|
||||
try:
|
||||
start_date = datetime.fromisoformat(data['start_date'])
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid start_date format. Use ISO format.'}), 400
|
||||
|
||||
if 'end_date' in data:
|
||||
try:
|
||||
end_date = datetime.fromisoformat(data['end_date'])
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
|
||||
|
||||
# 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
|
||||
optimizer = ReservationOptimizer()
|
||||
result = optimizer.optimize_schedule(
|
||||
commission_ids=commission_ids,
|
||||
admin_user_id=current_user.id,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'fitness_score': result['fitness_score'],
|
||||
'total_requests': result['total_requests'],
|
||||
'assigned_reservations': result['assigned_reservations'],
|
||||
'reservations': result['reservations'],
|
||||
'conflicts': result['conflicts']
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error in genetic algorithm optimization: {str(e)}")
|
||||
return jsonify({'error': f'Optimization failed: {str(e)}'}), 500
|
||||
|
||||
|
||||
@genetic_bp.route('/apply-optimization', methods=['POST'])
|
||||
@login_required
|
||||
def apply_optimization():
|
||||
"""Apply optimized reservations to the database"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'reservations' not in data:
|
||||
return jsonify({'error': 'Missing reservations data'}), 400
|
||||
|
||||
reservations_data = data['reservations']
|
||||
|
||||
if not isinstance(reservations_data, list):
|
||||
return jsonify({'error': 'reservations must be a list'}), 400
|
||||
|
||||
# Check if user has permission
|
||||
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
|
||||
optimizer = ReservationOptimizer()
|
||||
applied_reservations = optimizer.apply_optimized_reservations(reservations_data)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'applied_count': len(applied_reservations),
|
||||
'reservations': [res.to_dict() for res in applied_reservations]
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error applying optimized reservations: {str(e)}")
|
||||
db.session.rollback()
|
||||
return jsonify({'error': f'Failed to apply reservations: {str(e)}'}), 500
|
||||
|
||||
|
||||
@genetic_bp.route('/preview-optimization', methods=['POST'])
|
||||
@login_required
|
||||
def preview_optimization():
|
||||
"""Preview optimization without applying to database"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'commission_ids' not in data:
|
||||
return jsonify({'error': 'Missing commission_ids in request'}), 400
|
||||
|
||||
commission_ids = data['commission_ids']
|
||||
|
||||
# Optional parameters
|
||||
dry_run = data.get('dry_run', True) # Default to dry run
|
||||
|
||||
if not isinstance(commission_ids, list) or not commission_ids:
|
||||
return jsonify({'error': 'commission_ids must be a non-empty list'}), 400
|
||||
|
||||
# Check if user has permission
|
||||
if current_user.role not in ['ADMIN', 'teacher']:
|
||||
return jsonify({'error': 'Insufficient permissions to preview optimization'}), 403
|
||||
|
||||
# Initialize optimizer and run optimization
|
||||
optimizer = ReservationOptimizer()
|
||||
result = optimizer.optimize_schedule(
|
||||
commission_ids=commission_ids,
|
||||
admin_user_id=current_user.id
|
||||
)
|
||||
|
||||
# Add additional preview information
|
||||
preview_data = {
|
||||
'optimization_result': result,
|
||||
'commission_details': [],
|
||||
'classroom_utilization': {},
|
||||
'time_distribution': {}
|
||||
}
|
||||
|
||||
# Get commission details
|
||||
from app.models.subject import Commission, Subject
|
||||
commissions = Commission.query.filter(Commission.id.in_(commission_ids)).all()
|
||||
|
||||
for commission in commissions:
|
||||
preview_data['commission_details'].append({
|
||||
'id': commission.id,
|
||||
'code': commission.get_full_code(),
|
||||
'name': commission.subject.name if commission.subject else 'Unknown',
|
||||
'max_students': commission.max_students,
|
||||
'current_students': commission.current_students
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': preview_data
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error in optimization preview: {str(e)}")
|
||||
return jsonify({'error': f'Preview failed: {str(e)}'}), 500
|
||||
|
||||
|
||||
@genetic_bp.route('/commissions', methods=['GET'])
|
||||
@login_required
|
||||
def get_commissions():
|
||||
"""Get available commissions for optimization"""
|
||||
try:
|
||||
# Check if user has permission
|
||||
if current_user.role not in ['ADMIN', 'teacher']:
|
||||
return jsonify({'error': 'Insufficient permissions'}), 403
|
||||
|
||||
from app.models.subject import Commission, Subject
|
||||
|
||||
# Get active commissions
|
||||
commissions = Commission.query.filter_by(active=True).all()
|
||||
|
||||
commissions_data = []
|
||||
for commission in commissions:
|
||||
commission_dict = commission.to_dict()
|
||||
commission_dict['get_full_code'] = commission.get_full_code()
|
||||
commissions_data.append(commission_dict)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'commissions': commissions_data
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting commissions: {str(e)}")
|
||||
return jsonify({'error': f'Failed to load commissions: {str(e)}'}), 500
|
||||
|
||||
|
||||
@genetic_bp.route('/algorithm-status', methods=['GET'])
|
||||
@login_required
|
||||
def get_algorithm_status():
|
||||
"""Get genetic algorithm configuration status"""
|
||||
try:
|
||||
# Check if user has permission
|
||||
if current_user.role not in ['ADMIN']:
|
||||
return jsonify({'error': 'Insufficient permissions'}), 403
|
||||
|
||||
# Get system status
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Commission
|
||||
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
total_commissions = Commission.query.filter_by(active=True).count()
|
||||
total_reservations = Reservation.query.filter_by(status=ReservationStatus.CONFIRMED.value).count()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'algorithm_config': {
|
||||
'population_size': 50,
|
||||
'generations': 100,
|
||||
'mutation_rate': 0.1,
|
||||
'crossover_rate': 0.8
|
||||
},
|
||||
'system_status': {
|
||||
'available_classrooms': total_classrooms,
|
||||
'active_commissions': total_commissions,
|
||||
'confirmed_reservations': total_reservations
|
||||
},
|
||||
'capabilities': [
|
||||
'Automatic classroom assignment',
|
||||
'Capacity optimization',
|
||||
'Time conflict resolution',
|
||||
'Resource matching',
|
||||
'Batch reservation processing'
|
||||
]
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting algorithm status: {str(e)}")
|
||||
return jsonify({'error': f'Status check failed: {str(e)}'}), 500
|
||||
|
||||
|
||||
# Web interface route (without API prefix)
|
||||
genetic_web_bp = Blueprint('genetic_algorithm_web', __name__)
|
||||
|
||||
|
||||
@genetic_web_bp.route('/genetic-optimizer')
|
||||
@login_required
|
||||
def genetic_optimizer():
|
||||
"""Render the genetic algorithm optimizer interface"""
|
||||
if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||
return render_template('auth/403.html'), 403
|
||||
|
||||
return render_template('genetic_optimizer.html')
|
||||
@@ -0,0 +1,333 @@
|
||||
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.reservation import Reservation
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject
|
||||
from app.models.subject import Commission
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func
|
||||
from flask_babel import gettext as _
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Role-personalized institutional dashboard page"""
|
||||
from app.models.user import User
|
||||
from app.models.milestone import AcademicMilestone
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
actual_role = current_user.get_institutional_role()
|
||||
view_as = request.args.get('view_as', '').lower().strip()
|
||||
|
||||
# Allow admins and management roles to preview dashboard perspectives
|
||||
if view_as in ['admin', 'bedelia', 'docente', 'alumno'] and (current_user.is_admin() or current_user.can_manage()):
|
||||
role = view_as
|
||||
else:
|
||||
role = actual_role
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
# Common core statistics
|
||||
try:
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
except Exception:
|
||||
total_classrooms = 0
|
||||
|
||||
try:
|
||||
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||
except Exception:
|
||||
total_subjects = 0
|
||||
|
||||
try:
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
except Exception:
|
||||
today_reservations = 0
|
||||
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
try:
|
||||
week_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
except Exception:
|
||||
week_reservations = 0
|
||||
|
||||
stats = {
|
||||
'total_classrooms': total_classrooms,
|
||||
'total_subjects': total_subjects,
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations
|
||||
}
|
||||
|
||||
role_data = {}
|
||||
|
||||
if role == 'admin':
|
||||
# 1. Admin Institutional Cockpit
|
||||
try:
|
||||
total_users = User.query.filter_by(is_active=True).count()
|
||||
except Exception:
|
||||
total_users = 0
|
||||
|
||||
try:
|
||||
pending_count = Reservation.query.filter(
|
||||
Reservation.status == 'PENDING'
|
||||
).count()
|
||||
except Exception:
|
||||
pending_count = 0
|
||||
|
||||
try:
|
||||
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
|
||||
except Exception:
|
||||
recent_audits = []
|
||||
|
||||
try:
|
||||
upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= datetime.utcnow(),
|
||||
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||
).order_by(Reservation.start_time.asc()).limit(8).all()
|
||||
except Exception:
|
||||
upcoming_reservations = []
|
||||
|
||||
stats.update({
|
||||
'total_users': total_users,
|
||||
'pending_reservations': pending_count
|
||||
})
|
||||
role_data = {
|
||||
'recent_audits': recent_audits,
|
||||
'upcoming_reservations': upcoming_reservations
|
||||
}
|
||||
|
||||
elif role == 'bedelia':
|
||||
# 2. Bedelía Operations & Facilities Control
|
||||
try:
|
||||
physical_classrooms = Classroom.query.filter_by(is_active=True).filter(
|
||||
~Classroom.room_number.ilike('%VIRTUAL%') & ~Classroom.building.ilike('%VIRTUAL%')
|
||||
).count()
|
||||
virtual_classrooms = total_classrooms - physical_classrooms
|
||||
if virtual_classrooms < 0:
|
||||
virtual_classrooms = 0
|
||||
except Exception:
|
||||
physical_classrooms = total_classrooms
|
||||
virtual_classrooms = 0
|
||||
|
||||
try:
|
||||
pending_reservations = Reservation.query.filter(
|
||||
Reservation.status == 'PENDING'
|
||||
).order_by(Reservation.start_time.asc()).limit(10).all()
|
||||
pending_count = len(pending_reservations)
|
||||
except Exception:
|
||||
pending_reservations = []
|
||||
pending_count = 0
|
||||
|
||||
try:
|
||||
today_schedule_list = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).order_by(Reservation.start_time.asc()).limit(12).all()
|
||||
except Exception:
|
||||
today_schedule_list = []
|
||||
|
||||
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
|
||||
|
||||
stats.update({
|
||||
'physical_classrooms': physical_classrooms,
|
||||
'virtual_classrooms': virtual_classrooms,
|
||||
'pending_count': pending_count,
|
||||
'occupancy_rate': occupancy_rate
|
||||
})
|
||||
role_data = {
|
||||
'pending_reservations': pending_reservations,
|
||||
'today_schedule_list': today_schedule_list
|
||||
}
|
||||
|
||||
elif role == 'docente':
|
||||
# 3. Docente (Profesor) Academic & Classroom Workflow
|
||||
try:
|
||||
my_today_classes = Reservation.query.filter(
|
||||
Reservation.user_id == current_user.id,
|
||||
func.date(Reservation.start_time) == today
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
except Exception:
|
||||
my_today_classes = []
|
||||
|
||||
try:
|
||||
my_upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.user_id == current_user.id,
|
||||
Reservation.start_time >= datetime.utcnow()
|
||||
).order_by(Reservation.start_time.asc()).limit(8).all()
|
||||
except Exception:
|
||||
my_upcoming_reservations = []
|
||||
|
||||
try:
|
||||
my_total_reservations = Reservation.query.filter(
|
||||
Reservation.user_id == current_user.id
|
||||
).count()
|
||||
except Exception:
|
||||
my_total_reservations = 0
|
||||
|
||||
try:
|
||||
virtual_classrooms = Classroom.query.filter_by(is_active=True).filter(
|
||||
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
|
||||
).limit(4).all()
|
||||
except Exception:
|
||||
virtual_classrooms = []
|
||||
|
||||
stats.update({
|
||||
'my_today_count': len(my_today_classes),
|
||||
'my_total_reservations': my_total_reservations,
|
||||
'virtual_rooms_count': len(virtual_classrooms)
|
||||
})
|
||||
role_data = {
|
||||
'my_today_classes': my_today_classes,
|
||||
'my_upcoming_reservations': my_upcoming_reservations,
|
||||
'virtual_classrooms': virtual_classrooms
|
||||
}
|
||||
|
||||
elif role == 'alumno':
|
||||
# 4. Alumno (Estudiante) Daily Guide & Timetable
|
||||
try:
|
||||
today_classes = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today,
|
||||
Reservation.status == 'CONFIRMED'
|
||||
).order_by(Reservation.start_time.asc()).limit(12).all()
|
||||
except Exception:
|
||||
today_classes = []
|
||||
|
||||
try:
|
||||
upcoming_milestones = AcademicMilestone.query.filter(
|
||||
AcademicMilestone.date >= datetime.utcnow(),
|
||||
AcademicMilestone.is_active == True
|
||||
).order_by(AcademicMilestone.date.asc()).limit(6).all()
|
||||
except Exception:
|
||||
upcoming_milestones = []
|
||||
|
||||
try:
|
||||
virtual_rooms = Classroom.query.filter_by(is_active=True).filter(
|
||||
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
|
||||
).limit(4).all()
|
||||
except Exception:
|
||||
virtual_rooms = []
|
||||
|
||||
stats.update({
|
||||
'today_classes_count': len(today_classes),
|
||||
'milestones_count': len(upcoming_milestones)
|
||||
})
|
||||
role_data = {
|
||||
'today_classes': today_classes,
|
||||
'upcoming_milestones': upcoming_milestones,
|
||||
'virtual_rooms': virtual_rooms
|
||||
}
|
||||
|
||||
# Backward compatibility for upcoming_reservations
|
||||
upcoming_reservations = role_data.get('upcoming_reservations', [])
|
||||
if not upcoming_reservations:
|
||||
try:
|
||||
upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= datetime.utcnow(),
|
||||
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||
).order_by(Reservation.start_time.asc()).limit(6).all()
|
||||
except Exception:
|
||||
upcoming_reservations = []
|
||||
|
||||
return render_template('dashboard.html',
|
||||
role=role,
|
||||
actual_role=actual_role,
|
||||
stats=stats,
|
||||
role_data=role_data,
|
||||
upcoming_reservations=upcoming_reservations)
|
||||
|
||||
@main_bp.route('/api/dashboard-stats')
|
||||
@login_required
|
||||
def dashboard_stats_api():
|
||||
"""API endpoint for dashboard statistics"""
|
||||
# Get today's data
|
||||
today = datetime.utcnow().date()
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
|
||||
# Get this week's data
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
week_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
|
||||
# Get monthly data for chart
|
||||
current_date = datetime.utcnow()
|
||||
monthly_data = []
|
||||
|
||||
for i in range(12):
|
||||
month_date = current_date - timedelta(days=30 * i)
|
||||
month_start = month_date.replace(day=1)
|
||||
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
|
||||
|
||||
monthly_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= month_start.date(),
|
||||
func.date(Reservation.start_time) <= month_end.date()
|
||||
).count()
|
||||
|
||||
monthly_data.append({
|
||||
'month': month_start.strftime('%Y-%m'),
|
||||
'reservations': monthly_reservations
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations,
|
||||
'monthly_data': list(reversed(monthly_data))
|
||||
})
|
||||
|
||||
@main_bp.route('/genetic-optimizer')
|
||||
@login_required
|
||||
def genetic_optimizer():
|
||||
"""Serve the genetic algorithm optimization interface"""
|
||||
if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||
flash(_('No tienes permisos para acceder a la herramienta de optimización.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('genetic_optimizer.html')
|
||||
|
||||
@main_bp.route('/set_language/<language>')
|
||||
def set_language(language=None):
|
||||
if language not in ['en', 'es']:
|
||||
language = 'es'
|
||||
|
||||
session['language'] = language
|
||||
|
||||
# Si se selecciona el idioma desde la pantalla de login, registrar la selección explícita
|
||||
if request.referrer and 'login' in request.referrer:
|
||||
session['login_lang_explicit'] = language
|
||||
|
||||
if current_user.is_authenticated:
|
||||
current_user.preferred_language = language
|
||||
db.session.commit()
|
||||
|
||||
return redirect(request.referrer or url_for('main.dashboard'))
|
||||
|
||||
|
||||
@main_bp.route('/api/milestone-types')
|
||||
def api_milestone_types():
|
||||
"""Retorna las tipificaciones de hitos activas en JSON para consumo de frontend/mobile"""
|
||||
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
|
||||
if MilestoneType.query.count() == 0:
|
||||
for item in BASE_MILESTONE_TYPES:
|
||||
mt = MilestoneType(
|
||||
name=item['name'],
|
||||
code=item['code'],
|
||||
color=item['color'],
|
||||
description=item['description'],
|
||||
sort_order=item['sort_order'],
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(mt)
|
||||
db.session.commit()
|
||||
|
||||
types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
|
||||
return jsonify([t.to_dict() for t in types])
|
||||
@@ -0,0 +1,126 @@
|
||||
from flask import Blueprint, render_template, abort
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.subject import Commission
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.classroom import Classroom
|
||||
|
||||
my_subjects_bp = Blueprint('my_subjects', __name__, url_prefix='/mis-materias')
|
||||
|
||||
|
||||
@my_subjects_bp.route('/')
|
||||
@login_required
|
||||
def my_subjects_list():
|
||||
"""Show the commissions assigned to the current user (teacher or student)."""
|
||||
role = current_user.get_institutional_role()
|
||||
|
||||
commissions = []
|
||||
|
||||
if role in ['admin', 'bedelia']:
|
||||
# Admin/Bedelia: see all active commissions
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter_by(active=True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'all'
|
||||
|
||||
elif role == 'docente':
|
||||
# Teacher: only commissions where they are assigned as teacher
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter_by(teacher_id=current_user.id, active=True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'teacher'
|
||||
|
||||
elif role == 'alumno':
|
||||
# Student: only commissions where they are enrolled
|
||||
enrollments = (
|
||||
StudentEnrollment.query
|
||||
.filter_by(student_id=current_user.id, status='activo')
|
||||
.all()
|
||||
)
|
||||
commission_ids = [e.commission_id for e in enrollments]
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter(Commission.id.in_(commission_ids), Commission.active == True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'student'
|
||||
else:
|
||||
commissions = []
|
||||
view_mode = 'none'
|
||||
|
||||
return render_template(
|
||||
'my_subjects/list.html',
|
||||
commissions=commissions,
|
||||
view_mode=view_mode,
|
||||
role=role
|
||||
)
|
||||
|
||||
|
||||
@my_subjects_bp.route('/<int:commission_id>')
|
||||
@login_required
|
||||
def my_subject_detail(commission_id):
|
||||
"""Detail view of a single commission for the current user."""
|
||||
role = current_user.get_institutional_role()
|
||||
commission = Commission.query.get_or_404(commission_id)
|
||||
|
||||
# Access control: make sure the user is allowed to see this commission
|
||||
if role in ['admin', 'bedelia']:
|
||||
pass # full access
|
||||
elif role == 'docente':
|
||||
if commission.teacher_id != current_user.id:
|
||||
abort(403)
|
||||
elif role == 'alumno':
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=current_user.id,
|
||||
commission_id=commission_id
|
||||
).first()
|
||||
if not enrollment:
|
||||
abort(403)
|
||||
else:
|
||||
abort(403)
|
||||
|
||||
# Get enrolled students (visible to teachers and admin/bedelia)
|
||||
enrollments = []
|
||||
if role in ['admin', 'bedelia', 'docente']:
|
||||
enrollments = (
|
||||
StudentEnrollment.query
|
||||
.filter_by(commission_id=commission_id)
|
||||
.order_by(StudentEnrollment.enrolled_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# Try to get classroom info from reservations linked to this commission
|
||||
from app.models.reservation import Reservation
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime
|
||||
|
||||
# Recent or upcoming reservation linked to this commission
|
||||
recent_reservation = (
|
||||
Reservation.query
|
||||
.filter(
|
||||
Reservation.commission_id == commission_id,
|
||||
Reservation.start_time >= datetime.utcnow()
|
||||
)
|
||||
.order_by(Reservation.start_time.asc())
|
||||
.first()
|
||||
)
|
||||
|
||||
classroom = None
|
||||
if recent_reservation and recent_reservation.classroom_id:
|
||||
classroom = Classroom.query.get(recent_reservation.classroom_id)
|
||||
|
||||
return render_template(
|
||||
'my_subjects/detail.html',
|
||||
commission=commission,
|
||||
enrollments=enrollments,
|
||||
classroom=classroom,
|
||||
recent_reservation=recent_reservation,
|
||||
role=role
|
||||
)
|
||||
@@ -0,0 +1,975 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject, Commission
|
||||
from app.forms.reservation import ReservationForm
|
||||
from datetime import datetime, timedelta, date
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
schedule_bp = Blueprint('schedule', __name__, url_prefix='/schedule')
|
||||
|
||||
@schedule_bp.route('/today')
|
||||
@login_required
|
||||
def today_schedule():
|
||||
"""Show today's schedule with filters for virtual classrooms, floor, and shift"""
|
||||
today = datetime.utcnow().date()
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
today_end = datetime.combine(today, datetime.max.time())
|
||||
|
||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||
floor_filter = request.args.get('floor', '').strip()
|
||||
shift_filter = request.args.get('shift', '').strip()
|
||||
|
||||
# Base query for today's reservations
|
||||
query = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject),
|
||||
joinedload(Reservation.user)
|
||||
).join(Reservation.classroom).filter(
|
||||
Reservation.start_time >= today_start,
|
||||
Reservation.start_time <= today_end,
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
||||
)
|
||||
|
||||
if hide_virtual:
|
||||
query = query.filter(
|
||||
~Classroom.code.ilike('%VIRTUAL%'),
|
||||
~Classroom.building.ilike('%Virtual%')
|
||||
)
|
||||
|
||||
if floor_filter:
|
||||
flr = str(floor_filter).strip()
|
||||
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
||||
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
||||
else:
|
||||
query = query.filter(Classroom.floor == flr)
|
||||
|
||||
if shift_filter:
|
||||
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
|
||||
|
||||
reservations = query.order_by(Reservation.start_time).all()
|
||||
|
||||
# Create time blocks for timeline view
|
||||
time_blocks = {}
|
||||
for reservation in reservations:
|
||||
start_hour = reservation.start_time.hour
|
||||
end_hour = reservation.end_time.hour
|
||||
|
||||
for hour in range(max(7, start_hour), min(22, end_hour + 1)):
|
||||
if hour not in time_blocks:
|
||||
time_blocks[hour] = []
|
||||
if reservation not in time_blocks[hour]:
|
||||
time_blocks[hour].append({
|
||||
'reservation': reservation,
|
||||
'classroom': reservation.classroom,
|
||||
'commission': reservation.commission,
|
||||
'user': reservation.user
|
||||
})
|
||||
|
||||
# Calculate stats
|
||||
total_reservations = len(reservations)
|
||||
confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value or str(r.status) == 'CONFIRMED'])
|
||||
pending = len([r for r in reservations if r.status == ReservationStatus.PENDING or str(r.status) == 'PENDING'])
|
||||
|
||||
total_classrooms = Classroom.query.count()
|
||||
occupied_classrooms = len(set(r.classroom_id for r in reservations if r.classroom_id))
|
||||
|
||||
stats = {
|
||||
'total_reservations': total_reservations,
|
||||
'confirmed': confirmed,
|
||||
'pending': pending,
|
||||
'occupied_classrooms': occupied_classrooms,
|
||||
'total_classrooms': total_classrooms
|
||||
}
|
||||
|
||||
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
||||
available_floors = [f[0] for f in floors]
|
||||
|
||||
return render_template('schedule/today.html',
|
||||
reservations=reservations,
|
||||
time_blocks=time_blocks,
|
||||
stats=stats,
|
||||
today=today,
|
||||
hide_virtual=hide_virtual,
|
||||
floor_filter=floor_filter,
|
||||
shift_filter=shift_filter,
|
||||
available_floors=available_floors)
|
||||
|
||||
@schedule_bp.route('/cancel/<int:id>', methods=['POST'])
|
||||
@login_required
|
||||
def cancel_reservation(id):
|
||||
"""Cancel a reservation"""
|
||||
try:
|
||||
reservation = Reservation.query.get_or_404(id)
|
||||
|
||||
if not reservation.can_be_cancelled():
|
||||
flash('Reservations can only be cancelled at least 24 hours before start time.', 'error')
|
||||
return redirect(request.referrer)
|
||||
|
||||
reservation.status = ReservationStatus.CANCELLED
|
||||
db.session.commit()
|
||||
|
||||
flash('Reservation cancelled successfully!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error cancelling reservation: {str(e)}', 'error')
|
||||
|
||||
return redirect(request.referrer)
|
||||
|
||||
@schedule_bp.route('/reservation/<int:id>/view')
|
||||
@login_required
|
||||
def view_reservation(id):
|
||||
"""View reservation details"""
|
||||
try:
|
||||
reservation = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission),
|
||||
joinedload(Reservation.user)
|
||||
).get_or_404(id)
|
||||
|
||||
return render_template('schedule/view.html',
|
||||
reservation=reservation,
|
||||
title='Reservation Details')
|
||||
|
||||
except Exception as e:
|
||||
flash(f'Error loading reservation details: {str(e)}', 'error')
|
||||
return redirect(url_for('schedule.today_schedule'))
|
||||
|
||||
@schedule_bp.route('/reservation/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_reservation(id):
|
||||
"""Edit existing reservation"""
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Commission
|
||||
from app.models.building import Building
|
||||
from flask_babel import gettext as _
|
||||
|
||||
reservation = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission),
|
||||
joinedload(Reservation.user)
|
||||
).get_or_404(id)
|
||||
|
||||
if not current_user.can_edit_reservation(reservation):
|
||||
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
|
||||
return redirect(url_for('schedule.view_reservation', id=reservation.id))
|
||||
|
||||
form = ReservationForm(is_edit=True)
|
||||
|
||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
commissions = Commission.query.filter_by(active=True).all()
|
||||
|
||||
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
|
||||
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
|
||||
|
||||
if request.method == 'GET':
|
||||
form.classroom_id.data = reservation.classroom_id
|
||||
form.commission_id.data = reservation.commission_id
|
||||
form.start_time.data = reservation.start_time
|
||||
form.end_time.data = reservation.end_time
|
||||
form.purpose.data = reservation.purpose
|
||||
form.expected_attendees.data = reservation.expected_attendees
|
||||
form.notes.data = reservation.notes or ''
|
||||
form.virtual_link.data = reservation.virtual_link or ''
|
||||
|
||||
elif request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Verificar colisión de horario si no es virtual
|
||||
target_room = Classroom.query.get(form.classroom_id.data)
|
||||
if target_room and not target_room.is_virtual:
|
||||
conflict = Reservation.query.filter(
|
||||
Reservation.id != reservation.id,
|
||||
Reservation.classroom_id == form.classroom_id.data,
|
||||
Reservation.status == ReservationStatus.CONFIRMED.value,
|
||||
Reservation.start_time < form.end_time.data,
|
||||
Reservation.end_time > form.start_time.data
|
||||
).first()
|
||||
|
||||
if conflict:
|
||||
flash(_('El aula seleccionada ya cuenta con una reserva confirmada en esa franja horaria.'), 'danger')
|
||||
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
||||
|
||||
# Asignar cambios
|
||||
reservation.classroom_id = form.classroom_id.data
|
||||
reservation.commission_id = form.commission_id.data
|
||||
reservation.start_time = form.start_time.data
|
||||
reservation.end_time = form.end_time.data
|
||||
reservation.purpose = form.purpose.data
|
||||
reservation.expected_attendees = form.expected_attendees.data
|
||||
reservation.notes = form.notes.data
|
||||
reservation.virtual_link = form.virtual_link.data.strip() if form.virtual_link.data else None
|
||||
|
||||
# Turno
|
||||
h = reservation.start_time.hour
|
||||
if h < 13:
|
||||
reservation.shift = 'Mañana'
|
||||
elif h < 18:
|
||||
reservation.shift = 'Tarde'
|
||||
elif h < 21:
|
||||
reservation.shift = 'Vespertino'
|
||||
else:
|
||||
reservation.shift = 'Noche'
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Reserva #%(id)d modificada exitosamente.', id=reservation.id), 'success')
|
||||
return redirect(url_for('schedule.view_reservation', id=reservation.id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al modificar reserva: {str(e)}', 'danger')
|
||||
|
||||
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
||||
|
||||
@schedule_bp.route('/calendar')
|
||||
@login_required
|
||||
def calendar_view():
|
||||
"""Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
|
||||
from app.models.career import Career
|
||||
from app.models.building import Building
|
||||
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all()
|
||||
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
|
||||
careers = Career.query.order_by(Career.name.asc()).all()
|
||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||
|
||||
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
||||
available_floors = [f[0] for f in floors]
|
||||
|
||||
return render_template(
|
||||
'schedule/calendar.html',
|
||||
title='Calendar View',
|
||||
today_date=date.today().isoformat(),
|
||||
classrooms=classrooms,
|
||||
subjects=subjects,
|
||||
careers=careers,
|
||||
buildings=buildings,
|
||||
available_floors=available_floors
|
||||
)
|
||||
|
||||
def user_can_manage_commission_milestones(user, commission=None):
|
||||
"""Verifica si el usuario (Docente, Admin, Bedelía) puede gestionar hitos y links virtuales"""
|
||||
if user.is_admin():
|
||||
return True
|
||||
role_name = (user.role_obj.name if user.role_obj else user.role or '').lower()
|
||||
if role_name in ['admin', 'bedelia', 'bedelía', 'operador']:
|
||||
return True
|
||||
if role_name in ['docente', 'profesor']:
|
||||
return True
|
||||
return user.has_permission('schedule', 'read_write')
|
||||
|
||||
|
||||
@schedule_bp.route('/commissions')
|
||||
@login_required
|
||||
def commissions_view():
|
||||
"""Vista y ABM de Comisiones con soporte de enlace virtual e hitos académicos"""
|
||||
from app.models.subject import Commission, Subject
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.user import User
|
||||
from app.models.milestone import MilestoneType
|
||||
|
||||
subjects = Subject.query.order_by(Subject.code.asc()).all()
|
||||
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.code.asc()).all()
|
||||
teachers = User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||
milestone_types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc()).all()
|
||||
|
||||
# Totales para indicadores superiores
|
||||
total_capacity = sum(c.max_students for s in subjects for c in s.commissions)
|
||||
total_commissions = sum(len(s.commissions) for s in subjects)
|
||||
|
||||
can_manage_milestones = user_can_manage_commission_milestones(current_user)
|
||||
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
||||
can_edit_commissions = current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')
|
||||
|
||||
return render_template('schedule/commissions.html',
|
||||
subjects=subjects,
|
||||
classrooms=classrooms,
|
||||
teachers=teachers,
|
||||
milestone_types=milestone_types,
|
||||
total_capacity=total_capacity,
|
||||
total_commissions=total_commissions,
|
||||
can_manage_milestones=can_manage_milestones,
|
||||
can_edit_commissions=can_edit_commissions)
|
||||
|
||||
|
||||
@schedule_bp.route('/commissions/add', methods=['POST'])
|
||||
@login_required
|
||||
def commission_add():
|
||||
"""Crear una nueva comisión de cursada"""
|
||||
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
||||
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
|
||||
flash(_('No tienes permisos para crear comisiones.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
from app.models.subject import Commission
|
||||
subject_id = request.form.get('subject_id', type=int)
|
||||
code = request.form.get('code', '').strip()
|
||||
semester = request.form.get('semester', '1C').strip()
|
||||
year = request.form.get('year', datetime.now().year, type=int)
|
||||
teacher_id = request.form.get('teacher_id', type=int) or None
|
||||
max_students = request.form.get('max_students', 40, type=int)
|
||||
schedule = request.form.get('schedule', '').strip()
|
||||
shift = request.form.get('shift', 'Tarde').strip()
|
||||
virtual_link = request.form.get('virtual_link', '').strip()
|
||||
|
||||
if not subject_id or not code:
|
||||
flash(_('La asignatura y el código de comisión son obligatorios.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
# Verificar existencia duplicada
|
||||
existing = Commission.query.filter_by(subject_id=subject_id, code=code, semester=semester, year=year).first()
|
||||
if existing:
|
||||
flash(_('Ya existe una comisión %(code)s para el ciclo %(sem)s %(yr)d.', code=code, sem=semester, yr=year), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
new_comm = Commission(
|
||||
subject_id=subject_id,
|
||||
code=code,
|
||||
semester=semester,
|
||||
year=year,
|
||||
teacher_id=teacher_id,
|
||||
max_students=max_students,
|
||||
schedule=schedule,
|
||||
shift=shift,
|
||||
virtual_link=virtual_link or None,
|
||||
active=True
|
||||
)
|
||||
db.session.add(new_comm)
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Comisión %(code)s creada exitosamente.', code=new_comm.get_full_code()), 'success')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
|
||||
@schedule_bp.route('/commissions/<int:id>/edit', methods=['POST'])
|
||||
@login_required
|
||||
def commission_edit(id):
|
||||
"""Editar datos de una comisión"""
|
||||
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
||||
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
|
||||
flash(_('No tienes permisos para modificar comisiones.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
from app.models.subject import Commission
|
||||
commission = Commission.query.get_or_404(id)
|
||||
|
||||
commission.code = request.form.get('code', commission.code).strip()
|
||||
commission.semester = request.form.get('semester', commission.semester).strip()
|
||||
commission.year = request.form.get('year', commission.year, type=int)
|
||||
commission.teacher_id = request.form.get('teacher_id', type=int) or None
|
||||
commission.max_students = request.form.get('max_students', commission.max_students, type=int)
|
||||
commission.schedule = request.form.get('schedule', commission.schedule).strip()
|
||||
commission.shift = request.form.get('shift', commission.shift).strip()
|
||||
commission.virtual_link = request.form.get('virtual_link', '').strip() or None
|
||||
commission.active = True if request.form.get('active') in ['1', 'true', 'on'] else False
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Comisión %(code)s actualizada con éxito.', code=commission.get_full_code()), 'success')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
|
||||
@schedule_bp.route('/commissions/<int:id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def commission_delete(id):
|
||||
"""Eliminar una comisión"""
|
||||
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
||||
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
|
||||
flash(_('No tienes permisos para eliminar comisiones.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
from app.models.subject import Commission
|
||||
commission = Commission.query.get_or_404(id)
|
||||
full_code = commission.get_full_code()
|
||||
|
||||
db.session.delete(commission)
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Comisión %(code)s eliminada permanentemente.', code=full_code), 'success')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
|
||||
@schedule_bp.route('/commissions/<int:id>/virtual-link', methods=['POST'])
|
||||
@login_required
|
||||
def commission_virtual_link(id):
|
||||
"""Actualizar enlace del aula virtual (Zoom, Meet, Teams, Moodle) - Profesores, Admin y Bedelía"""
|
||||
from app.models.subject import Commission
|
||||
commission = Commission.query.get_or_404(id)
|
||||
|
||||
if not user_can_manage_commission_milestones(current_user, commission):
|
||||
flash(_('No tienes permisos para actualizar el aula virtual de esta comisión.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
link = request.form.get('virtual_link', '').strip()
|
||||
commission.virtual_link = link or None
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Enlace del aula virtual actualizado para la comisión %(code)s.', code=commission.get_full_code()), 'success')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
|
||||
@schedule_bp.route('/commissions/<int:id>/milestones/add', methods=['POST'])
|
||||
@login_required
|
||||
def commission_milestone_add(id):
|
||||
"""Agregar un hito académico (Parcial, TP, Recuperatorio) a una comisión - Profesores, Admin y Bedelía"""
|
||||
from app.models.subject import Commission
|
||||
from app.models.milestone import AcademicMilestone, MilestoneType
|
||||
commission = Commission.query.get_or_404(id)
|
||||
|
||||
if not user_can_manage_commission_milestones(current_user, commission):
|
||||
flash(_('No tienes permisos para agregar hitos a esta comisión.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
milestone_type_id = request.form.get('milestone_type_id', type=int)
|
||||
title = request.form.get('title', '').strip()
|
||||
date_str = request.form.get('date', '').strip()
|
||||
time_str = request.form.get('time', '18:00').strip()
|
||||
classroom = request.form.get('classroom', '').strip()
|
||||
link = request.form.get('link', '').strip() or commission.virtual_link or ''
|
||||
notes = request.form.get('notes', '').strip()
|
||||
|
||||
if not milestone_type_id or not date_str:
|
||||
flash(_('Debes especificar el tipo de hito y la fecha.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
m_type = MilestoneType.query.get(milestone_type_id)
|
||||
if not m_type:
|
||||
flash(_('Tipo de hito inválido.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
|
||||
except ValueError:
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
flash(_('Formato de fecha inválido.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
milestone = AcademicMilestone(
|
||||
title=title or m_type.name,
|
||||
materia_codigo=commission.subject.code if commission.subject else 'ASIG',
|
||||
subject_id=commission.subject_id,
|
||||
commission_id=commission.id,
|
||||
milestone_type_id=m_type.id,
|
||||
date=dt,
|
||||
classroom=classroom or 'Campus Virtual',
|
||||
link=link or None,
|
||||
notes=notes or None,
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(milestone)
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Hito "%(title)s" registrado correctamente en %(comm)s.', title=milestone.title, comm=commission.get_full_code()), 'success')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
|
||||
@schedule_bp.route('/commissions/<int:commission_id>/milestones/<int:milestone_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def commission_milestone_delete(commission_id, milestone_id):
|
||||
"""Eliminar un hito académico de una comisión"""
|
||||
from app.models.subject import Commission
|
||||
from app.models.milestone import AcademicMilestone
|
||||
commission = Commission.query.get_or_404(commission_id)
|
||||
|
||||
if not user_can_manage_commission_milestones(current_user, commission):
|
||||
flash(_('No tienes permisos para eliminar hitos de esta comisión.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
milestone = AcademicMilestone.query.filter_by(id=milestone_id, commission_id=commission_id).first_or_404()
|
||||
title = milestone.title
|
||||
db.session.delete(milestone)
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Hito "%(title)s" eliminado de la comisión.', title=title), 'success')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
@schedule_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_reservation():
|
||||
"""Add new reservation"""
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Commission
|
||||
from app.models.building import Building
|
||||
|
||||
form = ReservationForm()
|
||||
|
||||
# Get active buildings and classrooms
|
||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
commissions = Commission.query.filter_by(active=True).all()
|
||||
|
||||
# Populate form choices
|
||||
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
|
||||
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
|
||||
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Check for existing reservations at the same time
|
||||
from app.models.reservation import ReservationStatus
|
||||
existing_reservation = Reservation.query.filter_by(
|
||||
classroom_id=form.classroom_id.data,
|
||||
status=ReservationStatus.CONFIRMED.value
|
||||
).filter(
|
||||
Reservation.start_time < form.end_time.data,
|
||||
Reservation.end_time > form.start_time.data
|
||||
).first()
|
||||
|
||||
if existing_reservation:
|
||||
flash('This classroom is already booked for the selected time slot.', 'danger')
|
||||
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
||||
|
||||
# Create new reservation
|
||||
reservation = Reservation(
|
||||
classroom_id=form.classroom_id.data,
|
||||
commission_id=form.commission_id.data,
|
||||
user_id=current_user.id,
|
||||
start_time=form.start_time.data,
|
||||
end_time=form.end_time.data,
|
||||
purpose=form.purpose.data,
|
||||
notes=form.notes.data,
|
||||
virtual_link=form.virtual_link.data.strip() if form.virtual_link.data else None,
|
||||
status=ReservationStatus.PENDING.value,
|
||||
expected_attendees=form.expected_attendees.data
|
||||
)
|
||||
|
||||
db.session.add(reservation)
|
||||
db.session.commit()
|
||||
|
||||
flash('Reservation request submitted successfully! It will be reviewed by an administrator.', 'success')
|
||||
return redirect(url_for('schedule.list_reservations'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error creating reservation: {str(e)}', 'danger')
|
||||
|
||||
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
||||
|
||||
@schedule_bp.route('/reservation/add', methods=['POST'])
|
||||
@login_required
|
||||
def quick_reservation_add():
|
||||
"""Endpoint AJAX para crear reserva rápida desde el modal de comisiones"""
|
||||
try:
|
||||
commission_id = request.form.get('commission_id', type=int)
|
||||
classroom_id = request.form.get('classroom_id', type=int)
|
||||
purpose = request.form.get('purpose', '').strip() or 'Clase de cursada'
|
||||
start_str = request.form.get('start_time', '').strip()
|
||||
end_str = request.form.get('end_time', '').strip()
|
||||
virtual_link = request.form.get('virtual_link', '').strip() or None
|
||||
|
||||
if not commission_id or not classroom_id or not start_str or not end_str:
|
||||
return jsonify({'error': 'Faltan campos obligatorios'}), 400
|
||||
|
||||
start_time = datetime.strptime(start_str, '%Y-%m-%d %H:%M')
|
||||
end_time = datetime.strptime(end_str, '%Y-%m-%d %H:%M')
|
||||
|
||||
from app.models.subject import Commission
|
||||
from app.models.classroom import Classroom
|
||||
comm = Commission.query.get_or_404(commission_id)
|
||||
|
||||
if not virtual_link and comm.virtual_link:
|
||||
virtual_link = comm.virtual_link
|
||||
|
||||
new_res = Reservation(
|
||||
classroom_id=classroom_id,
|
||||
commission_id=commission_id,
|
||||
user_id=comm.teacher_id or current_user.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
purpose=purpose,
|
||||
expected_attendees=comm.max_students or 30,
|
||||
shift=comm.shift or 'Tarde',
|
||||
virtual_link=virtual_link,
|
||||
status=ReservationStatus.CONFIRMED.value if current_user.is_admin() else ReservationStatus.PENDING.value
|
||||
)
|
||||
db.session.add(new_res)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'id': new_res.id})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@schedule_bp.route('/list')
|
||||
@login_required
|
||||
def list_reservations():
|
||||
"""List all reservations with pagination, date filters, floor, shift, and virtual toggle"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
status_filter = request.args.get('status', 'all').strip()
|
||||
date_from = request.args.get('date_from', '').strip()
|
||||
date_to = request.args.get('date_to', '').strip()
|
||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||
floor_filter = request.args.get('floor', '').strip()
|
||||
shift_filter = request.args.get('shift', '').strip()
|
||||
classroom_id = request.args.get('classroom_id', type=int)
|
||||
search = request.args.get('search', '').strip()
|
||||
|
||||
query = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject),
|
||||
joinedload(Reservation.user)
|
||||
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject)
|
||||
|
||||
if classroom_id:
|
||||
query = query.filter(Reservation.classroom_id == classroom_id)
|
||||
|
||||
if status_filter and status_filter != 'all':
|
||||
query = query.filter(Reservation.status == status_filter.upper())
|
||||
|
||||
if date_from:
|
||||
try:
|
||||
d_from = datetime.strptime(date_from, '%Y-%m-%d')
|
||||
query = query.filter(Reservation.start_time >= d_from)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if date_to:
|
||||
try:
|
||||
d_to = datetime.strptime(date_to, '%Y-%m-%d') + timedelta(days=1)
|
||||
query = query.filter(Reservation.start_time < d_to)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if hide_virtual:
|
||||
query = query.filter(
|
||||
~Classroom.code.ilike('%VIRTUAL%'),
|
||||
~Classroom.building.ilike('%Virtual%')
|
||||
)
|
||||
|
||||
if floor_filter:
|
||||
flr = str(floor_filter).strip()
|
||||
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
||||
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
||||
else:
|
||||
query = query.filter(Classroom.floor == flr)
|
||||
|
||||
if shift_filter:
|
||||
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Subject.name.ilike(f'%{search}%'),
|
||||
Subject.code.ilike(f'%{search}%'),
|
||||
Commission.code.ilike(f'%{search}%'),
|
||||
Classroom.code.ilike(f'%{search}%'),
|
||||
Reservation.purpose.ilike(f'%{search}%'),
|
||||
Reservation.shift.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
reservations = query.order_by(Reservation.start_time.desc()).paginate(
|
||||
page=page, per_page=20, error_out=False
|
||||
)
|
||||
|
||||
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
||||
available_floors = [f[0] for f in floors]
|
||||
all_classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
|
||||
return render_template(
|
||||
'schedule/list.html',
|
||||
reservations=reservations,
|
||||
status_filter=status_filter,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
hide_virtual=hide_virtual,
|
||||
floor_filter=floor_filter,
|
||||
shift_filter=shift_filter,
|
||||
classroom_id=classroom_id,
|
||||
all_classrooms=all_classrooms,
|
||||
search=search,
|
||||
available_floors=available_floors
|
||||
)
|
||||
|
||||
|
||||
@schedule_bp.route('/reservation/<int:id>/confirm', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def confirm_reservation(id):
|
||||
"""Confirm a reservation (Handles both GET from link and POST from form)"""
|
||||
try:
|
||||
reservation = Reservation.query.get_or_404(id)
|
||||
|
||||
# Validación de seguridad: Solo confirmar si está pendiente
|
||||
if reservation.status != ReservationStatus.PENDING.value:
|
||||
flash('Only pending reservations can be confirmed.', 'warning')
|
||||
return redirect(url_for('schedule.today_schedule'))
|
||||
|
||||
reservation.status = ReservationStatus.CONFIRMED.value
|
||||
db.session.commit()
|
||||
|
||||
flash(
|
||||
f'Reservation for {reservation.commission.code if reservation.commission else "Class"} confirmed successfully!',
|
||||
'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error confirming reservation: {str(e)}', 'error')
|
||||
|
||||
# Redirigir a la página desde la que venía el usuario o al inicio de hoy
|
||||
return redirect(request.referrer or url_for('schedule.today_schedule'))
|
||||
|
||||
# API endpoints
|
||||
@schedule_bp.route('/api/classroom-availability')
|
||||
@login_required
|
||||
def classroom_availability_api():
|
||||
"""Get classroom availability for a specific date/time"""
|
||||
try:
|
||||
date_str = request.args.get('date')
|
||||
start_time_str = request.args.get('start_time')
|
||||
end_time_str = request.args.get('end_time')
|
||||
max_students = request.args.get('max_students', type=int)
|
||||
|
||||
if not all([date_str, start_time_str, end_time_str]):
|
||||
return jsonify({'error': 'Missing required parameters: date, start_time, end_time'}), 400
|
||||
|
||||
# Parse datetime strings
|
||||
from datetime import datetime
|
||||
start_time = datetime.strptime(f"{date_str} {start_time_str}", "%Y-%m-%d %H:%M")
|
||||
end_time = datetime.strptime(f"{date_str} {end_time_str}", "%Y-%m-%d %H:%M")
|
||||
|
||||
# Get availability status
|
||||
from app.models.classroom import Classroom
|
||||
availability = Classroom.get_availability_status(start_time, end_time)
|
||||
|
||||
# Get best classroom recommendation if max_students provided
|
||||
best_recommendation = None
|
||||
if max_students:
|
||||
best_classrooms = Classroom.find_best_classroom(max_students, start_time, end_time)
|
||||
if best_classrooms:
|
||||
best_recommendation = best_classrooms[0] # Most efficient one
|
||||
|
||||
# Format response
|
||||
response = {
|
||||
'availability': {
|
||||
'available': [
|
||||
{
|
||||
'id': item['classroom'].id,
|
||||
'code': item['classroom'].code_display,
|
||||
'name': item['classroom'].name,
|
||||
'capacity': item['classroom'].capacity,
|
||||
'location': item['classroom'].get_full_location()
|
||||
}
|
||||
for item in availability['available']
|
||||
],
|
||||
'occupied': [
|
||||
{
|
||||
'id': item['classroom'].id,
|
||||
'code': item['classroom'].code_display,
|
||||
'name': item['classroom'].name,
|
||||
'capacity': item['classroom'].capacity,
|
||||
'location': item['classroom'].get_full_location(),
|
||||
'conflicts': [
|
||||
{
|
||||
'start_time': conflict.start_time.strftime('%H:%M'),
|
||||
'end_time': conflict.end_time.strftime('%H:%M'),
|
||||
'commission': conflict.commission.get_full_code() if conflict.commission else 'Unknown'
|
||||
}
|
||||
for conflict in item['conflicts']
|
||||
]
|
||||
}
|
||||
for item in availability['occupied']
|
||||
],
|
||||
'stats': {
|
||||
'total_available': availability['total_available'],
|
||||
'total_occupied': availability['total_occupied']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if best_recommendation:
|
||||
response['recommendation'] = {
|
||||
'classroom': {
|
||||
'id': best_recommendation['classroom'].id,
|
||||
'code': best_recommendation['classroom'].code_display,
|
||||
'name': best_recommendation['classroom'].name,
|
||||
'capacity': best_recommendation['classroom'].capacity,
|
||||
'location': best_recommendation['classroom'].get_full_location()
|
||||
},
|
||||
'efficiency': best_recommendation['efficiency'],
|
||||
'wasted_capacity': best_recommendation['wasted_capacity'],
|
||||
'reason': 'Best match based on capacity and availability'
|
||||
}
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({'error': f'Invalid date/time format: {str(e)}'}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@schedule_bp.route('/calendar-data')
|
||||
@login_required
|
||||
def calendar_data():
|
||||
"""Get calendar events data for FullCalendar"""
|
||||
try:
|
||||
start = request.args.get('start')
|
||||
end = request.args.get('end')
|
||||
classroom_id = request.args.get('classroom_id')
|
||||
subject_id = request.args.get('subject_id')
|
||||
career_id = request.args.get('career_id')
|
||||
shift = request.args.get('shift', '').strip()
|
||||
floor = request.args.get('floor', '').strip()
|
||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||
search = request.args.get('search', '').strip()
|
||||
|
||||
if start and end:
|
||||
# Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
|
||||
start_clean = start.replace('Z', '+00:00')
|
||||
end_clean = end.replace('Z', '+00:00')
|
||||
start_date = datetime.fromisoformat(start_clean).replace(tzinfo=None)
|
||||
end_date = datetime.fromisoformat(end_clean).replace(tzinfo=None)
|
||||
else:
|
||||
today = date.today()
|
||||
start_date = datetime.combine(today.replace(day=1), datetime.min.time())
|
||||
next_month = today.replace(day=28) + timedelta(days=4)
|
||||
end_date = datetime.combine(next_month.replace(day=1), datetime.max.time())
|
||||
|
||||
query = Reservation.query.join(Reservation.commission).join(Commission.subject).outerjoin(Reservation.classroom).options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
|
||||
).filter(
|
||||
Reservation.start_time >= start_date,
|
||||
Reservation.start_time <= end_date
|
||||
)
|
||||
|
||||
if classroom_id and classroom_id.isdigit():
|
||||
query = query.filter(Reservation.classroom_id == int(classroom_id))
|
||||
|
||||
if subject_id and subject_id.isdigit():
|
||||
query = query.filter(Commission.subject_id == int(subject_id))
|
||||
|
||||
if career_id and career_id.isdigit():
|
||||
query = query.filter(Subject.career_id == int(career_id))
|
||||
|
||||
building = request.args.get('building', '').strip()
|
||||
if building:
|
||||
from app.models.building import Building
|
||||
query = query.filter(
|
||||
or_(
|
||||
Classroom.building.ilike(f'%{building}%'),
|
||||
Classroom.building_entity.has(Building.name.ilike(f'%{building}%'))
|
||||
)
|
||||
)
|
||||
|
||||
if shift:
|
||||
query = query.filter(Reservation.shift.ilike(f'%{shift}%'))
|
||||
|
||||
if hide_virtual:
|
||||
query = query.filter(
|
||||
~Classroom.code.ilike('%VIRTUAL%'),
|
||||
~Classroom.building.ilike('%Virtual%')
|
||||
)
|
||||
|
||||
if floor:
|
||||
flr = str(floor).strip()
|
||||
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
||||
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
||||
else:
|
||||
query = query.filter(Classroom.floor == flr)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Subject.name.ilike(f'%{search}%'),
|
||||
Subject.code.ilike(f'%{search}%'),
|
||||
Classroom.code.ilike(f'%{search}%'),
|
||||
Reservation.purpose.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
reservations = query.order_by(Reservation.start_time).all()
|
||||
|
||||
events = []
|
||||
for reservation in reservations:
|
||||
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
|
||||
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
|
||||
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
|
||||
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
|
||||
classroom_floor = reservation.classroom.floor_display if reservation.classroom else None
|
||||
classroom_building = reservation.classroom.building_name if reservation.classroom else ''
|
||||
is_virtual = bool(reservation.classroom and reservation.classroom.is_virtual)
|
||||
career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '')
|
||||
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
|
||||
|
||||
events.append({
|
||||
'id': reservation.id,
|
||||
'title': subject_name,
|
||||
'start': reservation.start_time.isoformat(),
|
||||
'end': reservation.end_time.isoformat(),
|
||||
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
|
||||
'className': f'fc-event-{status_val.lower()}',
|
||||
'extendedProps': {
|
||||
'subject_code': subject_code,
|
||||
'subject_name': subject_name,
|
||||
'classroom': classroom_name,
|
||||
'building': classroom_building,
|
||||
'floor': classroom_floor,
|
||||
'is_virtual': is_virtual,
|
||||
'location_display': reservation.classroom.location_display if reservation.classroom else '',
|
||||
'career': career_name,
|
||||
'commission': reservation.commission.code if reservation.commission else '',
|
||||
'shift': reservation.shift or '',
|
||||
'status': status_val,
|
||||
'purpose': reservation.purpose or '',
|
||||
'attendees': reservation.expected_attendees or 0,
|
||||
'notes': reservation.notes or '',
|
||||
'teacher': reservation.user.name if reservation.user else '',
|
||||
'virtual_link': reservation.effective_virtual_link,
|
||||
'cancel_url': url_for('schedule.cancel_reservation', id=reservation.id)
|
||||
}
|
||||
})
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@schedule_bp.route('/api/today-events')
|
||||
@login_required
|
||||
def api_today_events():
|
||||
"""Get today's events as JSON"""
|
||||
try:
|
||||
today_start = datetime.combine(date.today(), datetime.min.time())
|
||||
today_end = datetime.combine(date.today(), datetime.max.time())
|
||||
|
||||
reservations = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
|
||||
).filter(
|
||||
Reservation.start_time >= today_start,
|
||||
Reservation.start_time <= today_end
|
||||
).order_by(Reservation.start_time).all()
|
||||
|
||||
events = []
|
||||
for reservation in reservations:
|
||||
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
|
||||
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
|
||||
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
|
||||
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
|
||||
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
|
||||
|
||||
events.append({
|
||||
'id': reservation.id,
|
||||
'title': f"{subject_name} ({subject_code}) - {classroom_name}",
|
||||
'start': reservation.start_time.isoformat(),
|
||||
'end': reservation.end_time.isoformat(),
|
||||
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
|
||||
'extendedProps': {
|
||||
'subject_code': subject_code,
|
||||
'subject_name': subject_name,
|
||||
'classroom': classroom_name,
|
||||
'purpose': reservation.purpose or 'No purpose',
|
||||
'attendees': reservation.expected_attendees,
|
||||
'status': status_val
|
||||
}
|
||||
})
|
||||
|
||||
return jsonify({'events': events})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Módulo de Data Transfer Objects (DTOs) y esquemas tipados con Pydantic."""
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO, ReservationResponseDTO
|
||||
from app.schemas.auth_dto import LoginDTO, TokenResponseDTO, RefreshTokenDTO
|
||||
|
||||
__all__ = [
|
||||
'ClassroomCreateDTO',
|
||||
'ClassroomUpdateDTO',
|
||||
'ClassroomResponseDTO',
|
||||
'ReservationCreateDTO',
|
||||
'ReservationUpdateDTO',
|
||||
'ReservationResponseDTO',
|
||||
'LoginDTO',
|
||||
'TokenResponseDTO',
|
||||
'RefreshTokenDTO'
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
|
||||
class LoginDTO(BaseModel):
|
||||
email: str = Field(..., description="Correo electrónico institucional")
|
||||
password: str = Field(..., min_length=1, description="Contraseña de acceso")
|
||||
|
||||
class TokenResponseDTO(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int = 900 # 15 minutos en segundos
|
||||
user: Dict[str, Any]
|
||||
|
||||
class RefreshTokenDTO(BaseModel):
|
||||
refresh_token: str = Field(..., description="Token de refresco válido")
|
||||
@@ -0,0 +1,44 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
class ClassroomBaseDTO(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=50, description="Código o nombre del aula (ej: Aula 101, VIRTUAL-A)")
|
||||
floor: str = Field(default="PB", max_length=50, description="Piso o nivel (PB, Piso 1, etc.)")
|
||||
capacity: int = Field(default=30, ge=1, le=10000, description="Capacidad máxima de estudiantes")
|
||||
description: Optional[str] = Field(default=None, max_length=500, description="Detalles o equipamiento")
|
||||
is_active: bool = Field(default=True, description="Estado operativo del aula")
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def clean_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("El código del aula no puede estar vacío.")
|
||||
return v
|
||||
|
||||
@field_validator('floor')
|
||||
@classmethod
|
||||
def clean_floor(cls, v: str) -> str:
|
||||
return str(v).strip() or "PB"
|
||||
|
||||
class ClassroomCreateDTO(ClassroomBaseDTO):
|
||||
building_id: Optional[int] = Field(default=None, description="ID del edificio asignado")
|
||||
building_name: Optional[str] = Field(default=None, description="Nombre de edificio (para creación dinámica)")
|
||||
|
||||
class ClassroomUpdateDTO(BaseModel):
|
||||
building_id: Optional[int] = None
|
||||
code: Optional[str] = None
|
||||
floor: Optional[str] = None
|
||||
capacity: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class ClassroomResponseDTO(ClassroomBaseDTO):
|
||||
id: int
|
||||
building_id: Optional[int] = None
|
||||
building_name: str
|
||||
is_virtual: bool
|
||||
location_display: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -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
|
||||
@@ -0,0 +1,43 @@
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
class ReservationBaseDTO(BaseModel):
|
||||
classroom_id: int = Field(..., description="ID del aula asignada")
|
||||
commission_id: int = Field(..., description="ID de la comisión académica")
|
||||
user_id: int = Field(..., description="ID del docente responsable")
|
||||
start_time: datetime = Field(..., description="Fecha y hora de inicio")
|
||||
end_time: datetime = Field(..., description="Fecha y hora de finalización")
|
||||
purpose: str = Field(default="Clase regular", max_length=500, description="Motivo o título de la reserva")
|
||||
expected_attendees: int = Field(default=20, ge=1, description="Cantidad estimada de asistentes")
|
||||
shift: Optional[str] = Field(default=None, max_length=30, description="Turno (Mañana, Tarde, Vespertino, Noche)")
|
||||
virtual_link: Optional[str] = Field(default=None, max_length=500, description="Enlace a sala virtual (Meet/Zoom)")
|
||||
notes: Optional[str] = Field(default=None, description="Observaciones adicionales")
|
||||
status: Optional[str] = Field(default="PENDING", description="Estado de la reserva")
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_time_range(self):
|
||||
if self.end_time <= self.start_time:
|
||||
raise ValueError("La hora de finalización debe ser posterior a la hora de inicio.")
|
||||
return self
|
||||
|
||||
class ReservationCreateDTO(ReservationBaseDTO):
|
||||
pass
|
||||
|
||||
class ReservationUpdateDTO(BaseModel):
|
||||
classroom_id: Optional[int] = None
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
purpose: Optional[str] = None
|
||||
expected_attendees: Optional[int] = None
|
||||
shift: Optional[str] = None
|
||||
virtual_link: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
class ReservationResponseDTO(ReservationBaseDTO):
|
||||
id: int
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Módulo de Seguridad Empresarial inspirado en Spring Security (SecurityFilterChain, RBAC y Auditoría)."""
|
||||
from app.security.filter_chain import SecurityFilterChain
|
||||
from app.security.decorators import require_permission
|
||||
|
||||
__all__ = [
|
||||
'SecurityFilterChain',
|
||||
'require_permission'
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
from functools import wraps
|
||||
from flask import request, jsonify, redirect, url_for, flash, abort, g
|
||||
from flask_login import current_user
|
||||
|
||||
def require_permission(module: str, min_level: str = 'read'):
|
||||
"""
|
||||
Decorador de seguridad declarativa a nivel de método inspirado en @PreAuthorize de Spring Security.
|
||||
Verifica los privilegios del usuario autenticado (tanto en sesiones web como en API JWT).
|
||||
|
||||
Niveles de permiso estándar:
|
||||
- 'read': Permiso de lectura / consulta
|
||||
- 'read_write': Permiso de modificación y creación
|
||||
- 'admin': Permiso de control total
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user = None
|
||||
# Evaluar contexto JWT primero si está presente
|
||||
if hasattr(g, 'jwt_user') and g.jwt_user:
|
||||
user = g.jwt_user
|
||||
elif current_user and current_user.is_authenticated:
|
||||
user = current_user
|
||||
|
||||
is_api = request.path.startswith('/api/') or request.is_json
|
||||
|
||||
if not user:
|
||||
if is_api:
|
||||
return jsonify({'error': 'Unauthorized', 'message': 'Autenticación requerida.'}), 401
|
||||
flash('Debe iniciar sesión para acceder a este recurso.', 'warning')
|
||||
return redirect(url_for('auth.login', next=request.url))
|
||||
|
||||
# Comprobar privilegios en la matriz RBAC
|
||||
if not user.has_permission(module, min_level):
|
||||
if is_api:
|
||||
return jsonify({
|
||||
'error': 'Forbidden',
|
||||
'message': f'Permiso insuficiente en módulo "{module}". Nivel requerido: "{min_level}".'
|
||||
}), 403
|
||||
flash(f'No posee permisos suficientes ({min_level}) para gestionar {module}.', 'danger')
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
@@ -0,0 +1,89 @@
|
||||
from flask import request, jsonify, g, render_template, Response
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Tuple
|
||||
import time
|
||||
|
||||
class SecurityFilterChain:
|
||||
"""
|
||||
Pipeline centralizado de interceptación de seguridad (SecurityFilterChain)
|
||||
inspirado en el patrón arquitectónico de Spring Security.
|
||||
|
||||
Capas de filtrado activas:
|
||||
1. Security Headers Filter (Blindaje HTTP en todas las respuestas).
|
||||
2. Rate Limiting Filter (Protección contra fuerza bruta y DoS).
|
||||
3. Bearer Token Context Filter (Inyección de identidad contextual).
|
||||
"""
|
||||
|
||||
def __init__(self, app=None):
|
||||
# Registro de timestamps de peticiones por IP: ip -> [timestamps]
|
||||
self._request_history: Dict[str, List[float]] = {}
|
||||
# Límites por endpoint sensible (max_requests, window_seconds)
|
||||
self._rate_limits = {
|
||||
'/login': (15, 60),
|
||||
'/api/v1/auth/login': (15, 60),
|
||||
'/api/v1/auth/refresh': (30, 60)
|
||||
}
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""Registra los filtros en el ciclo de vida de la aplicación Flask."""
|
||||
app.before_request(self._execute_pre_filters)
|
||||
app.after_request(self._execute_post_filters)
|
||||
|
||||
def _get_client_ip(self) -> str:
|
||||
"""Obtiene la IP real del cliente respetando proxies inversos de confianza."""
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
return request.remote_addr or '127.0.0.1'
|
||||
|
||||
def _execute_pre_filters(self):
|
||||
"""Ejecuta los filtros previos al enrutamiento del controlador."""
|
||||
path = request.path
|
||||
|
||||
# 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()
|
||||
|
||||
# Limpiar historial fuera de la ventana
|
||||
timestamps = self._request_history.get(client_ip, [])
|
||||
timestamps = [t for t in timestamps if now - t < window]
|
||||
|
||||
if len(timestamps) >= max_req:
|
||||
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 de autenticación desde su dirección IP. Por favor espere {retry_after} segundos antes de volver a intentar.",
|
||||
status=429,
|
||||
mimetype='text/plain; charset=utf-8'
|
||||
)
|
||||
|
||||
timestamps.append(now)
|
||||
self._request_history[client_ip] = timestamps
|
||||
|
||||
def _execute_post_filters(self, response):
|
||||
"""Inyecta cabeceras de blindaje HTTP (OWASP Secure Headers)."""
|
||||
# Evitar ataques de MIME-Sniffing
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
# Mitigación de Clickjacking
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
# Mitigación XSS en navegadores legacy
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
# Política de Referrer segura
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
# Inyección HSTS (si es HTTPS o entorno de producción)
|
||||
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,19 @@
|
||||
from .sheets_importer import GoogleSheetsImporter
|
||||
from .classroom_service import ClassroomService
|
||||
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',
|
||||
'ClassroomService',
|
||||
'ReservationService',
|
||||
'UserService',
|
||||
'JWTService',
|
||||
'AuditService',
|
||||
'OptimizerService',
|
||||
'OptimizerJob',
|
||||
'OptimizerJobStatus'
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
from flask import request, g, has_request_context
|
||||
from flask_login import current_user
|
||||
import json
|
||||
from app import db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import User
|
||||
|
||||
class AuditService:
|
||||
"""Servicio para la captura y consulta de la bitácora inmutable de auditoría."""
|
||||
|
||||
@staticmethod
|
||||
def log(action: str, module: str, entity_id: Optional[int] = None,
|
||||
details: Optional[Any] = None, user: Optional[User] = None,
|
||||
ip_address: Optional[str] = None) -> AuditLog:
|
||||
"""
|
||||
Registra un evento de auditoría de forma segura, infiriendo el usuario e IP
|
||||
desde el contexto actual de la petición si no se especifican.
|
||||
"""
|
||||
user_id = None
|
||||
user_email = None
|
||||
|
||||
# 1. Determinar usuario actuante
|
||||
if user:
|
||||
user_id = user.id
|
||||
user_email = user.email
|
||||
elif has_request_context():
|
||||
if hasattr(g, 'jwt_user') and g.jwt_user:
|
||||
user_id = g.jwt_user.id
|
||||
user_email = g.jwt_user.email
|
||||
elif current_user and current_user.is_authenticated:
|
||||
user_id = current_user.id
|
||||
user_email = current_user.email
|
||||
|
||||
# 2. Determinar IP de origen
|
||||
if not ip_address and has_request_context():
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
ip_address = request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
else:
|
||||
ip_address = request.remote_addr
|
||||
|
||||
# 3. Serializar detalles si es estructura de datos
|
||||
details_str = None
|
||||
if details is not None:
|
||||
if isinstance(details, (dict, list)):
|
||||
try:
|
||||
details_str = json.dumps(details, ensure_ascii=False)
|
||||
except Exception:
|
||||
details_str = str(details)
|
||||
else:
|
||||
details_str = str(details)
|
||||
|
||||
log_entry = AuditLog(
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
action=action.upper(),
|
||||
module=module.lower(),
|
||||
entity_id=entity_id,
|
||||
details=details_str,
|
||||
ip_address=ip_address
|
||||
)
|
||||
try:
|
||||
db.session.add(log_entry)
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
return log_entry
|
||||
|
||||
@staticmethod
|
||||
def get_recent_logs(limit: int = 50, module: Optional[str] = None) -> List[AuditLog]:
|
||||
"""Obtiene las entradas más recientes de auditoría."""
|
||||
query = AuditLog.query
|
||||
if module:
|
||||
query = query.filter_by(module=module.lower())
|
||||
return query.order_by(AuditLog.created_at.desc()).limit(limit).all()
|
||||
@@ -0,0 +1,118 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
import re
|
||||
from sqlalchemy import func
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.building import Building
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO
|
||||
from app import db
|
||||
|
||||
class ClassroomService:
|
||||
"""Capa de servicios para la lógica de negocio y validación de aulas."""
|
||||
|
||||
def __init__(self, repository: Optional[ClassroomRepository] = None):
|
||||
self.repository = repository or ClassroomRepository()
|
||||
|
||||
def get_classroom(self, classroom_id: int) -> Optional[Classroom]:
|
||||
"""Obtiene un aula por su identificador."""
|
||||
return self.repository.get_by_id(classroom_id)
|
||||
|
||||
def list_classrooms(self, building_id: Optional[int] = None, floor: Optional[str] = None,
|
||||
only_virtual: bool = False, only_physical: bool = False) -> List[Classroom]:
|
||||
"""Lista aulas filtradas según criterios de ubicación o modalidad."""
|
||||
return self.repository.get_active_classrooms(
|
||||
building_id=building_id,
|
||||
floor=floor,
|
||||
only_virtual=only_virtual,
|
||||
only_physical=only_physical
|
||||
)
|
||||
|
||||
def create_classroom(self, dto: ClassroomCreateDTO) -> Classroom:
|
||||
"""
|
||||
Crea una nueva aula aplicando reglas de negocio:
|
||||
- Resolución o creación dinámica del edificio.
|
||||
- Unicidad de código por piso y edificio.
|
||||
- Capacidad adaptativa para aulas virtuales.
|
||||
"""
|
||||
building = None
|
||||
if dto.building_id:
|
||||
building = Building.query.get(dto.building_id)
|
||||
if not building:
|
||||
raise ValueError(f"El edificio con ID {dto.building_id} no existe.")
|
||||
elif dto.building_name:
|
||||
b_name = dto.building_name.strip()
|
||||
building = Building.query.filter(func.lower(Building.name) == func.lower(b_name)).first()
|
||||
if not building:
|
||||
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', b_name.upper()).strip('_')
|
||||
building = Building(name=b_name, code=code_slug, is_active=True)
|
||||
db.session.add(building)
|
||||
db.session.flush()
|
||||
else:
|
||||
raise ValueError("Debe especificar un edificio válido o el nombre de un nuevo edificio.")
|
||||
|
||||
# Verificar unicidad
|
||||
existing = self.repository.get_by_code_and_building(building.id, dto.code, dto.floor)
|
||||
if existing:
|
||||
raise ValueError(f"Ya existe un aula con el código '{dto.code}' en el piso {dto.floor} de {building.name}.")
|
||||
|
||||
capacity = dto.capacity
|
||||
is_virtual = ('VIRTUAL' in dto.code.upper()) or ('VIRTUAL' in building.name.upper())
|
||||
if is_virtual and capacity < 999:
|
||||
capacity = 9999
|
||||
|
||||
classroom = Classroom(
|
||||
building_id=building.id,
|
||||
building=building.name,
|
||||
code=dto.code,
|
||||
floor=dto.floor,
|
||||
capacity=capacity,
|
||||
description=dto.description,
|
||||
is_active=dto.is_active
|
||||
)
|
||||
return self.repository.save(classroom)
|
||||
|
||||
def update_classroom(self, classroom_id: int, dto: ClassroomUpdateDTO) -> Classroom:
|
||||
"""Actualiza atributos de un aula existente."""
|
||||
classroom = self.repository.get_by_id(classroom_id)
|
||||
if not classroom:
|
||||
raise ValueError(f"Aula con ID {classroom_id} no encontrada.")
|
||||
|
||||
building_id = dto.building_id if dto.building_id is not None else classroom.building_id
|
||||
code = dto.code.strip() if dto.code is not None else classroom.code
|
||||
floor = dto.floor.strip() if dto.floor is not None else classroom.floor
|
||||
|
||||
# Si cambia código, piso o edificio, validar colisión
|
||||
if (building_id != classroom.building_id or code != classroom.code or floor != classroom.floor):
|
||||
existing = self.repository.get_by_code_and_building(building_id, code, floor)
|
||||
if existing and existing.id != classroom.id:
|
||||
raise ValueError(f"Ya existe otra aula con el código '{code}' en el piso {floor}.")
|
||||
|
||||
if dto.building_id is not None:
|
||||
building = Building.query.get(dto.building_id)
|
||||
if building:
|
||||
classroom.building_id = building.id
|
||||
classroom.building = building.name
|
||||
if dto.code is not None:
|
||||
classroom.code = code
|
||||
if dto.floor is not None:
|
||||
classroom.floor = floor
|
||||
if dto.capacity is not None:
|
||||
classroom.capacity = dto.capacity
|
||||
if dto.description is not None:
|
||||
classroom.description = dto.description
|
||||
if dto.is_active is not None:
|
||||
classroom.is_active = dto.is_active
|
||||
|
||||
return self.repository.save(classroom)
|
||||
|
||||
def delete_classroom(self, classroom_id: int) -> bool:
|
||||
"""Elimina o desactiva un aula verificando dependencias."""
|
||||
classroom = self.repository.get_by_id(classroom_id)
|
||||
if not classroom:
|
||||
return False
|
||||
# Si tiene reservas históricas, se desactiva lógicamente para preservar integridad
|
||||
if classroom.reservations:
|
||||
classroom.is_active = False
|
||||
self.repository.save(classroom)
|
||||
return True
|
||||
return self.repository.delete(classroom)
|
||||
@@ -0,0 +1,116 @@
|
||||
import jwt
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
from flask import current_app
|
||||
from app.models.user import User
|
||||
|
||||
# Lista negra de tokens revocados en memoria (jti -> timestamp de expiración)
|
||||
_token_blacklist: Dict[str, datetime] = {}
|
||||
|
||||
class JWTService:
|
||||
"""Servicio de emisión, verificación y revocación de tokens JWT (Dual Token Pattern)."""
|
||||
|
||||
ACCESS_TOKEN_EXPIRES = timedelta(days=1)
|
||||
REFRESH_TOKEN_EXPIRES = timedelta(days=7)
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
@classmethod
|
||||
def _clean_expired_blacklist(cls):
|
||||
"""Limpia periódicamente identificadores de tokens que ya han expirado naturalmente."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expired_jtis = [jti for jti, exp in _token_blacklist.items() if exp < now]
|
||||
for jti in expired_jtis:
|
||||
_token_blacklist.pop(jti, None)
|
||||
|
||||
@classmethod
|
||||
def _get_secret(cls) -> str:
|
||||
"""Obtiene la clave secreta desde la configuración de Flask."""
|
||||
return current_app.config.get('SECRET_KEY', 'default-edu-space-secret-key')
|
||||
|
||||
@classmethod
|
||||
def create_access_token(cls, user: User, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Emite un token de acceso de corta duración (15 min) con claims de rol y permisos."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = expires_delta or cls.ACCESS_TOKEN_EXPIRES
|
||||
role_name = user.role_obj.name if user.role_obj else user.role
|
||||
|
||||
# Mapeo de permisos para claims del token
|
||||
permissions = {}
|
||||
if user.role_obj and hasattr(user.role_obj, 'permissions'):
|
||||
for p in user.role_obj.permissions:
|
||||
permissions[p.module] = p.access_level
|
||||
|
||||
payload = {
|
||||
'sub': str(user.id),
|
||||
'email': user.email,
|
||||
'name': user.name,
|
||||
'role': role_name,
|
||||
'is_admin': user.is_admin(),
|
||||
'permissions': permissions,
|
||||
'type': 'access',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'iat': now,
|
||||
'exp': now + delta
|
||||
}
|
||||
return jwt.encode(payload, cls._get_secret(), algorithm=cls.ALGORITHM)
|
||||
|
||||
@classmethod
|
||||
def create_refresh_token(cls, user: User, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Emite un token de refresco de larga duración (7 días) para renovación silenciosa."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = expires_delta or cls.REFRESH_TOKEN_EXPIRES
|
||||
payload = {
|
||||
'sub': str(user.id),
|
||||
'type': 'refresh',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'iat': now,
|
||||
'exp': now + delta
|
||||
}
|
||||
return jwt.encode(payload, cls._get_secret(), algorithm=cls.ALGORITHM)
|
||||
|
||||
@classmethod
|
||||
def generate_tokens(cls, user: User) -> Dict[str, Any]:
|
||||
"""Genera el par de tokens (Access + Refresh) junto con los metadatos del usuario."""
|
||||
access_token = cls.create_access_token(user)
|
||||
refresh_token = cls.create_refresh_token(user)
|
||||
return {
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': int(cls.ACCESS_TOKEN_EXPIRES.total_seconds())
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def decode_token(cls, token: str, expected_type: str = 'access') -> Dict[str, Any]:
|
||||
"""
|
||||
Decodifica y valida la firma, expiración y tipo del token.
|
||||
Lanza jwt.PyJWTError si el token es inválido, expirado o fue revocado.
|
||||
"""
|
||||
cls._clean_expired_blacklist()
|
||||
payload = jwt.decode(token, cls._get_secret(), algorithms=[cls.ALGORITHM])
|
||||
|
||||
jti = payload.get('jti')
|
||||
if jti and jti in _token_blacklist:
|
||||
raise jwt.InvalidTokenError("El token ha sido revocado.")
|
||||
|
||||
token_type = payload.get('type')
|
||||
if token_type != expected_type:
|
||||
raise jwt.InvalidTokenError(f"Tipo de token inválido. Se esperaba '{expected_type}', recibido '{token_type}'.")
|
||||
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def revoke_token(cls, token: str) -> bool:
|
||||
"""Agrega el identificador del token a la lista negra hasta su fecha natural de expiración."""
|
||||
try:
|
||||
payload = jwt.decode(token, cls._get_secret(), algorithms=[cls.ALGORITHM], options={"verify_exp": False})
|
||||
jti = payload.get('jti')
|
||||
exp_ts = payload.get('exp')
|
||||
if jti and exp_ts:
|
||||
exp_dt = datetime.fromtimestamp(exp_ts, tz=timezone.utc)
|
||||
_token_blacklist[jti] = exp_dt
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.classroom import Classroom
|
||||
from app.repositories.reservation_repository import ReservationRepository
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
||||
from app import db
|
||||
|
||||
class ReservationService:
|
||||
"""Capa de servicios para la orquestación y validación de reservas y cronograma."""
|
||||
|
||||
def __init__(self, reservation_repo: Optional[ReservationRepository] = None,
|
||||
classroom_repo: Optional[ClassroomRepository] = None):
|
||||
self.reservation_repo = reservation_repo or ReservationRepository()
|
||||
self.classroom_repo = classroom_repo or ClassroomRepository()
|
||||
|
||||
def get_reservation(self, reservation_id: int) -> Optional[Reservation]:
|
||||
"""Obtiene una reserva por su ID."""
|
||||
return self.reservation_repo.get_by_id(reservation_id)
|
||||
|
||||
def list_reservations(self, start_dt: datetime, end_dt: datetime,
|
||||
classroom_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Obtiene reservas dentro de un rango temporal."""
|
||||
return self.reservation_repo.get_by_date_range(start_dt, end_dt, classroom_id=classroom_id)
|
||||
|
||||
def check_conflicts(self, classroom_id: int, start_time: datetime, end_time: datetime,
|
||||
exclude_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Verifica si existen solapamientos en un aula específica."""
|
||||
classroom = self.classroom_repo.get_by_id(classroom_id)
|
||||
# Las aulas virtuales tienen concurrencia ilimitada (sin colisiones)
|
||||
if classroom and classroom.is_virtual:
|
||||
return []
|
||||
return self.reservation_repo.find_conflicts(
|
||||
classroom_id=classroom_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
exclude_reservation_id=exclude_id
|
||||
)
|
||||
|
||||
def create_reservation(self, dto: ReservationCreateDTO) -> Reservation:
|
||||
"""
|
||||
Crea una nueva reserva aplicando validaciones de aforo y detección de colisiones.
|
||||
"""
|
||||
classroom = self.classroom_repo.get_by_id(dto.classroom_id)
|
||||
if not classroom or not classroom.is_active:
|
||||
raise ValueError(f"El aula con ID {dto.classroom_id} no existe o no se encuentra activa.")
|
||||
|
||||
# Detección de colisiones para aulas físicas
|
||||
if not classroom.is_virtual:
|
||||
conflicts = self.check_conflicts(dto.classroom_id, dto.start_time, dto.end_time)
|
||||
if conflicts:
|
||||
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in conflicts])
|
||||
raise ValueError(f"Conflicto de horario en {classroom.code}: se solapa con las reservas {conflict_details}.")
|
||||
|
||||
if dto.expected_attendees > classroom.capacity:
|
||||
raise ValueError(f"La cantidad de alumnos ({dto.expected_attendees}) supera la capacidad del aula ({classroom.capacity}).")
|
||||
|
||||
reservation = Reservation(
|
||||
classroom_id=dto.classroom_id,
|
||||
commission_id=dto.commission_id,
|
||||
user_id=dto.user_id,
|
||||
start_time=dto.start_time,
|
||||
end_time=dto.end_time,
|
||||
purpose=dto.purpose,
|
||||
expected_attendees=dto.expected_attendees,
|
||||
shift=dto.shift,
|
||||
virtual_link=dto.virtual_link,
|
||||
notes=dto.notes,
|
||||
status=dto.status or 'CONFIRMED'
|
||||
)
|
||||
return self.reservation_repo.save(reservation)
|
||||
|
||||
def update_reservation(self, reservation_id: int, dto: ReservationUpdateDTO) -> Reservation:
|
||||
"""Actualiza una reserva existente verificando disponibilidad horaria."""
|
||||
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||
if not reservation:
|
||||
raise ValueError(f"Reserva con ID {reservation_id} no encontrada.")
|
||||
|
||||
classroom_id = dto.classroom_id or reservation.classroom_id
|
||||
start_time = dto.start_time or reservation.start_time
|
||||
end_time = dto.end_time or reservation.end_time
|
||||
|
||||
classroom = self.classroom_repo.get_by_id(classroom_id)
|
||||
if not classroom or not classroom.is_active:
|
||||
raise ValueError(f"El aula con ID {classroom_id} no existe o no se encuentra activa.")
|
||||
|
||||
if not classroom.is_virtual:
|
||||
conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=reservation.id)
|
||||
if conflicts:
|
||||
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} - {c.end_time.strftime('%H:%M')})" for c in conflicts])
|
||||
raise ValueError(f"Conflicto de horario en {classroom.code} con: {conflict_details}.")
|
||||
|
||||
if dto.classroom_id is not None:
|
||||
reservation.classroom_id = dto.classroom_id
|
||||
if dto.start_time is not None:
|
||||
reservation.start_time = dto.start_time
|
||||
if dto.end_time is not None:
|
||||
reservation.end_time = dto.end_time
|
||||
if dto.purpose is not None:
|
||||
reservation.purpose = dto.purpose
|
||||
if dto.expected_attendees is not None:
|
||||
reservation.expected_attendees = dto.expected_attendees
|
||||
if dto.shift is not None:
|
||||
reservation.shift = dto.shift
|
||||
if dto.virtual_link is not None:
|
||||
reservation.virtual_link = dto.virtual_link
|
||||
if dto.notes is not None:
|
||||
reservation.notes = dto.notes
|
||||
if dto.status is not None:
|
||||
reservation.status = dto.status
|
||||
|
||||
return self.reservation_repo.save(reservation)
|
||||
|
||||
def cancel_reservation(self, reservation_id: int) -> bool:
|
||||
"""Cancela una reserva estableciendo su estado en CANCELLED."""
|
||||
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||
if not reservation:
|
||||
return False
|
||||
reservation.status = ReservationStatus.CANCELLED.value
|
||||
self.reservation_repo.save(reservation)
|
||||
return True
|
||||
@@ -0,0 +1,436 @@
|
||||
import urllib.request
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from datetime import datetime, date, time, timedelta
|
||||
from app import db
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.career import Career
|
||||
from app.models.subject import Subject, Commission
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.user import User
|
||||
|
||||
BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv'
|
||||
|
||||
SHEETS_CONFIG = [
|
||||
{ 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 },
|
||||
{ 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 },
|
||||
{ 'name': 'Miércoles', 'gid': '615483345', 'day_idx': 2 },
|
||||
{ 'name': 'Jueves', 'gid': '682416571', 'day_idx': 3 },
|
||||
{ 'name': 'Viernes', 'gid': '1162906711', 'day_idx': 4 },
|
||||
{ 'name': 'Sábado', 'gid': '752305601', 'day_idx': 5 },
|
||||
]
|
||||
|
||||
TIME_REGEX = re.compile(r'(\d{1,2})(?::(\d{2}))?\s*(?:a|-|to)\s*(\d{1,2})(?::(\d{2}))?', re.IGNORECASE)
|
||||
|
||||
class GoogleSheetsImporter:
|
||||
"""
|
||||
Service to fetch, parse, and synchronize academic schedules,
|
||||
classrooms, careers, and subjects from the official UniCABA Google Sheet.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url=None):
|
||||
self.base_url = base_url or BASE_CSV_URL
|
||||
|
||||
def fetch_sheet_csv(self, gid):
|
||||
"""Fetch CSV string for a specific sheet gid"""
|
||||
url = f"{self.base_url}&gid={gid}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return resp.read().decode('utf-8', errors='replace')
|
||||
|
||||
def parse_sheet_rows(self, csv_content, sheet_name):
|
||||
"""Parse raw CSV text into a structured list of class records"""
|
||||
reader = csv.reader(io.StringIO(csv_content))
|
||||
rows = list(reader)
|
||||
|
||||
header_idx = -1
|
||||
col_offset = 0
|
||||
|
||||
for r_idx, row in enumerate(rows):
|
||||
for c_idx, cell in enumerate(row):
|
||||
cell_clean = cell.lower().strip()
|
||||
if 'codigo' in cell_clean or 'código' in cell_clean:
|
||||
header_idx = r_idx
|
||||
col_offset = c_idx
|
||||
break
|
||||
if header_idx != -1:
|
||||
break
|
||||
|
||||
if header_idx == -1:
|
||||
return []
|
||||
|
||||
records = []
|
||||
current_shift = 'Mañana'
|
||||
for row in rows[header_idx + 1:]:
|
||||
if len(row) >= col_offset + 5:
|
||||
raw_cell = row[col_offset].strip().replace('\n', ' - ')
|
||||
row_full_text = ' '.join(row).upper()
|
||||
|
||||
# Detect shift delimiters
|
||||
if 'TURNO MAÑANA' in row_full_text or 'TURNO MANANA' in row_full_text:
|
||||
current_shift = 'Mañana'
|
||||
continue
|
||||
elif 'TURNO VESPERTINO' in row_full_text or 'TURNO TARDE' in row_full_text or 'TURNO NOCHE' in row_full_text:
|
||||
current_shift = 'Tarde'
|
||||
continue
|
||||
elif raw_cell.lower().startswith('turno'):
|
||||
if 'mañ' in raw_cell.lower() or 'man' in raw_cell.lower():
|
||||
current_shift = 'Mañana'
|
||||
else:
|
||||
current_shift = 'Tarde'
|
||||
continue
|
||||
|
||||
codigo = raw_cell
|
||||
asignatura = row[col_offset + 1].strip().replace('\n', ' ')
|
||||
aula = row[col_offset + 2].strip().replace('\n', ' ')
|
||||
carrera = row[col_offset + 3].strip().replace('\n', ' ')
|
||||
horario = row[col_offset + 4].strip().replace('\n', ' ')
|
||||
|
||||
# Check valid data row
|
||||
if codigo and asignatura and not 'codigo' in codigo.lower():
|
||||
# Double-check shift from start hour if possible
|
||||
record_shift = current_shift
|
||||
time_match = re.search(r'(\d{1,2}):(\d{2})', horario)
|
||||
if time_match:
|
||||
h = int(time_match.group(1))
|
||||
if h >= 14:
|
||||
record_shift = 'Tarde'
|
||||
elif h < 13:
|
||||
record_shift = 'Mañana'
|
||||
|
||||
records.append({
|
||||
'dia': sheet_name,
|
||||
'codigo': codigo,
|
||||
'asignatura': asignatura,
|
||||
'aula': aula,
|
||||
'carrera': carrera,
|
||||
'horario': horario,
|
||||
'turno': record_shift
|
||||
})
|
||||
return records
|
||||
|
||||
def normalize_room(self, aula_raw):
|
||||
"""
|
||||
Normalize room string into (building, room_number, floor, capacity)
|
||||
"""
|
||||
aula_upper = aula_raw.upper().strip()
|
||||
if not aula_upper or any(x in aula_upper for x in ['FERIADO', 'SIN ACTIVIDAD', 'CONSULTAR EN BEDELIA']):
|
||||
return None
|
||||
|
||||
if 'VIRTUAL' in aula_upper:
|
||||
return {
|
||||
'building': 'Campus Virtual',
|
||||
'code': 'VIRTUAL',
|
||||
'floor': 0,
|
||||
'capacity': 0,
|
||||
'description': 'Espacio áulico virtual UniCABA (Capacidad Ilimitada ∞)'
|
||||
}
|
||||
|
||||
if 'AUDITORIO' in aula_upper:
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': 'Auditorio',
|
||||
'floor': 0,
|
||||
'capacity': 150,
|
||||
'description': 'Auditorio principal UniCABA'
|
||||
}
|
||||
|
||||
if 'SALA DE REUNI' in aula_upper:
|
||||
floor = 2 if ('2DO' in aula_upper or '2°' in aula_upper) else 4
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': f'Sala Reunión {floor}°P',
|
||||
'floor': floor,
|
||||
'capacity': 15,
|
||||
'description': f'Sala de reunión piso {floor}'
|
||||
}
|
||||
|
||||
# Standard classrooms: "Aula 402", "AULA 203", "306 - 406"
|
||||
num_match = re.search(r'\d+', aula_raw)
|
||||
if num_match:
|
||||
num = num_match.group(0)
|
||||
floor = int(num[0]) if len(num) >= 3 else 1
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': f'Aula {num}',
|
||||
'floor': floor,
|
||||
'capacity': 35,
|
||||
'description': f'Aula física {num} - Piso {floor}'
|
||||
}
|
||||
|
||||
# Generic fallback
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': aula_raw,
|
||||
'floor': 1,
|
||||
'capacity': 30,
|
||||
'description': aula_raw
|
||||
}
|
||||
|
||||
def parse_time_range(self, horario_str):
|
||||
"""Parse '8:00 a 9:00hs' into (time(8, 0), time(9, 0))"""
|
||||
m = TIME_REGEX.search(horario_str)
|
||||
if not m:
|
||||
return None, None
|
||||
try:
|
||||
h1 = int(m.group(1))
|
||||
m1 = int(m.group(2) or 0)
|
||||
h2 = int(m.group(3))
|
||||
m2 = int(m.group(4) or 0)
|
||||
return time(h1, m1), time(h2, m2)
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
def sync(self):
|
||||
"""
|
||||
Main synchronization process:
|
||||
1. Fetches all 6 day sheets.
|
||||
2. Upserts Careers, Classrooms, Subjects, Commissions.
|
||||
3. Generates Reservations in the calendar for the semester.
|
||||
"""
|
||||
stats = {
|
||||
'total_rows_processed': 0,
|
||||
'careers_created': 0,
|
||||
'careers_updated': 0,
|
||||
'classrooms_created': 0,
|
||||
'classrooms_updated': 0,
|
||||
'subjects_created': 0,
|
||||
'subjects_updated': 0,
|
||||
'commissions_created': 0,
|
||||
'reservations_created': 0,
|
||||
'errors': [],
|
||||
'sample_records': []
|
||||
}
|
||||
|
||||
all_raw_records = []
|
||||
for sheet_cfg in SHEETS_CONFIG:
|
||||
try:
|
||||
csv_data = self.fetch_sheet_csv(sheet_cfg['gid'])
|
||||
rows = self.parse_sheet_rows(csv_data, sheet_cfg['name'])
|
||||
all_raw_records.extend([(r, sheet_cfg['day_idx']) for r in rows])
|
||||
except Exception as e:
|
||||
stats['errors'].append(f"Error al descargar o procesar la hoja de {sheet_cfg['name']}: {str(e)}")
|
||||
|
||||
stats['total_rows_processed'] = len(all_raw_records)
|
||||
if not all_raw_records:
|
||||
return stats
|
||||
|
||||
# Default admin user for reservations
|
||||
default_user = User.query.filter_by(role='ADMIN').first() or User.query.first()
|
||||
default_user_id = default_user.id if default_user else 1
|
||||
|
||||
# Ensure institutional buildings exist with official address
|
||||
from app.models.building import Building
|
||||
b_central = Building.query.filter_by(name='Edificio Central').first()
|
||||
if not b_central:
|
||||
b_central = Building(
|
||||
name='Edificio Central',
|
||||
code='CENTRAL',
|
||||
address='Tte. Gral. Juan Domingo Perón 802, CABA',
|
||||
floors=4,
|
||||
description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.'
|
||||
)
|
||||
db.session.add(b_central)
|
||||
db.session.flush()
|
||||
else:
|
||||
b_central.address = 'Tte. Gral. Juan Domingo Perón 802, CABA'
|
||||
|
||||
b_virtual = Building.query.filter_by(name='Campus Virtual').first()
|
||||
if not b_virtual:
|
||||
b_virtual = Building(
|
||||
name='Campus Virtual',
|
||||
code='VIRTUAL',
|
||||
address='Entorno Digital / Microsoft Teams - Moodle',
|
||||
floors=0,
|
||||
description='Entorno de aulas virtuales y clases a distancia'
|
||||
)
|
||||
db.session.add(b_virtual)
|
||||
db.session.flush()
|
||||
|
||||
buildings_cache = {b.name.strip().upper(): b for b in Building.query.all()}
|
||||
careers_cache = {c.name.strip().upper(): c for c in Career.query.all()}
|
||||
classrooms_cache = {(c.building.strip().upper(), c.code.strip().upper()): c for c in Classroom.query.all()}
|
||||
subjects_cache = {s.code.strip().upper(): s for s in Subject.query.all()}
|
||||
|
||||
# Calculate current semester dates (anchored on academic year 2026)
|
||||
# Week starting Monday 2026-08-31 to Saturday 2026-09-05
|
||||
base_monday = date(2026, 8, 31)
|
||||
weeks_to_generate = 4 # Generate reservations for 4 consecutive weeks
|
||||
|
||||
for rec, day_idx in all_raw_records:
|
||||
try:
|
||||
codigo = rec['codigo']
|
||||
asignatura = rec['asignatura']
|
||||
aula_raw = rec['aula']
|
||||
carrera_name = rec['carrera'].strip()
|
||||
horario_raw = rec['horario']
|
||||
dia = rec['dia']
|
||||
turno = rec.get('turno', 'Mañana')
|
||||
|
||||
# 1. Upsert Career
|
||||
carrera_key = carrera_name.upper()
|
||||
career = careers_cache.get(carrera_key)
|
||||
if not career:
|
||||
career = Career(name=carrera_name)
|
||||
db.session.add(career)
|
||||
db.session.flush()
|
||||
careers_cache[carrera_key] = career
|
||||
stats['careers_created'] += 1
|
||||
|
||||
# 2. Upsert Classroom
|
||||
room_info = self.normalize_room(aula_raw)
|
||||
classroom = None
|
||||
if room_info:
|
||||
room_key = (room_info['building'].upper(), room_info['code'].upper())
|
||||
classroom = classrooms_cache.get(room_key)
|
||||
b_entity = buildings_cache.get(room_info['building'].upper())
|
||||
if not classroom:
|
||||
classroom = Classroom(
|
||||
building=room_info['building'],
|
||||
building_id=b_entity.id if b_entity else None,
|
||||
floor=room_info['floor'],
|
||||
capacity=room_info['capacity'],
|
||||
description=room_info['description'],
|
||||
is_active=True
|
||||
)
|
||||
classroom.code = room_info['code']
|
||||
db.session.add(classroom)
|
||||
db.session.flush()
|
||||
classrooms_cache[room_key] = classroom
|
||||
stats['classrooms_created'] += 1
|
||||
else:
|
||||
classroom.capacity = max(classroom.capacity, room_info['capacity'])
|
||||
if not classroom.building_id and b_entity:
|
||||
classroom.building_id = b_entity.id
|
||||
|
||||
# 3. Upsert Subject
|
||||
subject_key = codigo.upper()
|
||||
subject = subjects_cache.get(subject_key)
|
||||
if not subject:
|
||||
subject = Subject(
|
||||
code=codigo,
|
||||
name=asignatura,
|
||||
career_id=career.id,
|
||||
department=career.name,
|
||||
credits=4,
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(subject)
|
||||
db.session.flush()
|
||||
subjects_cache[subject_key] = subject
|
||||
stats['subjects_created'] += 1
|
||||
else:
|
||||
if subject.name != asignatura:
|
||||
subject.name = asignatura
|
||||
stats['subjects_updated'] += 1
|
||||
subject.career_id = career.id
|
||||
subject.department = career.name
|
||||
|
||||
# 4. Upsert Commission
|
||||
# Identify commission subcode e.g. from code suffix or default 'C1'
|
||||
comm_code = 'C1'
|
||||
if '-E' in codigo:
|
||||
comm_code = codigo.split('-')[-1]
|
||||
|
||||
commission = Commission.query.filter_by(
|
||||
subject_id=subject.id,
|
||||
code=comm_code,
|
||||
semester='2026-2',
|
||||
year=2026
|
||||
).first()
|
||||
|
||||
if not commission:
|
||||
commission = Commission(
|
||||
subject_id=subject.id,
|
||||
code=comm_code,
|
||||
semester='2026-2',
|
||||
year=2026,
|
||||
teacher_id=default_user_id,
|
||||
max_students=classroom.capacity if classroom else 35,
|
||||
current_students=0,
|
||||
schedule=f"{dia} {horario_raw}",
|
||||
shift=turno,
|
||||
active=True
|
||||
)
|
||||
db.session.add(commission)
|
||||
db.session.flush()
|
||||
stats['commissions_created'] += 1
|
||||
else:
|
||||
commission.schedule = f"{dia} {horario_raw}"
|
||||
commission.shift = turno
|
||||
|
||||
# 5. Generate Calendar Reservations
|
||||
t_start, t_end = self.parse_time_range(horario_raw)
|
||||
if classroom and t_start and t_end and day_idx is not None:
|
||||
is_room_virtual = bool(classroom.is_virtual or 'VIRTUAL' in (classroom.code or '').upper())
|
||||
|
||||
# Generar enlace de videollamada para clases virtuales si no tienen uno asignado
|
||||
v_link = None
|
||||
if is_room_virtual:
|
||||
clean_subj = re.sub(r'[^a-zA-Z0-9]', '', subject.code or 'cls').lower()
|
||||
clean_comm = re.sub(r'[^a-zA-Z0-9]', '', commission.code or 'c1').lower()
|
||||
v_link = f"https://meet.google.com/edu-{clean_subj}-{clean_comm}"
|
||||
if not commission.virtual_link:
|
||||
commission.virtual_link = v_link
|
||||
|
||||
for w in range(weeks_to_generate):
|
||||
class_date = base_monday + timedelta(days=day_idx, weeks=w)
|
||||
dt_start = datetime.combine(class_date, t_start)
|
||||
dt_end = datetime.combine(class_date, t_end)
|
||||
|
||||
# En aulas virtuales la capacidad es infinita y pueden cursar múltiples comisiones en paralelo
|
||||
if is_room_virtual:
|
||||
existing_res = Reservation.query.filter_by(
|
||||
classroom_id=classroom.id,
|
||||
commission_id=commission.id,
|
||||
start_time=dt_start,
|
||||
end_time=dt_end
|
||||
).first()
|
||||
else:
|
||||
existing_res = Reservation.query.filter_by(
|
||||
classroom_id=classroom.id,
|
||||
start_time=dt_start,
|
||||
end_time=dt_end
|
||||
).first()
|
||||
|
||||
if not existing_res:
|
||||
res = Reservation(
|
||||
classroom_id=classroom.id,
|
||||
commission_id=commission.id,
|
||||
user_id=default_user_id,
|
||||
start_time=dt_start,
|
||||
end_time=dt_end,
|
||||
purpose=f"{subject.code} - {subject.name}",
|
||||
status='CONFIRMED',
|
||||
expected_attendees=min(classroom.capacity, 30) if not is_room_virtual else 35,
|
||||
shift=turno,
|
||||
virtual_link=v_link,
|
||||
notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
||||
)
|
||||
db.session.add(res)
|
||||
stats['reservations_created'] += 1
|
||||
else:
|
||||
existing_res.shift = turno
|
||||
existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
||||
if is_room_virtual and not existing_res.virtual_link:
|
||||
existing_res.virtual_link = v_link
|
||||
|
||||
if len(stats['sample_records']) < 10:
|
||||
stats['sample_records'].append({
|
||||
'dia': dia,
|
||||
'codigo': codigo,
|
||||
'asignatura': asignatura,
|
||||
'carrera': career.name,
|
||||
'aula': classroom.code_display if classroom else aula_raw,
|
||||
'horario': horario_raw
|
||||
})
|
||||
|
||||
except Exception as row_err:
|
||||
stats['errors'].append(f"Error en fila '{rec.get('codigo', '?')}': {str(row_err)}")
|
||||
|
||||
db.session.commit()
|
||||
return stats
|
||||
@@ -0,0 +1,53 @@
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from app.models.user import User
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app import db
|
||||
|
||||
class UserService:
|
||||
"""Capa de servicios para la autenticación, roles y perfil de usuarios."""
|
||||
|
||||
def __init__(self, repository: Optional[UserRepository] = None):
|
||||
self.repository = repository or UserRepository()
|
||||
|
||||
def authenticate(self, email: str, password: str) -> Optional[User]:
|
||||
"""
|
||||
Valida credenciales de acceso de un usuario.
|
||||
Retorna la entidad User si es válido y activo; None en caso contrario.
|
||||
"""
|
||||
user = self.repository.get_by_email(email)
|
||||
if not user or not user.is_active:
|
||||
return None
|
||||
|
||||
if user.check_password(password):
|
||||
user.last_login = datetime.utcnow()
|
||||
self.repository.save(user)
|
||||
return user
|
||||
return None
|
||||
|
||||
def get_by_id(self, user_id: int) -> Optional[User]:
|
||||
"""Obtiene un usuario por ID."""
|
||||
return self.repository.get_by_id(user_id)
|
||||
|
||||
def get_by_email(self, email: str) -> Optional[User]:
|
||||
"""Obtiene un usuario por email."""
|
||||
return self.repository.get_by_email(email)
|
||||
|
||||
def get_profile_data(self, user: User) -> Dict[str, Any]:
|
||||
"""Genera un diccionario estructurado del perfil y permisos del usuario."""
|
||||
role_name = user.role_obj.name if user.role_obj else user.role
|
||||
permissions = {}
|
||||
if user.role_obj and hasattr(user.role_obj, 'permissions'):
|
||||
for p in user.role_obj.permissions:
|
||||
permissions[p.module] = p.access_level
|
||||
|
||||
return {
|
||||
'id': user.id,
|
||||
'name': user.name,
|
||||
'email': user.email,
|
||||
'role': role_name,
|
||||
'is_admin': user.is_admin(),
|
||||
'permissions': permissions,
|
||||
'preferred_language': user.preferred_language,
|
||||
'theme_preference': user.theme_preference
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from .decorators import permission_required, admin_required
|
||||
|
||||
__all__ = ['permission_required', 'admin_required']
|
||||
@@ -0,0 +1,64 @@
|
||||
from functools import wraps
|
||||
from flask import abort, redirect, url_for, request, flash
|
||||
from flask_login import current_user
|
||||
from flask_babel import gettext as _
|
||||
|
||||
def permission_required(module, min_level='read'):
|
||||
"""
|
||||
Decorator to restrict view access based on RBAC permissions.
|
||||
Hierarchy: 'read_write' > 'read' > 'none'.
|
||||
Administrators have implicit access to all modules.
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('auth.login', next=request.url))
|
||||
|
||||
if not current_user.has_permission(module, min_level):
|
||||
flash(_('No tienes los permisos necesarios para acceder a esta sección.'), 'error')
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""
|
||||
Decorator requiring administrative privileges or 'users' management access.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('auth.login', next=request.url))
|
||||
|
||||
if not current_user.can_manage():
|
||||
flash(_('Esta sección requiere privilegios de administrador.'), 'error')
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def can_edit_reservation_required(f):
|
||||
"""
|
||||
Decorator requiring permission to modify a specific reservation.
|
||||
Allows administrators, Bedelía, or the creator/teacher of the reservation.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('auth.login', next=request.url))
|
||||
|
||||
res_id = kwargs.get('id')
|
||||
if res_id:
|
||||
from app.models.reservation import Reservation
|
||||
reservation = Reservation.query.get_or_404(res_id)
|
||||
if not current_user.can_edit_reservation(reservation):
|
||||
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
from functools import wraps
|
||||
from flask import request, jsonify, g
|
||||
import jwt
|
||||
from app.services.jwt_service import JWTService
|
||||
from app.models.user import User
|
||||
|
||||
def jwt_required(f):
|
||||
"""
|
||||
Decorador para proteger endpoints REST mediante Bearer Token JWT.
|
||||
Extrae y valida el token, inyectando el usuario en 'g.jwt_user' y el payload en 'g.jwt_payload'.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
token = None
|
||||
|
||||
if auth_header.startswith('Bearer '):
|
||||
token = auth_header.split(' ', 1)[1].strip()
|
||||
elif 'access_token' in request.cookies:
|
||||
token = request.cookies.get('access_token')
|
||||
|
||||
if not token:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Encabezado Authorization con Bearer token no proporcionado.'
|
||||
}), 401
|
||||
|
||||
try:
|
||||
payload = JWTService.decode_token(token, expected_type='access')
|
||||
user_id = int(payload.get('sub'))
|
||||
from app import db
|
||||
user = db.session.get(User, user_id)
|
||||
|
||||
if not user or not user.is_active:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Usuario no encontrado o cuenta desactivada.'
|
||||
}), 401
|
||||
|
||||
g.jwt_user = user
|
||||
g.jwt_payload = payload
|
||||
g.jwt_token = token
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({
|
||||
'error': 'TokenExpired',
|
||||
'message': 'El token de acceso ha expirado. Por favor renueve su sesión.'
|
||||
}), 401
|
||||
except jwt.InvalidTokenError as e:
|
||||
return jsonify({
|
||||
'error': 'InvalidToken',
|
||||
'message': str(e)
|
||||
}), 401
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'error': 'AuthenticationError',
|
||||
'message': f'Fallo en la verificación del token: {str(e)}'
|
||||
}), 401
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def jwt_role_required(*allowed_roles):
|
||||
"""
|
||||
Decorador que verifica que el usuario autenticado vía JWT posea alguno de los roles permitidos.
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
@jwt_required
|
||||
def decorated_function(*args, **kwargs):
|
||||
user: User = g.jwt_user
|
||||
if user.is_admin():
|
||||
return f(*args, **kwargs)
|
||||
|
||||
user_role = (user.role_obj.name if user.role_obj else user.role).upper()
|
||||
allowed = [r.upper() for r in allowed_roles]
|
||||
|
||||
if user_role not in allowed:
|
||||
return jsonify({
|
||||
'error': 'Forbidden',
|
||||
'message': f'Acceso denegado. Se requiere uno de los siguientes roles: {", ".join(allowed_roles)}'
|
||||
}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||