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/', 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 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 ) @schedule_bp.route('/commissions') def commissions_view(): subjects = Subject.query.all() # Calculamos el total de capacidad sumando todas las comisiones de todas las materias total_capacity = sum(c.max_students for s in subjects for c in s.commissions) # Calculamos el total de comisiones total_commissions = sum(len(s.commissions) for s in subjects) return render_template('schedule/commissions.html', subjects=subjects, total_capacity=total_capacity, total_commissions=total_commissions) @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.code_display} - {c.name} (Cap: {c.capacity})') 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//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