diff --git a/app/routes/classrooms.py b/app/routes/classrooms.py index 121090d..b2eaa70 100644 --- a/app/routes/classrooms.py +++ b/app/routes/classrooms.py @@ -89,49 +89,34 @@ def add_classroom(): @classrooms_bp.route('/list') @login_required def list_classrooms(): - """List all classrooms with Building and Floor filters""" + """List all classrooms with separation between Physical spaces and Virtual on-demand rooms""" 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) + 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()) @@ -143,8 +128,62 @@ def list_classrooms(): 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 + 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), @@ -153,10 +192,6 @@ def list_classrooms(): 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 @@ -164,20 +199,16 @@ def list_classrooms(): 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) + day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100))) - # 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) + week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100))) - # 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) + month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100))) occupancy_metrics[c.id] = { 'day_pct': day_pct, @@ -188,12 +219,77 @@ def list_classrooms(): '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') + # --- 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', @@ -204,6 +300,12 @@ def list_classrooms(): 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') diff --git a/app/templates/classrooms/list.html b/app/templates/classrooms/list.html index 00543c7..bb26330 100644 --- a/app/templates/classrooms/list.html +++ b/app/templates/classrooms/list.html @@ -1,17 +1,63 @@ {% extends "base.html" %} -{% block title %}{% trans %}Classrooms{% endtrans %} - Edu-Space Admin{% endblock %} +{% block title %}{% trans %}Aulas y Espacios{% endtrans %} - Edu-Space Admin{% endblock %} {% block extra_css %}