feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia

This commit is contained in:
2026-09-18 23:04:48 -03:00
parent 47b50ede7e
commit 1080acd43a
240 changed files with 8354 additions and 8886 deletions
+975
View File
@@ -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