feat: optimizacion zocalos aulas, metricas ocupacion dia/semana/mes, modificacion de reservas y permisos RBAC en gestion

This commit is contained in:
Carlos Tello
2026-09-03 11:16:07 -03:00
parent cde62c9b0a
commit c9dc3d9208
13 changed files with 957 additions and 87 deletions
+71 -2
View File
@@ -122,13 +122,80 @@ def list_classrooms():
)
)
classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate(
page=page, per_page=12, error_out=False
classrooms = query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
page=page, per_page=20, error_out=False
)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
floors = [f[0] for f in db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()]
# Fechas y cálculo de métricas de ocupación (Día, Semana, Mes)
import calendar
from datetime import date, timedelta
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
week_start = datetime.combine(today - timedelta(days=today.weekday()), datetime.min.time())
week_end = datetime.combine(week_start.date() + timedelta(days=6), datetime.max.time())
_, last_day = calendar.monthrange(today.year, today.month)
month_start = datetime(today.year, today.month, 1, 0, 0, 0)
month_end = datetime(today.year, today.month, last_day, 23, 59, 59)
# Consultar en lote las reservas del mes para las aulas visibles
from app.models.reservation import Reservation, ReservationStatus
visible_ids = [c.id for c in classrooms.items]
month_reservations = Reservation.query.filter(
Reservation.classroom_id.in_(visible_ids),
Reservation.start_time >= month_start,
Reservation.start_time <= month_end,
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
).all() if visible_ids else []
# Horas base operativas académicas:
# Día: 15 hs (07:00 a 22:00 = 900 min)
# Semana: 6 días x 15 hs = 5400 min
# Mes: ~24 días hábiles x 15 hs = 21600 min
DAILY_OP_MINS = 15 * 60
WEEKLY_OP_MINS = 6 * 15 * 60
MONTHLY_OP_MINS = 24 * 15 * 60
occupancy_metrics = {}
for c in classrooms.items:
c_res = [r for r in month_reservations if r.classroom_id == c.id]
# Día
day_res = [r for r in c_res if today_start <= r.start_time <= today_end]
day_mins = sum((min(r.end_time, today_end) - max(r.start_time, today_start)).total_seconds() / 60 for r in day_res)
day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100))) if not c.is_virtual else len(day_res)
# Semana
week_res = [r for r in c_res if week_start <= r.start_time <= week_end]
week_mins = sum((min(r.end_time, week_end) - max(r.start_time, week_start)).total_seconds() / 60 for r in week_res)
week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100))) if not c.is_virtual else len(week_res)
# Mes
month_mins = sum((min(r.end_time, month_end) - max(r.start_time, month_start)).total_seconds() / 60 for r in c_res)
month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100))) if not c.is_virtual else len(c_res)
occupancy_metrics[c.id] = {
'day_pct': day_pct,
'day_count': len(day_res),
'week_pct': week_pct,
'week_count': len(week_res),
'month_pct': month_pct,
'month_count': len(c_res)
}
date_filters = {
'today': today.strftime('%Y-%m-%d'),
'week_start': week_start.strftime('%Y-%m-%d'),
'week_end': week_end.strftime('%Y-%m-%d'),
'month_start': month_start.strftime('%Y-%m-%d'),
'month_end': month_end.strftime('%Y-%m-%d')
}
return render_template('classrooms/list.html',
classrooms=classrooms,
buildings=buildings,
@@ -137,6 +204,8 @@ def list_classrooms():
floor_filter=floor_filter,
active_only=active_only,
search=search,
occupancy_metrics=occupancy_metrics,
date_filters=date_filters,
title='Classrooms')
except Exception as e:
+93
View File
@@ -139,6 +139,92 @@ def view_reservation(id):
flash(f'Error loading reservation details: {str(e)}', 'error')
return redirect(url_for('schedule.today_schedule'))
@schedule_bp.route('/reservation/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_reservation(id):
"""Edit existing reservation"""
from app.models.classroom import Classroom
from app.models.subject import Commission
from app.models.building import Building
from flask_babel import gettext as _
reservation = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission),
joinedload(Reservation.user)
).get_or_404(id)
if not current_user.can_edit_reservation(reservation):
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
return redirect(url_for('schedule.view_reservation', id=reservation.id))
form = ReservationForm(is_edit=True)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
commissions = Commission.query.filter_by(active=True).all()
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
if request.method == 'GET':
form.classroom_id.data = reservation.classroom_id
form.commission_id.data = reservation.commission_id
form.start_time.data = reservation.start_time
form.end_time.data = reservation.end_time
form.purpose.data = reservation.purpose
form.expected_attendees.data = reservation.expected_attendees
form.notes.data = reservation.notes or ''
form.virtual_link.data = reservation.virtual_link or ''
elif request.method == 'POST':
if form.validate_on_submit():
try:
# Verificar colisión de horario si no es virtual
target_room = Classroom.query.get(form.classroom_id.data)
if target_room and not target_room.is_virtual:
conflict = Reservation.query.filter(
Reservation.id != reservation.id,
Reservation.classroom_id == form.classroom_id.data,
Reservation.status == ReservationStatus.CONFIRMED.value,
Reservation.start_time < form.end_time.data,
Reservation.end_time > form.start_time.data
).first()
if conflict:
flash(_('El aula seleccionada ya cuenta con una reserva confirmada en esa franja horaria.'), 'danger')
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
# Asignar cambios
reservation.classroom_id = form.classroom_id.data
reservation.commission_id = form.commission_id.data
reservation.start_time = form.start_time.data
reservation.end_time = form.end_time.data
reservation.purpose = form.purpose.data
reservation.expected_attendees = form.expected_attendees.data
reservation.notes = form.notes.data
reservation.virtual_link = form.virtual_link.data.strip() if form.virtual_link.data else None
# Turno
h = reservation.start_time.hour
if h < 13:
reservation.shift = 'Mañana'
elif h < 18:
reservation.shift = 'Tarde'
elif h < 21:
reservation.shift = 'Vespertino'
else:
reservation.shift = 'Noche'
db.session.commit()
flash(_('Reserva #%(id)d modificada exitosamente.', id=reservation.id), 'success')
return redirect(url_for('schedule.view_reservation', id=reservation.id))
except Exception as e:
db.session.rollback()
flash(f'Error al modificar reserva: {str(e)}', 'danger')
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
@schedule_bp.route('/calendar')
@login_required
def calendar_view():
@@ -518,6 +604,7 @@ def list_reservations():
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
floor_filter = request.args.get('floor', '').strip()
shift_filter = request.args.get('shift', '').strip()
classroom_id = request.args.get('classroom_id', type=int)
search = request.args.get('search', '').strip()
query = Reservation.query.options(
@@ -526,6 +613,9 @@ def list_reservations():
joinedload(Reservation.user)
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject)
if classroom_id:
query = query.filter(Reservation.classroom_id == classroom_id)
if status_filter and status_filter != 'all':
query = query.filter(Reservation.status == status_filter.upper())
@@ -575,6 +665,7 @@ def list_reservations():
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors]
all_classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
return render_template(
'schedule/list.html',
@@ -585,6 +676,8 @@ def list_reservations():
hide_virtual=hide_virtual,
floor_filter=floor_filter,
shift_filter=shift_filter,
classroom_id=classroom_id,
all_classrooms=all_classrooms,
search=search,
available_floors=available_floors
)