From 6ccc9e8c87e69d82ac21596969b4944762416c7a Mon Sep 17 00:00:00 2001 From: Carlos Tello Date: Thu, 3 Sep 2026 05:18:01 -0300 Subject: [PATCH] feat: refactorizar espacios fisicos desacoplando Edificio, Piso y Aula con selectores en cascada y filtros --- CHANGELOG_SESSION.md | 27 ++ app/forms/classroom.py | 22 +- app/models/classroom.py | 65 +++- app/routes/classrooms.py | 191 ++++++---- app/routes/schedule.py | 51 ++- app/templates/classrooms/add.html | 292 +++++++++------- app/templates/classrooms/edit.html | 253 +++++++++----- app/templates/classrooms/list.html | 90 +++-- app/templates/schedule/add.html | 497 +++++++++++++++++++-------- app/templates/schedule/calendar.html | 71 +++- app/templates/schedule/view.html | 56 +-- 11 files changed, 1093 insertions(+), 522 deletions(-) diff --git a/CHANGELOG_SESSION.md b/CHANGELOG_SESSION.md index 5411562..f23f3e2 100644 --- a/CHANGELOG_SESSION.md +++ b/CHANGELOG_SESSION.md @@ -319,6 +319,33 @@ * **Formato de Códigos de Comisión ([app/models/subject.py](file:///c:/Workspace/admin-edu-space/app/models/subject.py)):** * Se optimizó `Commission.get_full_code()` para no repetir el año cuando ya viene incluido en el string del semestre (ej: `2026-2`). +--- + +## 20. Refactorización Integral de Espacios Físicos: Desacoplamiento de Edificio, Piso y Aula +* **Modelo de Datos y Base de Datos ([app/models/classroom.py](file:///c:/Workspace/admin-edu-space/app/models/classroom.py), [app/models/building.py](file:///c:/Workspace/admin-edu-space/app/models/building.py)):** + * **Desacoplamiento de Edificio:** Se reforzó la clave foránea `Classroom.building_id` vinculada a `buildings.id`. El nombre del edificio se obtiene directamente a través de `building_entity` manteniendo retrocompatibilidad. + * **Pisos Flexibles (Alfanuméricos):** Se migró la columna `classrooms.floor` a `VARCHAR(50)`, permitiendo registrar tanto valores numéricos como designaciones textuales ("PB", "Planta Baja", "1", "2°P", "Subsuelo"). + * **Propiedades de Formato:** Se incorporaron `floor_display` ("Planta Baja", "Piso X"), `is_virtual` y `location_display` (`Edificio · Piso · Aula`). + * **Rutina de Migración:** Se verificó la integridad referencial garantizando que todas las aulas existentes queden asociadas a su `building_id` correspondiente en PostgreSQL. +* **Formulario y Flujo Dinámico de Creación/Edición de Aulas ([app/forms/classroom.py](file:///c:/Workspace/admin-edu-space/app/forms/classroom.py), [app/routes/classrooms.py](file:///c:/Workspace/admin-edu-space/app/routes/classrooms.py), [app/templates/classrooms/add.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/add.html), [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html)):** + * **Selector de Edificio con Alta Dinámica:** Desplegable con las sedes activas más la opción destacada `+ Crear nuevo edificio`. Al seleccionarla, despliega de forma inmediata un campo de texto para escribir el nuevo edificio sin abandonar la pantalla. + * **Sugerencia de Pisos:** Campo con datalist interactivo que sugiere los pisos ya cargados en la sede seleccionada o permite tipear uno nuevo libremente. + * **Validación de Unicidad:** Se valida tanto en cliente como en servidor que no existan aulas duplicadas dentro del mismo edificio y piso. +* **Selectores en Cascada para Reservas ([app/templates/schedule/add.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/add.html), [app/routes/schedule.py](file:///c:/Workspace/admin-edu-space/app/routes/schedule.py)):** + * **Flujo Dependiente:** + 1. `Edificio / Sede`: Selección de sede física o modalidad `Campus Virtual`. + 2. `Piso / Nivel`: Se puebla dinámicamente con los pisos disponibles de esa sede (se deshabilita automáticamente si es Virtual). + 3. `Aula / Espacio`: Filtra y muestra únicamente las aulas del edificio y piso seleccionados con su capacidad. + * **Resumen Desglosado con Badges:** + * 🏢 **Edificio / Sede:** Muestra el nombre limpio de la sede. + * 🪜 **Piso / Nivel:** Muestra la planta o "Remoto (Virtual)". + * 🚪 **Aula Seleccionada:** Muestra el número de aula y su capacidad. +* **Filtros en Listado de Aulas y Calendario ([app/templates/classrooms/list.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/list.html), [app/templates/schedule/calendar.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/calendar.html), [app/templates/schedule/view.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/view.html)):** + * **Listado de Aulas:** Incorporación de dropdowns independientes para filtrar por **Edificio** y **Piso** en la barra superior. Tarjetas rediseñadas con badges distintivos de sede y piso. + * **Calendario Semanal:** Agregado el filtro **Filtrar por Edificio** en la barra superior junto al filtro de Piso. + * **Modal Estilo Google Calendar:** La sección de espacio físico desglosa limpiamente en badges: 🏢 Edificio, 🪜 Piso y 🚪 Aula. + + diff --git a/app/forms/classroom.py b/app/forms/classroom.py index f905194..fe81eca 100644 --- a/app/forms/classroom.py +++ b/app/forms/classroom.py @@ -19,27 +19,25 @@ def validate_capacity(form, field): raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.')) class ClassroomForm(FlaskForm): - building = StringField(_l('Building'), validators=[ - DataRequired(), - Length(min=2, max=100) - ]) - room_number = StringField(_l('Room Number'), validators=[ + building_id = SelectField(_l('Edificio'), validators=[DataRequired()]) + new_building_name = StringField(_l('Nombre del Nuevo Edificio'), validators=[Optional(), Length(max=100)]) + room_number = StringField(_l('Número / Nombre del Aula'), validators=[ DataRequired(), Length(min=1, max=50) ]) - floor = IntegerField(_l('Floor'), validators=[ - InputRequired(message=_l('El piso es obligatorio (0 para Planta Baja)')), - NumberRange(min=0, max=50, message=_l('El piso debe estar entre 0 (Planta Baja) y 50')) + floor = StringField(_l('Piso'), validators=[ + DataRequired(message=_l('El piso es obligatorio (ej: PB, 1, 2, 4°P)')), + Length(min=1, max=50) ]) - capacity = StringField(_l('Capacity'), validators=[ + capacity = StringField(_l('Capacidad'), validators=[ validate_capacity ]) - description = TextAreaField(_l('Description'), validators=[ + description = TextAreaField(_l('Descripción'), validators=[ Optional(), Length(max=500) ]) - is_active = BooleanField(_l('Active'), default=True) - submit = SubmitField(_l('Save Classroom')) + is_active = BooleanField(_l('Activa'), default=True) + submit = SubmitField(_l('Guardar Aula')) class ClassroomResourceForm(FlaskForm): resource_type = SelectField(_l('Resource Type'), choices=[ diff --git a/app/models/classroom.py b/app/models/classroom.py index f3c0d46..1d69e9c 100644 --- a/app/models/classroom.py +++ b/app/models/classroom.py @@ -9,23 +9,57 @@ class Classroom(db.Model): building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True) building = db.Column(db.String(100), nullable=False) code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB - floor = db.Column(db.Integer, nullable=False) + floor = db.Column(db.String(50), nullable=False, default='1') capacity = db.Column(db.Integer, nullable=False) description = db.Column(db.Text) is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relación con Building (mapeada vía backref 'building_entity' en Building.classrooms) + @property + def building_rel(self): + return self.building_entity + + @property + def building_name(self): + """Nombre del edificio desacoplado""" + if self.building_rel: + return self.building_rel.name + return self.building or 'Edificio Central' + + @property + def floor_display(self): + """Formato amigable para el piso: PB, Planta Baja, Piso X""" + f = str(self.floor).strip() if self.floor is not None else '' + if f in ['0', 'PB', 'pb', 'Planta Baja', 'planta baja']: + return 'Planta Baja' + if f.lower().startswith('piso') or '°' in f: + return f + return f"Piso {f}" + + @property + def is_virtual(self): + """Indica si el aula es virtual""" + return (self.code and 'VIRTUAL' in self.code.upper()) or (self.building and 'VIRTUAL' in self.building.upper()) + + @property + def location_display(self): + """Ubicación completa y limpia: Edificio · Piso · Aula""" + if self.is_virtual: + return '🌐 Campus Virtual' + return f"{self.building_name} · {self.floor_display} · {self.code}" # Virtual properties for compatibility @property def name(self): """Generate name from building and code""" - return f"{self.building}-{self.code}" + return f"{self.building_name}-{self.code}" @property def campus_location(self): - """Placeholder for campus location - can be expanded when campus model is implemented""" - return None + """Placeholder for campus location""" + return self.building_rel.address if self.building_rel else None # Virtual field for compatibility - combines building and code @property @@ -39,28 +73,34 @@ class Classroom(db.Model): # Override code property for consistency @property def code_display(self): - return f"{self.building}-{self.code}" + return f"{self.building_name} - {self.code}" @property def capacity_display(self): """Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero.""" - if self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper()): + if self.capacity == 0 or self.is_virtual: return '∞' return str(self.capacity) @property def is_unlimited(self): - return self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper()) + return self.capacity == 0 or self.is_virtual def to_dict(self): """Convert classroom to dictionary""" return { 'id': self.id, - 'code': self.code_display, - 'building': self.building, + 'building_id': self.building_id, + 'building': self.building_name, + 'code': self.code, + 'code_display': self.code_display, 'name': self.name, 'floor': self.floor, + 'floor_display': self.floor_display, + 'location_display': self.location_display, 'capacity': self.capacity, + 'capacity_display': self.capacity_display, + 'is_virtual': self.is_virtual, 'description': self.description, 'campus_location': self.campus_location, 'is_active': self.is_active, @@ -71,10 +111,9 @@ class Classroom(db.Model): def get_full_location(self): """Get full classroom location""" - location = f"{self.building} - Floor {self.floor}" - if self.campus_location: - location += f" ({self.campus_location})" - return location + if self.is_virtual: + return 'Campus Virtual (Remoto)' + return f"{self.building_name} - {self.floor_display}" @staticmethod def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None): diff --git a/app/routes/classrooms.py b/app/routes/classrooms.py index 667355a..b78eff3 100644 --- a/app/routes/classrooms.py +++ b/app/routes/classrooms.py @@ -12,69 +12,103 @@ classrooms_bp = Blueprint('classrooms', __name__) @classrooms_bp.route('/add', methods=['GET', 'POST']) @login_required def add_classroom(): - """Add a new 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')] - # TODO: Update when campus model is implemented - # form.campus_id.choices = [(1, 'Main Campus')] - + # 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=form.building.data, - code=form.room_number.data, - floor=form.floor.data + building_id=building.id, + code=room_code, + floor=floor_val ).first() - + if existing_classroom: - flash('Classroom with this building, room number, and floor already exists.', 'error') - return render_template('classrooms/add.html', form=form, title='Add 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( - # campus_id=form.campus_id.data, - building=form.building.data, - code=form.room_number.data, - floor=form.floor.data, + 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'Classroom {classroom.code_display} has been created successfully!', 'success') + + 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, title='Add Classroom') + + 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""" + """List all classrooms with Building and Floor filters""" try: - # Get filters + 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) - - # Build query + 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: + query = query.filter(Classroom.floor == floor_filter) + if search: query = query.filter( db.or_( @@ -83,102 +117,135 @@ def list_classrooms(): Classroom.description.ilike(f'%{search}%') ) ) - - # Paginate results + classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate( page=page, per_page=12, 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()] + 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, 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) - - # Get upcoming reservations for this classroom 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""" + """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': - if classroom.capacity == 0 or (classroom.code and 'VIRTUAL' in classroom.code.upper()): + 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) - - # TODO: Update when campus model is implemented - # form.campus_id.choices = [(1, 'Main Campus')] - + if form.validate_on_submit(): try: - # Check for duplicates (excluding current classroom) + 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 == form.building.data, - Classroom.code == form.room_number.data, - Classroom.floor == form.floor.data, + Classroom.building_id == building.id, + Classroom.code == room_code, + Classroom.floor == floor_val, Classroom.id != id ).first() - + if existing_classroom: - flash('Classroom with this building, room number, and floor already exists.', 'error') - return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit 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) - # Update classroom - classroom.building = form.building.data - classroom.code = form.room_number.data - classroom.floor = form.floor.data + 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'Classroom {classroom.code_display} has been updated successfully!', 'success') + + 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, title='Edit Classroom') + + 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 diff --git a/app/routes/schedule.py b/app/routes/schedule.py index 9af9417..4825436 100644 --- a/app/routes/schedule.py +++ b/app/routes/schedule.py @@ -40,8 +40,8 @@ def today_schedule(): ~Classroom.building.ilike('%Virtual%') ) - if floor_filter and floor_filter.isdigit(): - query = query.filter(Classroom.floor == int(floor_filter)) + if floor_filter: + query = query.filter(Classroom.floor == str(floor_filter).strip()) if shift_filter: query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%')) @@ -140,9 +140,11 @@ def view_reservation(id): def calendar_view(): """Show calendar view with classroom, subject, career, shift, floor and virtual filtering""" from app.models.career import Career + from app.models.building import Building classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all() subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all() careers = Career.query.order_by(Career.name.asc()).all() + buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all() 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] @@ -154,6 +156,7 @@ def calendar_view(): classrooms=classrooms, subjects=subjects, careers=careers, + buildings=buildings, available_floors=available_floors ) @@ -400,23 +403,23 @@ def add_reservation(): """Add new reservation""" from app.models.classroom import Classroom from app.models.subject import Commission + from app.models.building import Building form = ReservationForm() - # Get active classrooms for the dropdown - classrooms = Classroom.query.filter_by(is_active=True).all() - - # Get active commissions for the dropdown + # Get active buildings and classrooms + 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() # Populate form choices - form.classroom_id.choices = [(c.id, f'{c.building} - {c.code} (Cap: {c.capacity_display})') for c in classrooms] + 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 == 'POST': if form.validate_on_submit(): try: -# Check for existing reservations at the same time + # Check for existing reservations at the same time from app.models.reservation import ReservationStatus existing_reservation = Reservation.query.filter_by( classroom_id=form.classroom_id.data, @@ -428,7 +431,7 @@ def add_reservation(): if existing_reservation: flash('This classroom is already booked for the selected time slot.', 'danger') - return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions) + return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings) # Create new reservation reservation = Reservation( @@ -451,10 +454,9 @@ def add_reservation(): except Exception as e: db.session.rollback() - current_app.logger.error(f"Error creating reservation: {str(e)}") flash(f'Error creating reservation: {str(e)}', 'danger') - return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions) + return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings) @schedule_bp.route('/list') @login_required @@ -498,8 +500,8 @@ def list_reservations(): ~Classroom.building.ilike('%Virtual%') ) - if floor_filter and floor_filter.isdigit(): - query = query.filter(Classroom.floor == int(floor_filter)) + if floor_filter: + query = query.filter(Classroom.floor == str(floor_filter).strip()) if shift_filter: query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%')) @@ -666,7 +668,6 @@ def calendar_data(): search = request.args.get('search', '').strip() if start and end: - from datetime import datetime # Clean up ISO timezone strings for PostgreSQL naive timestamp comparison start_clean = start.replace('Z', '+00:00') end_clean = end.replace('Z', '+00:00') @@ -695,6 +696,16 @@ def calendar_data(): if career_id and career_id.isdigit(): query = query.filter(Subject.career_id == int(career_id)) + building = request.args.get('building', '').strip() + if building: + from app.models.building import Building + query = query.filter( + or_( + Classroom.building.ilike(f'%{building}%'), + Classroom.building_entity.has(Building.name.ilike(f'%{building}%')) + ) + ) + if shift: query = query.filter(Reservation.shift.ilike(f'%{shift}%')) @@ -704,8 +715,8 @@ def calendar_data(): ~Classroom.building.ilike('%Virtual%') ) - if floor and floor.isdigit(): - query = query.filter(Classroom.floor == int(floor)) + if floor: + query = query.filter(Classroom.floor == str(floor).strip()) if search: query = query.filter( @@ -725,8 +736,9 @@ def calendar_data(): subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '') subject_name = subject.name if subject else (reservation.purpose or 'Clase') classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula' - classroom_floor = reservation.classroom.floor if reservation.classroom else None - is_virtual = bool(reservation.classroom and ('VIRTUAL' in (reservation.classroom.code or '').upper() or 'VIRTUAL' in (reservation.classroom.building or '').upper())) + classroom_floor = reservation.classroom.floor_display if reservation.classroom else None + classroom_building = reservation.classroom.building_name if reservation.classroom else '' + is_virtual = bool(reservation.classroom and reservation.classroom.is_virtual) career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '') status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status) @@ -741,9 +753,10 @@ def calendar_data(): 'subject_code': subject_code, 'subject_name': subject_name, 'classroom': classroom_name, - 'building': reservation.classroom.building if reservation.classroom else '', + 'building': classroom_building, 'floor': classroom_floor, 'is_virtual': is_virtual, + 'location_display': reservation.classroom.location_display if reservation.classroom else '', 'career': career_name, 'commission': reservation.commission.code if reservation.commission else '', 'shift': reservation.shift or '', diff --git a/app/templates/classrooms/add.html b/app/templates/classrooms/add.html index 3c79f29..71d9ed5 100644 --- a/app/templates/classrooms/add.html +++ b/app/templates/classrooms/add.html @@ -7,11 +7,19 @@
-

- {% trans %}Add Classroom{% endtrans %} -

+
+ +

+ {% trans %}Add Classroom{% endtrans %} +

+
- {% trans %}Back to Classrooms{% endtrans %} + {% trans %}Back to Classrooms{% endtrans %}
@@ -22,59 +30,81 @@
- {% trans %}Classroom Information{% endtrans %} + {% trans %}Classroom Information{% endtrans %}
-
+
{{ form.hidden_tag() }} -
-
- {{ form.building.label(class="form-label") }} - {{ form.building(class="form-control", placeholder="A") }} - {% if form.building.errors %} +
+ +
+ + {{ form.building_id(class="form-select", id="building_id") }} + {% if form.building_id.errors %}
- {{ form.building.errors[0] }} + {{ form.building_id.errors[0] }}
{% endif %} + + +
- -
- {{ form.room_number.label(class="form-label") }} - {{ form.room_number(class="form-control", placeholder="101") }} - {% if form.room_number.errors %} -
- {{ form.room_number.errors[0] }} -
- {% endif %} -
- -
- {{ form.floor.label(class="form-label") }} - {{ form.floor(class="form-control") }} + + +
+ + {{ form.floor(class="form-control", id="floor", list="floorSuggestions", placeholder="ej. PB, 1, 2, 4°P") }} + + + +
{% trans %}Sugerido o nuevo{% endtrans %}
{% if form.floor.errors %}
{{ form.floor.errors[0] }}
{% endif %}
+ + +
+ + {{ form.room_number(class="form-control", id="room_number", placeholder="ej. Aula 304, Lab 1") }} + {% if form.room_number.errors %} +
+ {{ form.room_number.errors[0] }} +
+ {% endif %} +
-
-
+
+
- {{ form.capacity.label(class="form-label mb-0") }} +
- {{ form.capacity(class="form-control", id="capacity", placeholder="ej. 30 o ∞") }} + {{ form.capacity(class="form-control", id="capacity", placeholder="ej. 35 o ∞") }}
- {% trans %}Para aulas virtuales o sin límite de aforo, ingrese ∞ o 0 (Capacidad ilimitada).{% endtrans %} + {% trans %}Para aulas virtuales o sin límite de aforo, ingrese ∞ o 0.{% endtrans %}
{% if form.capacity.errors %}
@@ -83,18 +113,17 @@ {% endif %}
-
- -
- {{ form.is_active(class="form-check-input") }} - {{ form.is_active.label(class="form-check-label") }} +
+
+ {{ form.is_active(class="form-check-input", id="is_active") }} +
- {{ form.description.label(class="form-label") }} - {{ form.description(class="form-control", rows="3", placeholder=_("Optional: Add any additional details about this classroom...")) }} + {{ form.description.label(class="form-label fw-semibold") }} + {{ form.description(class="form-control", rows="3", placeholder=_("Opcional: Detalles adicionales de equipamiento, tomas de corriente, proyector...")) }} {% if form.description.errors %}
{{ form.description.errors[0] }} @@ -102,27 +131,36 @@ {% endif %}
- -
-
- {% trans %}Preview{% endtrans %} -
-
-

{% trans %}Fill in the form to see a preview of the classroom code:{% endtrans %} - - -

-

{% trans %}Full location:{% endtrans %} - - -

+ +
+
+
+ {% trans %}Vista Previa de Ubicación{% endtrans %} +
+
+
+ 🏢 {% trans %}Edificio{% endtrans %} + - +
+
+ 🪜 {% trans %}Piso{% endtrans %} + - +
+
+ 🚪 {% trans %}Aula / Código{% endtrans %} + - +
+
+
- {% trans %}Cancel{% endtrans %} + {% trans %}Cancelar{% endtrans %} -
@@ -130,89 +168,89 @@
- - -
-
-
-
-
- {% trans %}Resources (Optional){% endtrans %} -
-
-
-

{% trans %}You can add classroom resources after creating the classroom.{% endtrans %}

-
-
-
-
{% endblock %} {% block extra_js %} {% endblock %} \ No newline at end of file diff --git a/app/templates/classrooms/edit.html b/app/templates/classrooms/edit.html index e7e9f0d..c301a50 100644 --- a/app/templates/classrooms/edit.html +++ b/app/templates/classrooms/edit.html @@ -1,32 +1,36 @@ {% extends "base.html" %} -{% block title %}{% trans %}Edit Classroom{% endtrans %} - {{ classroom.code_display }} - Edu-Space Admin{% endblock %} +{% block title %}{% trans %}Edit Classroom{% endtrans %} - {{ classroom.code_display }}{% endblock %} {% block content %}
-
-
-
-
-

- {% trans %}Edit Classroom{% endtrans %} -

-

{{ classroom.building }} — {{ classroom.code }}

-
- -
+ +
+
+ +

+ {% trans %}Edit Classroom{% endtrans %} +

+

{{ classroom.location_display }}

+
+
-
-
+
+
@@ -38,34 +42,56 @@
{{ form.hidden_tag() }} -
-
- {{ form.building.label(class="form-label fw-semibold") }} - {{ form.building(class="form-control" + (" is-invalid" if form.building.errors else ""), placeholder="Edificio Central") }} - {% if form.building.errors %} -
{{ form.building.errors[0] }}
+
+ +
+ + {{ form.building_id(class="form-select", id="building_id") }} + {% if form.building_id.errors %} +
{{ form.building_id.errors[0] }}
{% endif %} + +
-
- {{ form.room_number.label(class="form-label fw-semibold") }} - {{ form.room_number(class="form-control" + (" is-invalid" if form.room_number.errors else ""), placeholder="402") }} - {% if form.room_number.errors %} -
{{ form.room_number.errors[0] }}
- {% endif %} -
- -
- {{ form.floor.label(class="form-label fw-semibold") }} - {{ form.floor(class="form-control" + (" is-invalid" if form.floor.errors else "")) }} + +
+ + {{ form.floor(class="form-control", id="floor", list="floorSuggestions", placeholder="ej. PB, 1, 2, 4°P") }} + + + +
{% trans %}Sugerido o nuevo{% endtrans %}
{% if form.floor.errors %} -
{{ form.floor.errors[0] }}
+
{{ form.floor.errors[0] }}
{% endif %}
- + + +
+ + {{ form.room_number(class="form-control", id="room_number", placeholder="ej. Aula 304, Lab 1") }} + {% if form.room_number.errors %} +
{{ form.room_number.errors[0] }}
+ {% endif %} +
+
+ +
- {{ form.capacity.label(class="form-label fw-semibold mb-0") }} + @@ -75,43 +101,55 @@
- {% trans %}Para aulas virtuales o eventos sin aforo restringido, ingrese ∞ o 0 (Capacidad ilimitada).{% endtrans %} + {% trans %}Para aulas virtuales o eventos sin aforo restringido, ingrese ∞ o 0.{% endtrans %}
{% if form.capacity.errors %}
{{ form.capacity.errors[0] }}
{% endif %}
-
+
- {{ form.is_active(class="form-check-input", role="switch") }} - {{ form.is_active.label(class="form-check-label fw-semibold") }} + {{ form.is_active(class="form-check-input", id="is_active") }} +
- -
- {{ form.description.label(class="form-label fw-semibold") }} - {{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), rows="3", placeholder="Detalles sobre equipamiento o uso preferente del aula...") }} - {% if form.description.errors %} -
{{ form.description.errors[0] }}
- {% endif %} -
+
+ +
+ {{ form.description.label(class="form-label fw-semibold") }} + {{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), rows="3", placeholder=_("Opcional: Detalles adicionales del equipamiento...")) }} + {% if form.description.errors %} +
{{ form.description.errors[0] }}
+ {% endif %}
- -
-
- {% trans %}Vista Previa de Identificador{% endtrans %} -
-
-

- {% trans %}Código de visualización{% endtrans %}: - {{ classroom.code_display }} -

+ +
+
+
+ {% trans %}Vista Previa de Ubicación{% endtrans %} +
+
+
+ 🏢 {% trans %}Edificio{% endtrans %} + {{ classroom.building_name }} +
+
+ 🪜 {% trans %}Piso{% endtrans %} + {{ classroom.floor_display }} +
+
+ 🚪 {% trans %}Aula / Código{% endtrans %} + {{ classroom.code }} +
+
- -
+ +
{% trans %}Cancelar{% endtrans %} @@ -130,31 +168,78 @@ {% block extra_js %}