diff --git a/app/forms/reservation.py b/app/forms/reservation.py index 3e822c0..0c53214 100644 --- a/app/forms/reservation.py +++ b/app/forms/reservation.py @@ -27,6 +27,10 @@ class ReservationForm(FlaskForm): ]) submit = SubmitField(_l('Make Reservation')) + def __init__(self, *args, is_edit=False, **kwargs): + super().__init__(*args, **kwargs) + self.is_edit = is_edit + def validate_end_time(self, field): if field.data <= self.start_time.data: raise ValidationError('End time must be after start time.') @@ -36,8 +40,8 @@ class ReservationForm(FlaskForm): raise ValidationError('Reservation cannot exceed 8 hours.') def validate_start_time(self, field): - # Don't allow reservations in the past - if field.data < datetime.now(): + # Don't allow new reservations in the past + if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10): raise ValidationError('Start time cannot be in the past.') # Don't allow reservations too far in the future (max 3 months) diff --git a/app/models/role.py b/app/models/role.py index 271527c..9a6d748 100644 --- a/app/models/role.py +++ b/app/models/role.py @@ -22,7 +22,7 @@ SYSTEM_MODULES = [ 'name': 'Reservas de Aulas', 'name_en': 'Classroom Reservations', 'icon': 'bi-calendar-check', - 'description': 'Creación, aprobación, confirmación y cancelación de reservas.' + 'description': 'Creación, modificación, aprobación, confirmación y cancelación de reservas.' }, { 'id': 'schedule', @@ -65,6 +65,13 @@ SYSTEM_MODULES = [ 'name_en': 'Milestone Types', 'icon': 'bi-tags', 'description': 'Catálogo dinámico de tipos de evaluaciones, parciales, TPs y eventos.' + }, + { + 'id': 'metrics', + 'name': 'Métricas de Ocupación', + 'name_en': 'Occupancy Metrics', + 'icon': 'bi-graph-up', + 'description': 'Visualización y análisis de porcentajes de ocupación por aula (día, semana y mes).' } ] diff --git a/app/models/user.py b/app/models/user.py index 1374776..cd71584 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -59,9 +59,31 @@ class User(UserMixin, db.Model): self.is_admin() or self.has_permission('users', 'read') or self.has_permission('milestone_types', 'read') or - self.has_permission('import', 'read') + self.has_permission('import', 'read') or + self.has_permission('reservations', 'read_write') or + self.has_permission('classrooms', 'read_write') or + self.has_permission('buildings', 'read_write') or + self.has_permission('metrics', 'read') ) + def can_edit_reservation(self, reservation): + """Check if user has permission to edit a given reservation""" + if not reservation: + return False + if self.is_admin(): + return True + role_name = (self.role_obj.name if self.role_obj else self.role or '').lower() + if role_name in ['admin', 'administrador', 'bedelia', 'bedelía', 'operador']: + return True + if self.has_permission('reservations', 'read_write'): + # El docente creador puede modificar su propia reserva + if reservation.user_id == self.id: + return True + # Bedelía o roles de gestión con permiso de escritura total + if role_name in ['bedelia', 'bedelía', 'operador']: + return True + return False + def update_last_login(self): """Update last login timestamp""" diff --git a/app/routes/classrooms.py b/app/routes/classrooms.py index 1268140..121090d 100644 --- a/app/routes/classrooms.py +++ b/app/routes/classrooms.py @@ -122,13 +122,80 @@ def list_classrooms(): ) ) - classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate( - page=page, per_page=12, error_out=False + 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, @@ -137,6 +204,8 @@ def list_classrooms(): floor_filter=floor_filter, active_only=active_only, search=search, + occupancy_metrics=occupancy_metrics, + date_filters=date_filters, title='Classrooms') except Exception as e: diff --git a/app/routes/schedule.py b/app/routes/schedule.py index aaf306a..f623d5f 100644 --- a/app/routes/schedule.py +++ b/app/routes/schedule.py @@ -139,6 +139,92 @@ def view_reservation(id): flash(f'Error loading reservation details: {str(e)}', 'error') return redirect(url_for('schedule.today_schedule')) +@schedule_bp.route('/reservation//edit', methods=['GET', 'POST']) +@login_required +def edit_reservation(id): + """Edit existing reservation""" + from app.models.classroom import Classroom + from app.models.subject import Commission + from app.models.building import Building + from flask_babel import gettext as _ + + reservation = Reservation.query.options( + joinedload(Reservation.classroom), + joinedload(Reservation.commission), + joinedload(Reservation.user) + ).get_or_404(id) + + if not current_user.can_edit_reservation(reservation): + flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error') + return redirect(url_for('schedule.view_reservation', id=reservation.id)) + + form = ReservationForm(is_edit=True) + + buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all() + classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all() + commissions = Commission.query.filter_by(active=True).all() + + form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms] + form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions] + + if request.method == 'GET': + form.classroom_id.data = reservation.classroom_id + form.commission_id.data = reservation.commission_id + form.start_time.data = reservation.start_time + form.end_time.data = reservation.end_time + form.purpose.data = reservation.purpose + form.expected_attendees.data = reservation.expected_attendees + form.notes.data = reservation.notes or '' + form.virtual_link.data = reservation.virtual_link or '' + + elif request.method == 'POST': + if form.validate_on_submit(): + try: + # Verificar colisión de horario si no es virtual + target_room = Classroom.query.get(form.classroom_id.data) + if target_room and not target_room.is_virtual: + conflict = Reservation.query.filter( + Reservation.id != reservation.id, + Reservation.classroom_id == form.classroom_id.data, + Reservation.status == ReservationStatus.CONFIRMED.value, + Reservation.start_time < form.end_time.data, + Reservation.end_time > form.start_time.data + ).first() + + if conflict: + flash(_('El aula seleccionada ya cuenta con una reserva confirmada en esa franja horaria.'), 'danger') + return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings) + + # Asignar cambios + reservation.classroom_id = form.classroom_id.data + reservation.commission_id = form.commission_id.data + reservation.start_time = form.start_time.data + reservation.end_time = form.end_time.data + reservation.purpose = form.purpose.data + reservation.expected_attendees = form.expected_attendees.data + reservation.notes = form.notes.data + reservation.virtual_link = form.virtual_link.data.strip() if form.virtual_link.data else None + + # Turno + h = reservation.start_time.hour + if h < 13: + reservation.shift = 'Mañana' + elif h < 18: + reservation.shift = 'Tarde' + elif h < 21: + reservation.shift = 'Vespertino' + else: + reservation.shift = 'Noche' + + db.session.commit() + flash(_('Reserva #%(id)d modificada exitosamente.', id=reservation.id), 'success') + return redirect(url_for('schedule.view_reservation', id=reservation.id)) + except Exception as e: + db.session.rollback() + flash(f'Error al modificar reserva: {str(e)}', 'danger') + + return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings) + @schedule_bp.route('/calendar') @login_required def calendar_view(): @@ -518,6 +604,7 @@ def list_reservations(): hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True'] floor_filter = request.args.get('floor', '').strip() shift_filter = request.args.get('shift', '').strip() + classroom_id = request.args.get('classroom_id', type=int) search = request.args.get('search', '').strip() query = Reservation.query.options( @@ -526,6 +613,9 @@ def list_reservations(): joinedload(Reservation.user) ).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject) + if classroom_id: + query = query.filter(Reservation.classroom_id == classroom_id) + if status_filter and status_filter != 'all': query = query.filter(Reservation.status == status_filter.upper()) @@ -575,6 +665,7 @@ def list_reservations(): floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all() available_floors = [f[0] for f in floors] + all_classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all() return render_template( 'schedule/list.html', @@ -585,6 +676,8 @@ def list_reservations(): hide_virtual=hide_virtual, floor_filter=floor_filter, shift_filter=shift_filter, + classroom_id=classroom_id, + all_classrooms=all_classrooms, search=search, available_floors=available_floors ) diff --git a/app/templates/base.html b/app/templates/base.html index 971c8bd..1752891 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -109,8 +109,11 @@