490 lines
22 KiB
Python
490 lines
22 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 separation between Physical spaces and Virtual on-demand rooms"""
|
|
try:
|
|
from app.models.building import Building
|
|
from app.models.reservation import Reservation, ReservationStatus
|
|
from app.models.subject import Commission, Subject
|
|
import calendar
|
|
from datetime import date, timedelta
|
|
|
|
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).strip()
|
|
building_filter = request.args.get('building_id', '', type=str).strip()
|
|
floor_filter = request.args.get('floor', '', type=str).strip()
|
|
view_mode = request.args.get('view_mode', '').strip().lower()
|
|
|
|
# Identificar edificio virtual y aula virtual base
|
|
virtual_building = Building.query.filter(
|
|
db.or_(Building.code == 'VIRTUAL', Building.name.ilike('%Virtual%'))
|
|
).first()
|
|
virtual_bldg_id = virtual_building.id if virtual_building else None
|
|
|
|
# Si el usuario seleccionó "Campus Virtual" en el filtro de edificios, conmutar a pestaña virtual
|
|
if building_filter and str(building_filter) == str(virtual_bldg_id):
|
|
view_mode = 'virtual'
|
|
elif not view_mode:
|
|
view_mode = 'physical'
|
|
|
|
# Fechas operativas (Día, Semana, Mes)
|
|
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)
|
|
|
|
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')
|
|
}
|
|
|
|
# --- AULAS FÍSICAS (Presenciales) ---
|
|
physical_query = Classroom.query
|
|
if virtual_bldg_id:
|
|
physical_query = physical_query.filter(Classroom.building_id != virtual_bldg_id)
|
|
else:
|
|
physical_query = physical_query.filter(~Classroom.code.ilike('%VIRTUAL%'))
|
|
|
|
if active_only:
|
|
physical_query = physical_query.filter_by(is_active=True)
|
|
|
|
physical_count = physical_query.count()
|
|
|
|
if building_filter and building_filter.isdigit() and str(building_filter) != str(virtual_bldg_id):
|
|
physical_query = physical_query.filter(Classroom.building_id == int(building_filter))
|
|
|
|
if floor_filter:
|
|
flr = str(floor_filter).strip()
|
|
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
|
physical_query = physical_query.filter(db.or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
|
else:
|
|
physical_query = physical_query.filter(Classroom.floor == flr)
|
|
|
|
if search and view_mode == 'physical':
|
|
physical_query = physical_query.filter(
|
|
db.or_(
|
|
Classroom.building.ilike(f'%{search}%'),
|
|
Classroom.code.ilike(f'%{search}%'),
|
|
Classroom.description.ilike(f'%{search}%')
|
|
)
|
|
)
|
|
|
|
classrooms = physical_query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
|
|
page=page, per_page=20, error_out=False
|
|
)
|
|
|
|
# Edificios y Pisos estrictamente físicos para el filtro
|
|
buildings = Building.query.filter_by(is_active=True)
|
|
if virtual_bldg_id:
|
|
buildings = buildings.filter(Building.id != virtual_bldg_id)
|
|
buildings = buildings.order_by(Building.name.asc()).all()
|
|
|
|
floors = [f[0] for f in db.session.query(Classroom.floor).filter(
|
|
Classroom.is_active == True,
|
|
Classroom.building_id != virtual_bldg_id if virtual_bldg_id else True,
|
|
Classroom.floor.isnot(None)
|
|
).distinct().order_by(Classroom.floor.asc()).all()]
|
|
|
|
# Métricas de ocupación física
|
|
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 []
|
|
|
|
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]
|
|
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)))
|
|
|
|
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)))
|
|
|
|
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)))
|
|
|
|
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)
|
|
}
|
|
|
|
# --- AULAS VIRTUALES (Campus Virtual a Demanda - Infinitas) ---
|
|
vc = Classroom.query.filter(
|
|
Classroom.building_id == virtual_bldg_id if virtual_bldg_id else Classroom.code.ilike('%VIRTUAL%')
|
|
).first()
|
|
vc_id = vc.id if vc else 1
|
|
|
|
all_vres = Reservation.query.filter(Reservation.classroom_id == vc_id).all() if vc_id else []
|
|
|
|
# Agrupar las salas virtuales por comisión
|
|
comm_dict = {}
|
|
for r in all_vres:
|
|
c = r.commission
|
|
cid = c.id if c else 0
|
|
if cid not in comm_dict:
|
|
vlink = (c.virtual_link if c and c.virtual_link else None) or r.effective_virtual_link
|
|
comm_dict[cid] = {
|
|
'commission_id': cid,
|
|
'commission_code': c.code if c else 'General',
|
|
'subject_name': c.subject.name if (c and c.subject) else (r.purpose or 'Clase Virtual'),
|
|
'subject_code': c.subject.code if (c and c.subject) else '',
|
|
'career_name': c.subject.career.name if (c and c.subject and hasattr(c.subject, 'career') and c.subject.career) else '',
|
|
'max_students': c.max_students if c else (r.expected_attendees or 0),
|
|
'virtual_link': vlink,
|
|
'reservations': []
|
|
}
|
|
comm_dict[cid]['reservations'].append(r)
|
|
if not comm_dict[cid]['virtual_link'] and r.effective_virtual_link:
|
|
comm_dict[cid]['virtual_link'] = r.effective_virtual_link
|
|
|
|
virtual_rooms = []
|
|
for cid, data in comm_dict.items():
|
|
res_list = data['reservations']
|
|
day_cnt = sum(1 for r in res_list if today_start <= r.start_time <= today_end)
|
|
week_cnt = sum(1 for r in res_list if week_start <= r.start_time <= week_end)
|
|
month_cnt = sum(1 for r in res_list if month_start <= r.start_time <= month_end)
|
|
shift = res_list[0].shift if res_list else 'A Demanda'
|
|
|
|
if search and view_mode == 'virtual':
|
|
s_low = search.lower()
|
|
matches = (
|
|
s_low in data['subject_name'].lower() or
|
|
s_low in data['commission_code'].lower() or
|
|
s_low in data['subject_code'].lower() or
|
|
s_low in data['career_name'].lower()
|
|
)
|
|
if not matches:
|
|
continue
|
|
|
|
virtual_rooms.append({
|
|
'commission_id': data['commission_id'],
|
|
'commission_code': data['commission_code'],
|
|
'subject_name': data['subject_name'],
|
|
'subject_code': data['subject_code'],
|
|
'career_name': data['career_name'],
|
|
'max_students': data['max_students'],
|
|
'virtual_link': data['virtual_link'],
|
|
'shift': shift,
|
|
'day_count': day_cnt,
|
|
'week_count': week_cnt,
|
|
'month_count': month_cnt,
|
|
'total_count': len(res_list)
|
|
})
|
|
|
|
virtual_rooms.sort(key=lambda x: (x['subject_name'], x['commission_code']))
|
|
|
|
global_virtual_metrics = {
|
|
'today_count': sum(1 for r in all_vres if today_start <= r.start_time <= today_end),
|
|
'week_count': sum(1 for r in all_vres if week_start <= r.start_time <= week_end),
|
|
'month_count': sum(1 for r in all_vres if month_start <= r.start_time <= month_end),
|
|
'total_count': len(all_vres),
|
|
'commissions_count': len(comm_dict)
|
|
}
|
|
|
|
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,
|
|
view_mode=view_mode,
|
|
physical_count=physical_count,
|
|
virtual_count=len(comm_dict),
|
|
virtual_rooms=virtual_rooms,
|
|
virtual_classroom_id=vc_id,
|
|
global_virtual_metrics=global_virtual_metrics,
|
|
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 |