From 95d2aac32396930c6edb5358815994d74ccff84a Mon Sep 17 00:00:00 2001 From: Carlos Tello Date: Wed, 23 Sep 2026 20:44:08 -0300 Subject: [PATCH] feat(install): agregar opcion --empty-academic en install.sh e init_db.py para inicializar base de datos sin datos demo --- CHANGELOG.md | 3 + README.md | 17 +++- ROADMAP_MVP.md | 2 + backend/init_db.py | 231 +++++++++++++++++++++++++++------------------ install.sh | 45 +++++++-- 5 files changed, 196 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 318acea..b20ed90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,9 @@ El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1 - Sustitución de listas fijas/mockeadas en `frontend/src/routes/schedule.js` por consulta dinámica a `/commissions` en vivo, permitiendo reservar aulas para cualquiera de las comisiones reales de la plataforma. - **Soporte Multisede Consistente en Aulas:** - Resolución robusta de `building_id` y flag `is_virtual` en `frontend/src/routes/classrooms.js`, eliminando hardcodes y asegurando que las aulas físicas pertenezcan a la sede seleccionada. +- **Inicialización de Base de Datos Limpia sin Datos Demo (`--empty-academic` / `--no-demo`):** + - **Soporte en `install.sh` y Menú Interactivo:** Incorporación de la opción `sudo bash install.sh --empty-academic` (o `--no-demo`) y opción `[3]` en el menú interactivo para crear la base de datos limpia desde cero, omitiendo la inyección de snapshots con carreras, materias, comisiones, aulas o reservas heredadas. + - **Inicializador de Esquema `init_db.py`:** Nuevo flag `--empty-academic` que genera el esquema de tablas completo, los roles y permisos RBAC, los usuarios institucionales y las tipificaciones de hitos, purgando y omitiendo cualquier edificio, aula física o virtual, carrera, materia, comisión o reserva demo por defecto. - **Personalización de Interfaz y Saludo en Dashboard (Configuración Global):** - **Nueva Pestaña de Interfaz & Dashboard (`/admin/settings`):** Módulo en el panel de configuración global para personalizar elementos visuales del sistema. - **Desactivación Opcional del Saludo y Selector de Rol:** Switch interactivo para habilitar o deshabilitar el banner superior `¡Hola, {Usuario}!` y el selector de perspectiva de roles en `dashboard.html`. Al deshabilitarlo, el usuario accede directamente a los indicadores y accesos operativos de su rol sin distracciones ni dudas de contexto. diff --git a/README.md b/README.md index c3a24e9..bb5bc75 100644 --- a/README.md +++ b/README.md @@ -218,8 +218,7 @@ node src/app.js Para servidores Debian 12 / Ubuntu en Proxmox LXC o VPS, el repositorio incluye el instalador unificado [install.sh](install.sh) en la raíz: -```bash -# Despliegue interactivo (pregunta si mantener o restaurar la BD si ya existe): +# Despliegue interactivo (pregunta si mantener BD, restaurar snapshot o crear base limpia): sudo bash install.sh # Despliegue desatendido manteniendo la base de datos existente: @@ -228,10 +227,22 @@ sudo bash install.sh --keep-db # Despliegue desatendido preservando la base de datos y los usuarios/credenciales configurados: sudo bash install.sh --keep-db --keep-users -# Despliegue desatendido recreando la base de datos desde cero (DROP y restore snapshot): +# Despliegue desatendido con base de datos limpia sin carreras, materias, ciclos lectivos, aulas ni edificios demo: +sudo bash install.sh --empty-academic + +# Despliegue desatendido recreando la base de datos desde cero con snapshot de muestra (DROP y restore classrooms_db.sql): sudo bash install.sh --fresh-db ``` +También es posible inicializar directamente la base de datos limpia desde Python en cualquier entorno: +```bash +# Inicialización estándar con datos de muestra: +python backend/init_db.py + +# Inicialización limpia (sin carreras, materias, ciclos lectivos, aulas, edificios ni reservas): +python backend/init_db.py --empty-academic +``` + El script configura automáticamente: 1. Entorno Python 3.10+ y virtualenv en `backend/venv` con Gunicorn y servicio `admin-edu-space-backend.service` (puerto 5000). 2. Entorno Node.js 20 LTS en `frontend/` con servicio `admin-edu-space-frontend.service` (puerto 3000). diff --git a/ROADMAP_MVP.md b/ROADMAP_MVP.md index c376f7f..b397e9f 100644 --- a/ROADMAP_MVP.md +++ b/ROADMAP_MVP.md @@ -162,6 +162,8 @@ Fase 6: Despliegue Producción e Infraest.[███████░░░░░ - [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. + 5. **Inicialización y Despliegue con Base Limpia:** + - [x] Soporte `--empty-academic` / `--no-demo` en `install.sh` e `init_db.py` para inicializar el sistema con roles y usuarios intactos pero con tablas académicas y de espacios totalmente vacías para configuración desde cero. --- diff --git a/backend/init_db.py b/backend/init_db.py index 246058d..87ae056 100644 --- a/backend/init_db.py +++ b/backend/init_db.py @@ -8,6 +8,7 @@ if hasattr(sys.stderr, 'reconfigure'): sys.stderr.reconfigure(encoding='utf-8') from app import create_app, db +from app import models from app.models.user import User from app.models.role import Role, Permission, SYSTEM_MODULES from sqlalchemy import text @@ -313,6 +314,49 @@ def init_database(): # 4. Verificar o crear usuarios institucionales de cada rol keep_users = ('--keep-users' in sys.argv) or (os.environ.get('KEEP_USERS', '').lower() in ['1', 'true', 'yes']) + empty_academic = any(arg in sys.argv for arg in ['--empty-academic', '--no-demo', '--clean-db', '--without-demo-data', '--skip-demo']) or \ + (os.environ.get('EMPTY_ACADEMIC', '').lower() in ['1', 'true', 'yes']) + + if empty_academic: + print("[*] Opción --empty-academic activa: Se configurará la base de datos limpia SIN carreras, materias, ciclos lectivos, aulas, edificios ni reservas por defecto.") + try: + from app.models.reservation import Reservation + from app.models.grade import MilestoneGrade + from app.models.milestone import AcademicMilestone + from app.models.subject import CommissionTeacher, Commission, Subject + from app.models.enrollment import StudentEnrollment + from app.models.career import Career + from app.models.classroom import ClassroomResource, Classroom + from app.models.building import Building + from app.models.academic_term import AcademicTerm + + Reservation.query.delete() + try: + MilestoneGrade.query.delete() + AcademicMilestone.query.delete() + except Exception: + pass + try: + CommissionTeacher.query.delete() + except Exception: + pass + StudentEnrollment.query.delete() + Commission.query.delete() + Subject.query.delete() + Career.query.delete() + try: + ClassroomResource.query.delete() + except Exception: + pass + Classroom.query.delete() + Building.query.delete() + AcademicTerm.query.delete() + db.session.commit() + print(" [OK] Tablas de carreras, materias, comisiones, aulas, edificios y reservas aseguradas vacías.") + except Exception as pe: + db.session.rollback() + print(f" [!] Nota durante verificación de tablas académicas vacías: {pe}") + if keep_users: print("[*] Opción --keep-users activa: Preservando usuarios, roles y contraseñas existentes intactos (no se modifican ni pisan).") else: @@ -380,33 +424,36 @@ def init_database(): print(f" [OK] Usuario {u_info['email']} validado (Rol: {u_info['role_name']}).") # 5. Inicializar edificios institucionales oficiales - print("[*] Verificando edificios institucionales...") - from app.models.building import Building - b_central = Building.query.filter_by(name='Edificio Central').first() - if not b_central: - b_central = Building( - name='Edificio Central', - code='CENTRAL', - address='Tte. Gral. Juan Domingo Peron 802, CABA', - floors=4, - description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.' - ) - db.session.add(b_central) - else: - b_central.address = 'Tte. Gral. Juan Domingo Peron 802, CABA' + if not empty_academic: + print("[*] Verificando edificios institucionales...") + from app.models.building import Building + b_central = Building.query.filter_by(name='Edificio Central').first() + if not b_central: + b_central = Building( + name='Edificio Central', + code='CENTRAL', + address='Tte. Gral. Juan Domingo Peron 802, CABA', + floors=4, + description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.' + ) + db.session.add(b_central) + else: + b_central.address = 'Tte. Gral. Juan Domingo Peron 802, CABA' - b_virtual = Building.query.filter_by(name='Campus Virtual').first() - if not b_virtual: - b_virtual = Building( - name='Campus Virtual', - code='VIRTUAL', - address='Entorno Digital / Microsoft Teams - Moodle', - floors=0, - description='Entorno de aulas virtuales y clases a distancia' - ) - db.session.add(b_virtual) - db.session.commit() - print("[OK] Edificios institucionales configurados con dirección oficial.") + b_virtual = Building.query.filter_by(name='Campus Virtual').first() + if not b_virtual: + b_virtual = Building( + name='Campus Virtual', + code='VIRTUAL', + address='Entorno Digital / Microsoft Teams - Moodle', + floors=0, + description='Entorno de aulas virtuales y clases a distancia' + ) + db.session.add(b_virtual) + db.session.commit() + print("[OK] Edificios institucionales configurados con dirección oficial.") + else: + print("[*] Modo --empty-academic: Omitiendo creación de edificios institucionales por defecto.") # 6. Inicializar catalogo dinamico de tipificaciones de hitos academicos print("[*] Verificando catalogo de tipificaciones de hitos academicos...") @@ -432,76 +479,80 @@ def init_database(): print(f"[OK] Catalogo de tipificaciones inicializado ({MilestoneType.query.count()} tipos disponibles).") # 7. Inicializar aulas, materias, comisiones y reservas de muestra - from app.models.classroom import Classroom - from app.models.subject import Subject, Commission - from app.models.reservation import Reservation, ReservationStatus - from datetime import datetime, timedelta, date + if not empty_academic: + from app.models.classroom import Classroom + from app.models.subject import Subject, Commission + from app.models.reservation import Reservation, ReservationStatus + from datetime import datetime, timedelta, date - if Classroom.query.count() == 0: - print("[*] Generando catalogo de aulas...") - for floor in ['PB', '1', '2', '3']: - for num in range(1, 8): - code = f'{floor}0{num}' if floor != 'PB' else f'PB-0{num}' - c = Classroom( - code=code, - building=b_central.name, - building_id=b_central.id, - floor=floor, - capacity=30 + (num * 5), - description=f'Aula física estándar {code} en {b_central.name}', + if Classroom.query.count() == 0 and 'b_central' in locals() and b_central and 'b_virtual' in locals() and b_virtual: + print("[*] Generando catalogo de aulas...") + for floor in ['PB', '1', '2', '3']: + for num in range(1, 8): + code = f'{floor}0{num}' if floor != 'PB' else f'PB-0{num}' + c = Classroom( + code=code, + building=b_central.name, + building_id=b_central.id, + floor=floor, + capacity=30 + (num * 5), + description=f'Aula física estándar {code} en {b_central.name}', + is_active=True + ) + db.session.add(c) + + for v in range(1, 4): + vc = Classroom( + code=f'VIRTUAL-0{v}', + building=b_virtual.name, + building_id=b_virtual.id, + floor='0', + capacity=100, + description=f'Sala virtual Microsoft Teams {v}', is_active=True ) - db.session.add(c) + db.session.add(vc) + db.session.commit() + print(f"[OK] {Classroom.query.count()} aulas creadas.") - for v in range(1, 4): - vc = Classroom( - code=f'VIRTUAL-0{v}', - building=b_virtual.name, - building_id=b_virtual.id, - floor='0', - capacity=100, - description=f'Sala virtual Microsoft Teams {v}', - is_active=True - ) - db.session.add(vc) - db.session.commit() - print(f"[OK] {Classroom.query.count()} aulas creadas.") + # Materia y Comisión + s = Subject.query.filter_by(code='ARQ-101').first() + if not s: + s = Subject(name='Arquitectura de Software', code='ARQ-101', is_active=True) + db.session.add(s) + db.session.commit() - # Materia y Comisión - s = Subject.query.filter_by(code='ARQ-101').first() - if not s: - s = Subject(name='Arquitectura de Software', code='ARQ-101', is_active=True) - db.session.add(s) - db.session.commit() + comm = Commission.query.filter_by(code='COM-A').first() + if not comm: + comm = Commission(code='COM-A', subject_id=s.id, shift='Noche', semester=2, year=2026, max_students=40, is_active=True) + db.session.add(comm) + db.session.commit() - comm = Commission.query.filter_by(code='COM-A').first() - if not comm: - comm = Commission(code='COM-A', subject_id=s.id, shift='Noche', semester=2, year=2026, max_students=40, is_active=True) - db.session.add(comm) - db.session.commit() - - # Reservas de Hoy - if Reservation.query.count() == 0: - print("[*] Generando reservas de muestra...") - today = date.today() - classrooms = Classroom.query.all() - docente = User.query.filter_by(email='docente@edu-space.com').first() - for idx, cl in enumerate(classrooms[:19]): - start_hour = 8 + (idx % 6) * 2 - r = Reservation( - classroom_id=cl.id, - commission_id=comm.id, - user_id=docente.id if docente else 1, - start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=start_hour), - end_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=start_hour + 2), - purpose='Clase Regular', - status=ReservationStatus.CONFIRMED.value, - shift='Noche' if start_hour >= 18 else 'Tarde' if start_hour >= 13 else 'Mañana', - expected_attendees=cl.capacity - 5 - ) - db.session.add(r) - db.session.commit() - print(f"[OK] {Reservation.query.count()} reservas creadas.") + # Reservas de Hoy + if Reservation.query.count() == 0: + print("[*] Generando reservas de muestra...") + today = date.today() + classrooms = Classroom.query.all() + docente = User.query.filter_by(email='docente@edu-space.com').first() + for idx, cl in enumerate(classrooms[:19]): + start_hour = 8 + (idx % 6) * 2 + r = Reservation( + classroom_id=cl.id, + commission_id=comm.id, + user_id=docente.id if docente else 1, + start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=start_hour), + end_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=start_hour + 2), + purpose='Clase Regular', + status=ReservationStatus.CONFIRMED.value, + shift='Noche' if start_hour >= 18 else 'Tarde' if start_hour >= 13 else 'Mañana', + expected_attendees=cl.capacity - 5 + ) + db.session.add(r) + db.session.commit() + print(f"[OK] {Reservation.query.count()} reservas creadas.") + else: + print("[*] Modo --empty-academic: Omitiendo creación de aulas, materias, comisiones y reservas de muestra.") + print("[OK] Base de datos creada con tablas vacías para gestión académica y de espacios desde cero.") print("\n[LISTO] Base de datos inicializada y lista para usar.") diff --git a/install.sh b/install.sh index 9b6ec4e..90217c5 100755 --- a/install.sh +++ b/install.sh @@ -21,6 +21,7 @@ hdr() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}\n"; } # ─── Argumentos de Línea de Comandos ─────────────────────────────────────────── ARG_DB_ACTION="" ARG_KEEP_USERS=false +ARG_EMPTY_ACADEMIC=false for arg in "$@"; do case "$arg" in --keep-db) @@ -32,13 +33,19 @@ for arg in "$@"; do --fresh-db|--restore-db) ARG_DB_ACTION="fresh" ;; + --empty-academic|--no-demo|--clean-db|--without-demo-data) + ARG_DB_ACTION="fresh" + ARG_EMPTY_ACADEMIC=true + ;; -h|--help) echo "Uso: sudo bash install.sh [OPCIONES]" echo "Opciones:" - echo " --keep-db Mantiene la base de datos existente si ya contiene datos." - echo " --keep-users Preserva los usuarios existentes sin volver a crearlos o pisar sus contraseñas." - echo " --fresh-db Restaura la base de datos desde cero (DROP y recarga snapshot)." - echo " -h, --help Muestra esta ayuda." + echo " --keep-db Mantiene la base de datos existente si ya contiene datos." + echo " --keep-users Preserva los usuarios existentes sin volver a crearlos o pisar sus contraseñas." + echo " --fresh-db Restaura la base de datos desde cero (DROP y recarga snapshot classrooms_db.sql)." + echo " --empty-academic Crea la base de datos limpia sin carreras, materias, ciclos lectivos, aulas, edificios ni reservas demo." + echo " --no-demo Alias de --empty-academic." + echo " -h, --help Muestra esta ayuda." exit 0 ;; *) @@ -117,6 +124,7 @@ info "Directorio Frontend: ${FRONTEND_DIR}" info "Usuario de Servicio: ${APP_USER}" info "Preservar Base de Datos: ${ARG_DB_ACTION:-auto (interactivo/detección)}" info "Preservar Usuarios: ${ARG_KEEP_USERS}" +info "Base Limpia (Sin Demo): ${ARG_EMPTY_ACADEMIC}" # ─── 1. Paquetes Base del Sistema ────────────────────────────────────────────── hdr "1. Actualizando repositorios y paquetes base" @@ -340,22 +348,32 @@ if [ "$DB_EXISTS" = true ] && [ "$TABLE_COUNT" -gt 0 ]; then info "Bandera --keep-db recibida: Se mantiene la base de datos existente intacta." DO_RESTORE_FRESH=false elif [[ "$ARG_DB_ACTION" == "fresh" ]]; then - warn "Bandera --fresh-db recibida: Se recreará la base de datos desde cero." + if [ "$ARG_EMPTY_ACADEMIC" = true ]; then + warn "Bandera --empty-academic recibida: Se inicializará la base de datos limpia sin datos demo." + else + warn "Bandera --fresh-db recibida: Se recreará la base de datos desde cero con snapshot." + fi DO_RESTORE_FRESH=true elif [ -t 0 ]; then echo -e "\n${BOLD}${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" echo -e "${BOLD} ¿Qué deseas hacer con la base de datos PostgreSQL existente?${RESET}" echo -e " [1] ${GREEN}${BOLD}Mantener la base de datos actual${RESET} (Recomendado: preserva registros y sincroniza esquema)" - echo -e " [2] ${RED}${BOLD}Restaurar desde cero${RESET} (DROP DATABASE y recargar snapshot ${SQL_BACKUP_FILE##*/})" + echo -e " [2] ${RED}${BOLD}Restaurar desde cero con datos de muestra${RESET} (DROP DATABASE y recargar snapshot ${SQL_BACKUP_FILE##*/})" + echo -e " [3] ${CYAN}${BOLD}Base de datos limpia sin datos demo${RESET} (DROP DATABASE y crear tablas vacías sin carreras, materias, aulas ni sedes)" echo -e "${BOLD}${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" DB_CHOICE="" - read -t 30 -p " Ingresá tu opción [1/2] (por defecto 1 en 30s): " DB_CHOICE || DB_CHOICE="1" + read -t 30 -p " Ingresá tu opción [1/2/3] (por defecto 1 en 30s): " DB_CHOICE || DB_CHOICE="1" DB_CHOICE="${DB_CHOICE:-1}" if [[ "$DB_CHOICE" == "2" ]]; then - warn "Opción [2] seleccionada: Restauración destructiva desde cero." + warn "Opción [2] seleccionada: Restauración destructiva desde snapshot demo." DO_RESTORE_FRESH=true + ARG_EMPTY_ACADEMIC=false + elif [[ "$DB_CHOICE" == "3" ]]; then + warn "Opción [3] seleccionada: Base de datos limpia sin carreras, materias, aulas ni sedes demo." + DO_RESTORE_FRESH=true + ARG_EMPTY_ACADEMIC=true else ok "Opción [1] seleccionada: Mantener base de datos existente." DO_RESTORE_FRESH=false @@ -401,7 +419,12 @@ hdr "9. Aplicando esquema y datos en la base de datos" cd "$BACKEND_DIR" if [ "$DO_RESTORE_FRESH" = true ]; then - if [[ -f "$SQL_BACKUP_FILE" ]]; then + if [ "$ARG_EMPTY_ACADEMIC" = true ]; then + info "Inicializando base de datos limpia (sin datos demo de carreras, materias, aulas o sedes)..." + pg_cmd -c "DROP DATABASE IF EXISTS \"${DB_NAME}\";" > /dev/null 2>&1 || true + pg_cmd -c "CREATE DATABASE \"${DB_NAME}\" OWNER postgres;" > /dev/null 2>&1 + ok "Base de datos '${DB_NAME}' creada limpia (sin snapshot demo)" + elif [[ -f "$SQL_BACKUP_FILE" ]]; then BACKUP_SIZE=$(du -h "$SQL_BACKUP_FILE" | awk '{print $1}') info "Restaurando snapshot '${SQL_BACKUP_FILE##*/}' (${BACKUP_SIZE})..." pg_cmd -c "DROP DATABASE IF EXISTS \"${DB_NAME}\";" > /dev/null 2>&1 || true @@ -436,6 +459,10 @@ if [ "$ARG_KEEP_USERS" = true ]; then INIT_DB_EXTRA_FLAGS+=( "--keep-users" ) info "Bandera --keep-users activada: init_db.py preservará los usuarios y contraseñas sin modificarlos ni recrearlos." fi +if [ "$ARG_EMPTY_ACADEMIC" = true ]; then + INIT_DB_EXTRA_FLAGS+=( "--empty-academic" ) + info "Bandera --empty-academic activada: init_db.py creará las tablas vacías sin carreras, materias, ciclos lectivos, aulas, edificios ni reservas." +fi LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 PYTHONUTF8=1 PYTHONIOENCODING=utf-8 \ PYTHONPATH="${BACKEND_DIR}" FLASK_APP=wsgi.py "${VENV_DIR}/bin/python" -X utf8 init_db.py "${INIT_DB_EXTRA_FLAGS[@]}"