809 lines
36 KiB
Python
809 lines
36 KiB
Python
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 and floor_filter.isdigit():
|
|
query = query.filter(Classroom.floor == int(floor_filter))
|
|
|
|
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('/calendar')
|
|
@login_required
|
|
def calendar_view():
|
|
"""Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
|
|
from app.models.career import Career
|
|
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()
|
|
|
|
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,
|
|
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
|
|
|
|
form = ReservationForm()
|
|
|
|
# Get active classrooms for the dropdown
|
|
classrooms = Classroom.query.filter_by(is_active=True).all()
|
|
|
|
# Get active commissions for the dropdown
|
|
commissions = Commission.query.filter_by(active=True).all()
|
|
|
|
# Populate form choices
|
|
form.classroom_id.choices = [(c.id, f'{c.building} - {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)
|
|
|
|
# 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,
|
|
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()
|
|
current_app.logger.error(f"Error creating reservation: {str(e)}")
|
|
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)
|
|
|
|
@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()
|
|
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 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 and floor_filter.isdigit():
|
|
query = query.filter(Classroom.floor == int(floor_filter))
|
|
|
|
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}%'),
|
|
Classroom.code.ilike(f'%{search}%'),
|
|
Reservation.purpose.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]
|
|
|
|
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,
|
|
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:
|
|
from datetime import datetime
|
|
# 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))
|
|
|
|
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 and floor.isdigit():
|
|
query = query.filter(Classroom.floor == int(floor))
|
|
|
|
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 if reservation.classroom else None
|
|
is_virtual = bool(reservation.classroom and ('VIRTUAL' in (reservation.classroom.code or '').upper() or 'VIRTUAL' in (reservation.classroom.building or '').upper()))
|
|
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': reservation.classroom.building if reservation.classroom else '',
|
|
'floor': classroom_floor,
|
|
'is_virtual': is_virtual,
|
|
'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 '',
|
|
'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 |