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/', 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//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 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//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//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//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//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//milestones//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, 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('/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: 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}%'), 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//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 '', '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