Files
admin-edu-space/app/routes/classrooms.py
T

388 lines
17 KiB
Python

from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from datetime import datetime, date
from app.models.classroom import Classroom
from app.models.user import User
from app.forms.classroom import ClassroomForm
from app import db
from sqlalchemy import text
classrooms_bp = Blueprint('classrooms', __name__)
@classrooms_bp.route('/add', methods=['GET', 'POST'])
@login_required
def add_classroom():
"""Add a new classroom with dynamic building creation and floor suggestion"""
from app.models.building import Building
import re
from sqlalchemy import func
form = ClassroomForm()
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
# Map of existing floors per building for datalist
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
if form.validate_on_submit():
try:
building = None
if form.building_id.data == '__new__':
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
if not new_building_name:
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
if not building:
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
building = Building(name=new_building_name, code=code_slug, is_active=True)
db.session.add(building)
db.session.commit()
else:
building = Building.query.get_or_404(int(form.building_id.data))
room_code = form.room_number.data.strip()
floor_val = str(form.floor.data).strip()
# Prevent duplicate room in same building & floor
existing_classroom = Classroom.query.filter_by(
building_id=building.id,
code=room_code,
floor=floor_val
).first()
if existing_classroom:
flash(f'Ya existe un aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
cap_val = 0
else:
cap_val = int(cap_raw)
classroom = Classroom(
building_id=building.id,
building=building.name,
code=room_code,
floor=floor_val,
capacity=cap_val,
description=form.description.data,
is_active=form.is_active.data
)
db.session.add(classroom)
db.session.commit()
flash(f'Aula {classroom.location_display} creada exitosamente.', 'success')
return redirect(url_for('classrooms.list_classrooms'))
except Exception as e:
db.session.rollback()
flash(f'Error creating classroom: {str(e)}', 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
@classrooms_bp.route('/', endpoint='index')
@classrooms_bp.route('/list')
@login_required
def list_classrooms():
"""List all classrooms with Building and Floor filters"""
try:
from app.models.building import Building
page = request.args.get('page', 1, type=int)
active_only = request.args.get('active_only', 'true').lower() == 'true'
search = request.args.get('search', '', type=str)
building_filter = request.args.get('building_id', '', type=str).strip()
floor_filter = request.args.get('floor', '', type=str).strip()
query = Classroom.query
if active_only:
query = query.filter_by(is_active=True)
if building_filter and building_filter.isdigit():
query = query.filter(Classroom.building_id == int(building_filter))
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(db.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(
db.or_(
Classroom.building.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Classroom.description.ilike(f'%{search}%')
)
)
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,
floors=floors,
building_filter=building_filter,
floor_filter=floor_filter,
active_only=active_only,
search=search,
occupancy_metrics=occupancy_metrics,
date_filters=date_filters,
title='Classrooms')
except Exception as e:
flash(f'Error loading classrooms: {str(e)}', 'error')
return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
@classrooms_bp.route('/<int:id>')
@login_required
def view_classroom(id):
"""View classroom details"""
try:
classroom = Classroom.query.get_or_404(id)
from app.models.reservation import Reservation, ReservationStatus
upcoming_reservations = Reservation.query.filter(
Reservation.classroom_id == id,
Reservation.start_time > datetime.utcnow(),
Reservation.status == ReservationStatus.CONFIRMED.value
).order_by(Reservation.start_time).limit(10).all()
return render_template('classrooms/view.html',
classroom=classroom,
upcoming_reservations=upcoming_reservations,
title=f'Classroom {classroom.code_display}')
except Exception as e:
flash(f'Error loading classroom: {str(e)}', 'error')
return redirect(url_for('classrooms.list_classrooms'))
@classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_classroom(id):
"""Edit classroom with decoupled building and floor"""
from app.models.building import Building
import re
from sqlalchemy import func
classroom = Classroom.query.get_or_404(id)
form = ClassroomForm(obj=classroom)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
if request.method == 'GET':
form.building_id.data = str(classroom.building_id or '')
form.room_number.data = classroom.code
form.floor.data = classroom.floor
if classroom.capacity == 0 or classroom.is_virtual:
form.capacity.data = '∞'
else:
form.capacity.data = str(classroom.capacity)
if form.validate_on_submit():
try:
building = None
if form.building_id.data == '__new__':
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
if not new_building_name:
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
if not building:
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
building = Building(name=new_building_name, code=code_slug, is_active=True)
db.session.add(building)
db.session.commit()
else:
building = Building.query.get_or_404(int(form.building_id.data))
room_code = form.room_number.data.strip()
floor_val = str(form.floor.data).strip()
# Check duplicate excluding current
existing_classroom = Classroom.query.filter(
Classroom.building_id == building.id,
Classroom.code == room_code,
Classroom.floor == floor_val,
Classroom.id != id
).first()
if existing_classroom:
flash(f'Ya existe otra aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
cap_val = 0
else:
cap_val = int(cap_raw)
classroom.building_id = building.id
classroom.building = building.name
classroom.code = room_code
classroom.floor = floor_val
classroom.capacity = cap_val
classroom.description = form.description.data
classroom.is_active = form.is_active.data
classroom.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Aula {classroom.location_display} actualizada correctamente.', 'success')
return redirect(url_for('classrooms.view_classroom', id=classroom.id))
except Exception as e:
db.session.rollback()
flash(f'Error updating classroom: {str(e)}', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
@classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
@login_required
def toggle_classroom(id):
"""Toggle classroom active status"""
classroom = Classroom.query.get_or_404(id)
try:
classroom.is_active = not classroom.is_active
classroom.updated_at = datetime.utcnow()
db.session.commit()
status = 'activated' if classroom.is_active else 'deactivated'
flash(f'Classroom {classroom.code_display} has been {status} successfully!', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error updating classroom status: {str(e)}', 'error')
return redirect(request.referrer or url_for('classrooms.list_classrooms'))
# API endpoints for AJAX requests
@classrooms_bp.route('/api/available')
@login_required
def get_available_classrooms():
"""Get available classrooms for a specific time slot"""
start_time = request.args.get('start_time')
end_time = request.args.get('end_time')
if not start_time or not end_time:
return jsonify({'error': 'Start time and end time are required'}), 400
try:
from datetime import datetime
from app.models.reservation import Reservation, ReservationStatus
start_dt = datetime.fromisoformat(start_time)
end_dt = datetime.fromisoformat(end_time)
# Find all classrooms
all_classrooms = Classroom.query.filter_by(is_active=True).all()
available_classrooms = []
for classroom in all_classrooms:
# Check if classroom has conflicting reservations
conflicts = Reservation.query.filter(
Reservation.classroom_id == classroom.id,
Reservation.status == ReservationStatus.CONFIRMED.value,
Reservation.start_time < end_dt,
Reservation.end_time > start_dt
).all()
if not conflicts:
available_classrooms.append({
'id': classroom.id,
'code': classroom.code,
'building': classroom.building,
'room_number': classroom.code,
'floor': classroom.floor,
'capacity': classroom.capacity,
'code_display': classroom.code_display
})
return jsonify({'classrooms': available_classrooms})
except Exception as e:
return jsonify({'error': str(e)}), 500