feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user