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('/') @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