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), 'reservations': sorted(res_list, key=lambda x: x.start_time) }) 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('/virtual-room//update-link', methods=['POST']) @login_required def update_virtual_link(commission_id): """Actualizar enlace de videollamada para una comisión virtual""" from app.models.subject import Commission from flask_babel import gettext as _ comm = Commission.query.get_or_404(commission_id) new_link = request.form.get('virtual_link', '').strip() or None comm.virtual_link = new_link db.session.commit() flash(_('Enlace de videollamada para %(code)s actualizado correctamente.', code=comm.get_full_code()), 'success') return redirect(request.referrer or url_for('classrooms.list_classrooms', view_mode='virtual')) @classrooms_bp.route('/') @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('//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('//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