From ddd1714eb2f92bfaa168c126901bbb1bc8dbe537 Mon Sep 17 00:00:00 2001 From: Carlos Tello Date: Wed, 23 Sep 2026 20:35:54 -0300 Subject: [PATCH] feat(abm): abm completo de ciclos lectivos, aulas y reservas con proteccion de integridad y pruebas automatizadas --- CHANGELOG.md | 12 ++ README.md | 5 +- ROADMAP_MVP.md | 18 +- backend/app/routes/api/admin.py | 47 ++++++ backend/app/routes/api/classrooms.py | 16 ++ backend/app/routes/api/reservations.py | 24 ++- backend/app/utils/jwt_decorators.py | 19 ++- backend/tests/test_abm_endpoints.py | 156 ++++++++++++++++++ frontend/src/routes/admin.js | 22 +++ frontend/src/routes/classrooms.js | 24 +++ frontend/src/routes/schedule.js | 11 ++ frontend/views/admin/academic_terms/list.html | 54 ++++++ frontend/views/classrooms/list.html | 44 +++++ frontend/views/classrooms/view.html | 35 +++- frontend/views/schedule/view.html | 9 +- 15 files changed, 477 insertions(+), 19 deletions(-) create mode 100644 backend/tests/test_abm_endpoints.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6176b4e..318acea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,18 @@ El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1 ## [3.1.0] - 2026-09-23 ### Añadido +- **ABM Integral de Ciclos Lectivos (`AcademicTerm`):** + - **Gestión End-to-End de Períodos Académicos:** Implementación de ciclo de vida completo para ciclos lectivos: Alta, Edición, Establecimiento de Ciclo Actual, Toggle de Habilitación rápida y Eliminación protegida (`DELETE /api/v1/admin/academic-terms/` y `POST /api/v1/admin/academic-terms//toggle`). + - **Integridad Relacional Protegida:** Bloqueo de eliminación si el ciclo es el activo corriente, y fallback seguro a desactivación (`is_active = False`) si el ciclo tiene comisiones históricas asociadas. + - **Interfaz Enriquecida en Bedelía / Admin:** Botones de acción contextuales, switches y modales interactivos de confirmación (`#modalDeleteTerm`) en `frontend/views/admin/academic_terms/list.html`. +- **ABM Completo de Espacios y Aulas (`Classroom`):** + - **Control de Estado y Borrado Seguro:** Incorporación de toggle activo/inactivo (`POST /api/v1/classrooms//toggle`) y endpoint de baja de aulas integrado con el frontend BFF (`/classrooms/toggle_classroom` y `/classrooms/delete_classroom`). + - **Acciones Rápidas en Vista Lista y Ficha:** Botones de edición, alternancia de estado y eliminación con modales de advertencia en las vistas de cuadrícula/tabla (`list.html`) y detalle (`view.html`). +- **ABM Completo de Reservas (`Reservation`):** + - **Eliminación Definitiva (Hard Delete):** Soporte en backend para purga física de reservas (`DELETE /api/v1/reservations/?hard=true` y `POST /api/v1/reservations//delete`) complementando el flujo de cancelación lógica (`CANCELLED`). + - **Control Operativo en Vista Detalle:** Botón de "Eliminar Reserva Definitivamente" con diálogo modal de confirmación en `/schedule/view.html`. +- **Auditoría y Cobertura 100% de ABMs en la Plataforma:** + - Verificación y garantía de operaciones ABM completas en todos los componentes centrales: **Sedes/Edificios** (`Building`), **Aulas** (`Classroom`), **Ciclos Lectivos** (`AcademicTerm`), **Carreras** (`Career`), **Materias** (`Subject`), **Comisiones** (`Commission`) y **Reservas** (`Reservation`). - **Circuito Integral y Simplificado de Bedelía:** - **Flujo Operativo Ágil para Bedelía:** - Circuito intuitivo de 3 pasos: Catálogo de Aulas (`/classrooms/list_classrooms`) -> Creación/Edición con validación física/virtual y aforo real -> Asignación de Comisiones (`/schedule/add_reservation`). diff --git a/README.md b/README.md index 53b8c2c..c3a24e9 100644 --- a/README.md +++ b/README.md @@ -242,11 +242,12 @@ El script configura automáticamente: ## 🧪 Pruebas Automatizadas -Para ejecutar la suite completa de pruebas unitarias de regresión (Fases 2, 3 y 4): +Para ejecutar la suite completa de 47 pruebas unitarias y de integración (JWT, ABMs, Reglas, Optimizador, Calificaciones y Moodle): ```bash cd backend -.\venv\Scripts\python.exe -m unittest tests/test_phase2_rules.py tests/test_phase3_gradebook.py tests/test_phase4_interactive_impersonation.py +$env:PYTHONPATH="backend" +.\venv\Scripts\python.exe -m pytest tests -v ``` --- diff --git a/ROADMAP_MVP.md b/ROADMAP_MVP.md index 7215b37..c376f7f 100644 --- a/ROADMAP_MVP.md +++ b/ROADMAP_MVP.md @@ -140,22 +140,28 @@ Fase 6: Despliegue Producción e Infraest.[███████░░░░░ --- -### Fase 5.1: Bedelía UX, ABMs y Purga Segura de Datos 🏛️ ⚡ ✅ *(100% Completado)* -* **Objetivo:** Optimizar de punta a punta la experiencia de usuario de Bedelía, dotar de ABMs completos a las entidades académicas y asegurar la purga de datos sin comprometer identidades ni claves foráneas. +### Fase 5.1: Bedelía UX, Cobertura Integral de ABMs y Purga Segura de Datos 🏛️ ⚡ ✅ *(100% Completado)* +* **Objetivo:** Optimizar de punta a punta la experiencia de usuario de Bedelía, dotar de ABMs completos a todas las entidades de Espacios, Académica y Reservas, y asegurar la purga de datos sin comprometer identidades ni claves foráneas. * **Entregables:** 1. **Circuito Ágil de Bedelía:** - [x] Flujo de asignación ágil: de lista de comisiones (`/admin/commissions_list`) a reserva de aula (`/schedule/add_reservation?commission_id=...`) con 1 clic. - [x] Sincronización en cascada de selectores (Edificio -> Piso -> Aula) con detección de modalidad física/virtual y aforo. - [x] Conexión de reservas con comisiones activas en vivo vía API REST desacoplada (fin de mocks estáticos). - [x] Creación de aulas con vinculación consistente de sede y tipología. - 2. **ABMs Académicos Completos:** - - [x] ABM de Carreras (`/admin/careers_list`) con protección ante asignaturas existentes (soft-deactivation). - - [x] ABM de Asignaturas (`/admin/subjects_list`) con vinculación por carrera y mitigación de desvinculación forzada. - - [x] ABM de Comisiones (`/admin/commissions_list`) con asignación directa de profesores, turnos y cupos. + 2. **Cobertura ABM 100% (Espacios, Académica y Reservas):** + - [x] **Ciclos Lectivos (`AcademicTerm`):** ABM completo (Crear, Editar, Activar como Ciclo Actual, Toggle Activo/Inactivo rápido y Borrado protegido con soft-deactivate ante comisiones asociadas). Endpoints `/api/v1/admin/academic-terms` y vistas en `/admin/academic_terms`. + - [x] **Aulas y Espacios Físicos/Virtuales (`Classroom`):** ABM completo (Alta, Edición de aforo y piso, Toggle rápido activo/inactivo vía `/api/v1/classrooms//toggle`, y Eliminación con validación de reservas previas). Botones y modales en lista y detalle. + - [x] **Sedes y Edificios (`Building`):** ABM completo (Crear, Editar, Toggle y Eliminar sedes físicas e institucionales). + - [x] **Carreras (`Career`):** ABM completo (`/admin/careers_list`) con protección ante asignaturas existentes (soft-deactivation). + - [x] **Asignaturas (`Subject`):** ABM completo (`/admin/subjects_list`) con vinculación por carrera y mitigación de desvinculación forzada. + - [x] **Comisiones (`Commission`):** ABM completo (`/admin/commissions_list`) con asignación directa de profesores, turnos y cupos. + - [x] **Reservas (`Reservation`):** ABM completo (Creación, Edición, Confirmación, Cancelación lógica y Eliminación definitiva / Hard Delete con `/api/v1/reservations/?hard=true`). 3. **Purga Segura de Datos Académicos:** - [x] Modal de confirmación interactivo en frontend (`#purgeConfirmModal`) con feedback visual y prevención de borrado accidental. - [x] Secuencia de purga backend en estricto orden de integridad referencial (respetando Foreign Keys). - [x] Preservación garantizada de usuarios y roles RBAC ante reseteos de datos de cursada. + 4. **Configuración y Personalización UI:** + - [x] Panel de configuración global (`/admin/settings`) con opción para habilitar o deshabilitar el saludo superior y banner de rol en el dashboard. --- diff --git a/backend/app/routes/api/admin.py b/backend/app/routes/api/admin.py index 0dfd528..1ec9621 100644 --- a/backend/app/routes/api/admin.py +++ b/backend/app/routes/api/admin.py @@ -1387,6 +1387,53 @@ def set_current_academic_term(id): 'message': f'"{term.name}" establecido como ciclo activo.' }), 200 +@api_admin_bp.route('/academic-terms//toggle', methods=['POST']) +@jwt_required +def toggle_academic_term(id): + term = AcademicTerm.query.get_or_404(id) + if term.is_current and term.is_active: + return jsonify({ + 'error': 'Conflict', + 'message': 'No se puede desactivar el ciclo lectivo actualmente activo. Seleccione otro ciclo activo primero.' + }), 400 + term.is_active = not term.is_active + db.session.commit() + return jsonify({ + 'status': 'success', + 'id': term.id, + 'is_active': term.is_active, + 'message': f'Ciclo lectivo {"habilitado" if term.is_active else "desactivado"} correctamente.' + }), 200 + +@api_admin_bp.route('/academic-terms/', methods=['DELETE']) +@jwt_required +def delete_academic_term(id): + term = AcademicTerm.query.get_or_404(id) + if term.is_current: + return jsonify({ + 'error': 'Conflict', + 'message': 'No se puede eliminar el ciclo lectivo actualmente activo. Seleccione otro ciclo activo primero.' + }), 400 + + commissions_count = Commission.query.filter_by(academic_term_id=id).count() + if commissions_count > 0: + # Soft delete para preservar integridad de comisiones + term.is_active = False + db.session.commit() + return jsonify({ + 'status': 'success', + 'soft_deleted': True, + 'message': f'El ciclo lectivo tiene {commissions_count} comisiones asociadas. Ha sido desactivado para proteger el historial académico.' + }), 200 + + db.session.delete(term) + db.session.commit() + return jsonify({ + 'status': 'success', + 'soft_deleted': False, + 'message': 'Ciclo lectivo eliminado permanentemente.' + }), 200 + # --------------------------------------------------------- # MILESTONE TYPES CRUD # --------------------------------------------------------- diff --git a/backend/app/routes/api/classrooms.py b/backend/app/routes/api/classrooms.py index c80c10d..1ca186c 100644 --- a/backend/app/routes/api/classrooms.py +++ b/backend/app/routes/api/classrooms.py @@ -1,6 +1,8 @@ from flask import Blueprint, request, jsonify, g from pydantic import ValidationError +from app import db +from app.models.classroom import Classroom from app.services.classroom_service import ClassroomService from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO from app.utils.jwt_decorators import jwt_required, jwt_role_required @@ -117,3 +119,17 @@ def delete_classroom(classroom_id: int): return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404 return jsonify({'message': f'Aula {classroom_id} eliminada/desactivada correctamente.'}), 200 + +@api_classrooms_bp.route('//toggle', methods=['POST']) +@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA') +def toggle_classroom(classroom_id: int): + """Alterna el estado activo/inactivo de un aula.""" + classroom = Classroom.query.get_or_404(classroom_id) + classroom.is_active = not classroom.is_active + db.session.commit() + return jsonify({ + 'status': 'success', + 'id': classroom.id, + 'is_active': classroom.is_active, + 'message': f'Aula {"activada" if classroom.is_active else "desactivada"} correctamente.' + }), 200 diff --git a/backend/app/routes/api/reservations.py b/backend/app/routes/api/reservations.py index 7155a6b..2ffa4a5 100644 --- a/backend/app/routes/api/reservations.py +++ b/backend/app/routes/api/reservations.py @@ -2,6 +2,8 @@ from flask import Blueprint, request, jsonify, g from datetime import datetime, timedelta, date from pydantic import ValidationError +from app import db +from app.models.reservation import Reservation from app.services.reservation_service import ReservationService from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO from app.utils.jwt_decorators import jwt_required, jwt_role_required @@ -218,13 +220,33 @@ def update_reservation(reservation_id: int): @api_reservations_bp.route('/', methods=['DELETE']) @jwt_required def cancel_reservation(reservation_id: int): - """Cancela una reserva.""" + """Cancela o elimina una reserva según el parámetro hard.""" + hard_delete = request.args.get('hard', '').lower() in ['true', '1'] + if hard_delete: + res = Reservation.query.get(reservation_id) + if not res: + return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404 + db.session.delete(res) + db.session.commit() + return jsonify({'message': f'Reserva {reservation_id} eliminada permanentemente.'}), 200 + cancelled = reservation_service.cancel_reservation(reservation_id) if not cancelled: return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404 return jsonify({'message': f'Reserva {reservation_id} cancelada correctamente.'}), 200 +@api_reservations_bp.route('//delete', methods=['POST']) +@jwt_required +def delete_reservation_post(reservation_id: int): + """Elimina permanentemente una reserva.""" + res = Reservation.query.get(reservation_id) + if not res: + return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404 + db.session.delete(res) + db.session.commit() + return jsonify({'message': f'Reserva {reservation_id} eliminada permanentemente.'}), 200 + @api_reservations_bp.route('//cancel', methods=['POST']) @jwt_required def cancel_reservation_post(reservation_id: int): diff --git a/backend/app/utils/jwt_decorators.py b/backend/app/utils/jwt_decorators.py index 60da289..337b90e 100644 --- a/backend/app/utils/jwt_decorators.py +++ b/backend/app/utils/jwt_decorators.py @@ -20,14 +20,17 @@ def jwt_required(f): token = request.cookies.get('access_token') if not token: - from flask_login import current_user - if current_user.is_authenticated and current_user.is_active: - g.jwt_user = current_user - g.jwt_payload = {'sub': str(current_user.id), 'role': getattr(current_user, 'role', '')} - g.jwt_token = None - g.real_admin_user = current_user - g.is_impersonating = False - return f(*args, **kwargs) + try: + from flask_login import current_user + if current_user.is_authenticated and current_user.is_active: + g.jwt_user = current_user + g.jwt_payload = {'sub': str(current_user.id), 'role': getattr(current_user, 'role', '')} + g.jwt_token = None + g.real_admin_user = current_user + g.is_impersonating = False + return f(*args, **kwargs) + except Exception: + pass return jsonify({ 'error': 'Unauthorized', diff --git a/backend/tests/test_abm_endpoints.py b/backend/tests/test_abm_endpoints.py new file mode 100644 index 0000000..eb39488 --- /dev/null +++ b/backend/tests/test_abm_endpoints.py @@ -0,0 +1,156 @@ +import pytest +from app import create_app, db +from app.models.user import User +from app.models.role import Role +from app.models.academic_term import AcademicTerm +from app.models.classroom import Classroom +from app.models.building import Building +from app.models.subject import Subject, Commission +from app.models.reservation import Reservation +from app.services.jwt_service import JWTService +from datetime import datetime, timedelta + +@pytest.fixture +def app(): + app = create_app() + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' + app.config['TESTING'] = True + with app.app_context(): + db.create_all() + admin_role = Role.query.filter_by(name='ADMIN').first() + if not admin_role: + admin_role = Role(name='ADMIN', description='Administrator') + db.session.add(admin_role) + db.session.commit() + + user = User( + email='admin_abm@unicaba.edu.ar', + name='Admin ABM', + first_name='Admin', + last_name='ABM', + role='ADMIN', + role_id=admin_role.id, + is_active=True + ) + user.set_password('Admin1234!') + db.session.add(user) + db.session.commit() + + yield app + + db.session.remove() + db.drop_all() + +@pytest.fixture +def client(app): + return app.test_client() + +@pytest.fixture +def auth_headers(app): + with app.app_context(): + user = User.query.filter_by(email='admin_abm@unicaba.edu.ar').first() + token = JWTService.generate_tokens(user)['access_token'] + return { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + +def test_academic_term_abm(client, auth_headers): + # 1. Crear término + res = client.post('/api/v1/admin/academic-terms', headers=auth_headers, json={ + 'name': '2026-Segundo Cuatrimestre Test', + 'code': '2026-2C-TEST', + 'year': 2026, + 'period_type': 'Segundo Cuatrimestre', + 'is_active': True, + 'is_current': False + }) + assert res.status_code == 201 + data = res.get_json() + term_id = data['term']['id'] + + # 2. Toggle active + res_toggle = client.post(f'/api/v1/admin/academic-terms/{term_id}/toggle', headers=auth_headers) + assert res_toggle.status_code == 200 + assert res_toggle.get_json()['is_active'] is False + + # 3. Delete término + res_del = client.delete(f'/api/v1/admin/academic-terms/{term_id}', headers=auth_headers) + assert res_del.status_code == 200 + assert 'eliminado' in res_del.get_json()['message'] + +def test_classroom_toggle(client, auth_headers): + # Crear edificio y aula + building = Building(name='Sede Central Test', code='SC-TEST', address='Av Test 123') + db.session.add(building) + db.session.commit() + + classroom = Classroom( + building=building.name, + code='A-101-TEST', + capacity=50, + floor='1', + building_id=building.id, + is_active=True + ) + db.session.add(classroom) + db.session.commit() + + # Toggle aula + res = client.post(f'/api/v1/classrooms/{classroom.id}/toggle', headers=auth_headers) + assert res.status_code == 200 + assert res.get_json()['is_active'] is False + +def test_reservation_hard_delete(client, auth_headers): + # Crear edificio, aula, materia, comisión y reserva + building = Building(name='Sede Norte Test', code='SN-TEST', address='Av Norte 456') + db.session.add(building) + db.session.commit() + + classroom = Classroom( + building=building.name, + code='LAB-1-TEST', + capacity=30, + floor='PB', + building_id=building.id, + is_active=True + ) + db.session.add(classroom) + + subject = Subject(name='Base de Datos Test', code='BD-TEST', is_active=True) + db.session.add(subject) + db.session.commit() + + user = User.query.filter_by(email='admin_abm@unicaba.edu.ar').first() + commission = Commission( + subject_id=subject.id, + code='COM-101', + semester='2C', + year=2026, + teacher_id=user.id, + max_students=30, + active=True + ) + db.session.add(commission) + db.session.commit() + + now = datetime.now() + reservation = Reservation( + classroom_id=classroom.id, + commission_id=commission.id, + user_id=user.id, + purpose='Clase Regular de BD', + expected_attendees=25, + start_time=now, + end_time=now + timedelta(hours=2), + status='CONFIRMED' + ) + db.session.add(reservation) + db.session.commit() + res_id = reservation.id + + # Hard delete + res_del = client.delete(f'/api/v1/reservations/{res_id}?hard=true', headers=auth_headers) + assert res_del.status_code == 200 + assert 'eliminada' in res_del.get_json()['message'] + assert db.session.get(Reservation, res_id) is None diff --git a/frontend/src/routes/admin.js b/frontend/src/routes/admin.js index e06a1e3..098f5fa 100644 --- a/frontend/src/routes/admin.js +++ b/frontend/src/routes/admin.js @@ -1242,10 +1242,32 @@ const handleAcademicTermSetCurrent = async (req, res) => { res.redirect('/admin/academic_terms_list'); }; +const handleAcademicTermToggle = async (req, res) => { + const id = req.params.id || req.body.id; + try { + await req.apiClient.post(`/academic-terms/${id}/toggle`); + } catch (e) { + console.warn('Academic term toggle API notice:', e.response?.data || e.message); + } + res.redirect('/admin/academic_terms_list'); +}; + +const handleAcademicTermDelete = async (req, res) => { + const id = req.params.id || req.body.id; + try { + await req.apiClient.delete(`/academic-terms/${id}`); + } catch (e) { + console.warn('Academic term delete API notice:', e.response?.data || e.message); + } + res.redirect('/admin/academic_terms_list'); +}; + router.get(['/academic_terms_list', '/academic_terms', '/academic-terms'], handleAcademicTermsList); router.post(['/academic_term_add', '/academic-terms/add'], handleAcademicTermAdd); router.post(['/academic_term_edit', '/academic_term_edit/:id'], handleAcademicTermEdit); router.post(['/academic_term_set_current', '/academic_term_set_current/:id'], handleAcademicTermSetCurrent); +router.post(['/academic_term_toggle', '/academic_term_toggle/:id', '/academic-terms/toggle/:id'], handleAcademicTermToggle); +router.post(['/academic_term_delete', '/academic_term_delete/:id', '/academic-terms/delete/:id'], handleAcademicTermDelete); // ─── 9. MILESTONE TYPES ─────────────────────────────────────────────────────── diff --git a/frontend/src/routes/classrooms.js b/frontend/src/routes/classrooms.js index d499dc2..97cec81 100644 --- a/frontend/src/routes/classrooms.js +++ b/frontend/src/routes/classrooms.js @@ -583,4 +583,28 @@ router.post('/edit/:id', handlePostEditClassroom); router.post('/edit_classroom/:id', handlePostEditClassroom); router.post('/edit_classroom', handlePostEditClassroom); +const handleToggleClassroom = async (req, res) => { + const id = req.params.id || req.body.classroom_id || req.body.id || req.query.id; + try { + await req.apiClient.post(`/classrooms/${id}/toggle`); + } catch (e) { + console.warn('Error toggling classroom:', e.response?.data || e.message); + } + const referer = req.get('Referer') || '/classrooms/list_classrooms'; + res.redirect(referer); +}; + +const handleDeleteClassroom = async (req, res) => { + const id = req.params.id || req.body.classroom_id || req.body.id || req.query.id; + try { + await req.apiClient.delete(`/classrooms/${id}`); + } catch (e) { + console.warn('Error deleting classroom:', e.response?.data || e.message); + } + res.redirect('/classrooms/list_classrooms'); +}; + +router.post(['/toggle_classroom', '/toggle/:id', '/:id/toggle'], handleToggleClassroom); +router.post(['/delete_classroom', '/delete/:id', '/:id/delete'], handleDeleteClassroom); + module.exports = router; diff --git a/frontend/src/routes/schedule.js b/frontend/src/routes/schedule.js index 77e4a76..cbd76b6 100644 --- a/frontend/src/routes/schedule.js +++ b/frontend/src/routes/schedule.js @@ -641,6 +641,16 @@ const handleCancelReservation = async (req, res) => { res.redirect(`/schedule/view_reservation?id=${id}`); }; +const handleDeleteReservation = async (req, res) => { + const id = req.params.id || req.body.id || req.query.id; + try { + await req.apiClient.post(`/reservations/${id}/delete`); + } catch (e) { + console.warn('Delete reservation API notice:', e.response?.data || e.message); + } + res.redirect('/schedule/list_reservations'); +}; + router.get(['/view_reservation', '/view_reservation/:id', '/view/:id', '/reservation/:id/view'], handleViewReservation); router.get(['/edit_reservation', '/edit_reservation/:id', '/reservation/:id/edit'], handleEditReservation); @@ -648,6 +658,7 @@ router.post(['/edit_reservation', '/edit_reservation/:id', '/reservation/:id/edi router.post(['/confirm_reservation', '/confirm_reservation/:id', '/reservation/:id/confirm'], handleConfirmReservation); router.post(['/cancel_reservation', '/cancel_reservation/:id', '/reservation/:id/cancel'], handleCancelReservation); +router.post(['/delete_reservation', '/delete_reservation/:id', '/reservation/:id/delete'], handleDeleteReservation); // 9. Libro de Calificaciones / Gradebook (Fase 3) const handleGradebook = async (req, res) => { diff --git a/frontend/views/admin/academic_terms/list.html b/frontend/views/admin/academic_terms/list.html index fd082a4..bff5b6d 100644 --- a/frontend/views/admin/academic_terms/list.html +++ b/frontend/views/admin/academic_terms/list.html @@ -105,6 +105,20 @@ title="Editar Ciclo Lectivo"> + {% if not term.is_current %} +
+ + +
+ + {% endif %} {% endif %} @@ -190,6 +204,46 @@ + +{% if not term.is_current %} + +{% endif %} {% endfor %} {% endif %} diff --git a/frontend/views/classrooms/list.html b/frontend/views/classrooms/list.html index 15286bb..15d6622 100644 --- a/frontend/views/classrooms/list.html +++ b/frontend/views/classrooms/list.html @@ -399,6 +399,11 @@ class="btn btn-outline-secondary py-0 px-2" title="Editar Aula" style="font-size: 0.75rem;"> +
@@ -501,6 +506,11 @@ + {% endif %} @@ -997,6 +1007,40 @@ {% endif %} + +{% if classrooms and classrooms.items %} +{% for classroom in classrooms.items %} +
+{% endfor %} {% endif %} diff --git a/frontend/views/classrooms/view.html b/frontend/views/classrooms/view.html index e1d3c7d..dcde145 100644 --- a/frontend/views/classrooms/view.html +++ b/frontend/views/classrooms/view.html @@ -28,9 +28,12 @@
{% if user.has_permission('classrooms', 'read_write') %} - + Edit Classroom + {% endif %} Back to Classrooms @@ -169,5 +172,35 @@
+ + {% endblock %} diff --git a/frontend/views/schedule/view.html b/frontend/views/schedule/view.html index b6f5847..345f9a9 100644 --- a/frontend/views/schedule/view.html +++ b/frontend/views/schedule/view.html @@ -248,12 +248,19 @@ {% if reservation.can_be_cancelled() %}
-
{% endif %} +
+ + +
+
View All Reservations