409 lines
17 KiB
Python
409 lines
17 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"""
|
|
today = datetime.utcnow().date()
|
|
today_start = datetime.combine(today, datetime.min.time())
|
|
today_end = datetime.combine(today, datetime.max.time())
|
|
|
|
# Get reservations with eager loading
|
|
reservations = Reservation.query.options(
|
|
joinedload(Reservation.classroom),
|
|
joinedload(Reservation.commission),
|
|
joinedload(Reservation.user)
|
|
).filter(
|
|
Reservation.start_time >= today_start,
|
|
Reservation.start_time <= today_end,
|
|
Reservation.status.in_([ReservationStatus.CONFIRMED, ReservationStatus.PENDING])
|
|
).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])
|
|
pending = len([r for r in reservations if r.status == ReservationStatus.PENDING])
|
|
|
|
# Get total classrooms and calculate occupied ones
|
|
from app.models.classroom import Classroom
|
|
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
|
|
}
|
|
|
|
return render_template('schedule/today.html',
|
|
reservations=reservations,
|
|
time_blocks=time_blocks,
|
|
stats=stats,
|
|
today=today)
|
|
|
|
@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"""
|
|
return render_template('schedule/calendar.html', title='Calendar View')
|
|
|
|
@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
|
|
).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,
|
|
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"""
|
|
return redirect(url_for('schedule.today_schedule'))
|
|
|
|
|
|
@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:
|
|
flash('Only pending reservations can be confirmed.', 'warning')
|
|
return redirect(url_for('schedule.today_schedule'))
|
|
|
|
reservation.status = ReservationStatus.CONFIRMED
|
|
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:
|
|
# Get date range from query parameters
|
|
start = request.args.get('start')
|
|
end = request.args.get('end')
|
|
|
|
if start and end:
|
|
from datetime import datetime
|
|
start_date = datetime.fromisoformat(start)
|
|
end_date = datetime.fromisoformat(end)
|
|
else:
|
|
# Default to current month
|
|
today = date.today()
|
|
start_date = datetime.combine(today.replace(day=1), datetime.min.time())
|
|
next_month = today.replace(day=28) + timedelta(days=4) # Next month
|
|
end_date = datetime.combine(next_month.replace(day=1), datetime.max.time())
|
|
|
|
# Get reservations in the date range
|
|
reservations = Reservation.query.options(
|
|
joinedload(Reservation.classroom),
|
|
joinedload(Reservation.commission)
|
|
).filter(
|
|
Reservation.start_time >= start_date,
|
|
Reservation.start_time <= end_date,
|
|
Reservation.status.in_([ReservationStatus.CONFIRMED, ReservationStatus.PENDING])
|
|
).order_by(Reservation.start_time).all()
|
|
|
|
events = []
|
|
for reservation in reservations:
|
|
events.append({
|
|
'id': reservation.id,
|
|
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}",
|
|
'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-{reservation.status.value.lower()}',
|
|
'extendedProps': {
|
|
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown',
|
|
'commission': reservation.commission.get_full_code() if reservation.commission else 'No Commission',
|
|
'purpose': reservation.purpose or 'No purpose',
|
|
'attendees': reservation.expected_attendees,
|
|
'status': reservation.status.value,
|
|
'notes': reservation.notes or ''
|
|
}
|
|
})
|
|
|
|
return jsonify(events)
|
|
|
|
except Exception as e:
|
|
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)
|
|
).filter(
|
|
Reservation.start_time >= today_start,
|
|
Reservation.start_time <= today_end,
|
|
Reservation.status == ReservationStatus.CONFIRMED
|
|
).order_by(Reservation.start_time).all()
|
|
|
|
events = []
|
|
for reservation in reservations:
|
|
events.append({
|
|
'id': reservation.id,
|
|
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}",
|
|
'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': {
|
|
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown',
|
|
'purpose': reservation.purpose or 'No purpose',
|
|
'attendees': reservation.expected_attendees,
|
|
'status': reservation.status.value
|
|
}
|
|
})
|
|
|
|
return jsonify({'events': events})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500 |