import sys import os # Asegurar codificación UTF-8 en consola de Windows if hasattr(sys.stdout, 'reconfigure'): sys.stdout.reconfigure(encoding='utf-8') 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 def init_database(): app = create_app() with app.app_context(): try: print("[*] Conectando a la base de datos y sincronizando esquema...") # 1. Crear tablas que no existan db.create_all() # 2. Migraciones incrementales seguras para bases de datos existentes if db.engine.url.drivername != 'sqlite': # Columnas extendidas de users db.session.execute(text(""" ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10) DEFAULT NULL; ALTER TABLE users ADD COLUMN IF NOT EXISTS theme_preference VARCHAR(10) DEFAULT 'auto' NOT NULL; ALTER TABLE users ADD COLUMN IF NOT EXISTS role_id INTEGER REFERENCES roles(id) ON DELETE SET NULL; ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100); ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100); ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(50); ALTER TABLE users ADD COLUMN IF NOT EXISTS personal_email VARCHAR(255); ALTER TABLE users ADD COLUMN IF NOT EXISTS address VARCHAR(255); ALTER TABLE users ADD COLUMN IF NOT EXISTS document_type VARCHAR(20) DEFAULT 'DNI'; ALTER TABLE users ADD COLUMN IF NOT EXISTS document_number VARCHAR(30); CREATE INDEX IF NOT EXISTS ix_users_document_number ON users (document_number); """)) # Columnas extendidas de subjects db.session.execute(text(""" ALTER TABLE subjects ADD COLUMN IF NOT EXISTS career_id INTEGER REFERENCES careers(id) ON DELETE SET NULL; ALTER TABLE subjects ALTER COLUMN code TYPE VARCHAR(50); ALTER TABLE subjects ADD COLUMN IF NOT EXISTS is_short_course BOOLEAN DEFAULT FALSE; """)) # Infraestructura: buildings y classrooms db.session.execute(text(""" CREATE TABLE IF NOT EXISTS buildings ( id SERIAL PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL, code VARCHAR(50) UNIQUE, address VARCHAR(255), floors INTEGER DEFAULT 1 NOT NULL, description TEXT, is_active BOOLEAN DEFAULT TRUE NOT NULL, created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() ); ALTER TABLE classrooms ADD COLUMN IF NOT EXISTS building_id INTEGER REFERENCES buildings(id) ON DELETE SET NULL; """)) # Columnas extendidas de commissions db.session.execute(text(""" ALTER TABLE commissions ADD COLUMN IF NOT EXISTS shift VARCHAR(30); ALTER TABLE commissions ADD COLUMN IF NOT EXISTS virtual_link VARCHAR(500); ALTER TABLE commissions ADD COLUMN IF NOT EXISTS grades_closed BOOLEAN DEFAULT FALSE; ALTER TABLE commissions ADD COLUMN IF NOT EXISTS grades_closed_at TIMESTAMP WITHOUT TIME ZONE; ALTER TABLE commissions ADD COLUMN IF NOT EXISTS grades_closed_by INTEGER REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE commissions ADD COLUMN IF NOT EXISTS acta_number VARCHAR(50); ALTER TABLE commissions ADD COLUMN IF NOT EXISTS academic_term_id INTEGER REFERENCES academic_terms(id) ON DELETE SET NULL; """)) # Columnas extendidas de reservations db.session.execute(text(""" ALTER TABLE reservations ADD COLUMN IF NOT EXISTS shift VARCHAR(30); ALTER TABLE reservations ADD COLUMN IF NOT EXISTS virtual_link VARCHAR(500); """)) # Tablas de soporte: audit_logs db.session.execute(text(""" CREATE TABLE IF NOT EXISTS audit_logs ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, user_email VARCHAR(255), action VARCHAR(50) NOT NULL, module VARCHAR(50) NOT NULL, entity_id INTEGER, details TEXT, ip_address VARCHAR(45), created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL ); CREATE INDEX IF NOT EXISTS ix_audit_logs_action ON audit_logs (action); CREATE INDEX IF NOT EXISTS ix_audit_logs_module ON audit_logs (module); CREATE INDEX IF NOT EXISTS ix_audit_logs_created_at ON audit_logs (created_at); """)) # Tablas de soporte: student_enrollments db.session.execute(text(""" CREATE TABLE IF NOT EXISTS student_enrollments ( id SERIAL PRIMARY KEY, student_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, commission_id INTEGER NOT NULL REFERENCES commissions(id) ON DELETE CASCADE, status VARCHAR(20) NOT NULL DEFAULT 'activo', enrolled_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL, updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(), notes TEXT, CONSTRAINT uq_student_commission UNIQUE (student_id, commission_id) ); CREATE INDEX IF NOT EXISTS ix_student_enrollments_student_id ON student_enrollments (student_id); CREATE INDEX IF NOT EXISTS ix_student_enrollments_commission_id ON student_enrollments (commission_id); """)) # Tablas de soporte: commission_teachers db.session.execute(text(""" CREATE TABLE IF NOT EXISTS commission_teachers ( id SERIAL PRIMARY KEY, commission_id INTEGER NOT NULL REFERENCES commissions(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, role VARCHAR(50) DEFAULT 'Titular' NOT NULL, is_primary BOOLEAN DEFAULT FALSE NOT NULL, created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(), CONSTRAINT uq_commission_teacher UNIQUE (commission_id, user_id) ); CREATE INDEX IF NOT EXISTS ix_commission_teachers_commission_id ON commission_teachers (commission_id); CREATE INDEX IF NOT EXISTS ix_commission_teachers_user_id ON commission_teachers (user_id); """)) # Tablas de soporte: system_settings db.session.execute(text(""" CREATE TABLE IF NOT EXISTS system_settings ( id SERIAL PRIMARY KEY, key VARCHAR(100) UNIQUE NOT NULL, value TEXT, description VARCHAR(255), category VARCHAR(50) DEFAULT 'system', is_encrypted BOOLEAN DEFAULT FALSE, updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS ix_system_settings_key ON system_settings (key); """)) # Tablas de soporte: moodle_sync_tasks db.session.execute(text(""" CREATE TABLE IF NOT EXISTS moodle_sync_tasks ( id SERIAL PRIMARY KEY, action VARCHAR(50) NOT NULL, entity_type VARCHAR(50) NOT NULL, entity_id VARCHAR(100), payload TEXT NOT NULL, status VARCHAR(20) DEFAULT 'PENDING', attempts INTEGER DEFAULT 0, max_attempts INTEGER DEFAULT 5, error_message TEXT, next_retry_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS ix_moodle_sync_tasks_action ON moodle_sync_tasks (action); CREATE INDEX IF NOT EXISTS ix_moodle_sync_tasks_status ON moodle_sync_tasks (status); CREATE INDEX IF NOT EXISTS ix_moodle_sync_tasks_next_retry_at ON moodle_sync_tasks (next_retry_at); """)) db.session.commit() else: try: cursor = db.session.connection().connection.cursor() cursor.execute("PRAGMA table_info(users)") existing_cols = [row[1] for row in cursor.fetchall()] for col_name, col_def in [ ('preferred_language', 'VARCHAR(10)'), ('theme_preference', "VARCHAR(10) DEFAULT 'auto'"), ('role_id', 'INTEGER'), ('first_name', 'VARCHAR(100)'), ('last_name', 'VARCHAR(100)'), ('phone', 'VARCHAR(50)'), ('personal_email', 'VARCHAR(255)'), ('address', 'VARCHAR(255)'), ('document_type', "VARCHAR(20) DEFAULT 'DNI'"), ('document_number', 'VARCHAR(30)') ]: if col_name not in existing_cols: cursor.execute(f"ALTER TABLE users ADD COLUMN {col_name} {col_def}") db.session.commit() except Exception: pass print("[OK] Esquema y tablas actualizadas con éxito.") # 3. Crear o sincronizar roles predeterminados y matriz de permisos print("[*] Configurando roles institucionales y permisos RBAC...") default_roles = [ { 'name': 'Admin', 'description': 'Control total institucional, infraestructura, gestion academica y accesos RBAC', 'is_system': True, 'permissions': { 'classrooms': 'read_write', 'buildings': 'read_write', 'reservations': 'read_write', 'schedule': 'read_write', 'academic': 'read_write', 'import': 'read_write', 'optimizer': 'read_write', 'users': 'read_write', 'milestone_types': 'read_write', 'metrics': 'read_write', 'enrollment': 'read_write' } }, { 'name': 'Docente', 'description': 'Gestion de reservas de cursada, consulta de cronograma y materias', 'is_system': False, 'permissions': { 'classrooms': 'read', 'buildings': 'read', 'reservations': 'read_write', 'schedule': 'read_write', 'academic': 'read', 'import': 'none', 'optimizer': 'read', 'users': 'none', 'milestone_types': 'read', 'metrics': 'none', 'enrollment': 'read' } }, { 'name': 'Bedelia', 'description': 'Gestion operativa de aulas, sedes, reservas, comisiones, importacion y cartelera', 'is_system': False, 'permissions': { 'classrooms': 'read_write', 'buildings': 'read_write', 'reservations': 'read_write', 'schedule': 'read_write', 'academic': 'read_write', 'import': 'read_write', 'optimizer': 'none', 'users': 'none', 'milestone_types': 'read_write', 'metrics': 'read_write', 'enrollment': 'read_write' } }, { 'name': 'Alumno', 'description': 'Acceso de solo lectura al cronograma general, asignaturas y aulas', 'is_system': False, 'permissions': { 'classrooms': 'read', 'buildings': 'read', 'reservations': 'read', 'schedule': 'read', 'academic': 'read', 'import': 'none', 'optimizer': 'none', 'users': 'none', 'milestone_types': 'read', 'metrics': 'none', 'enrollment': 'read' } } ] module_ids = [m['id'] for m in SYSTEM_MODULES] admin_role = None for r_data in default_roles: role = Role.query.filter_by(name=r_data['name']).first() if not role: role = Role( name=r_data['name'], description=r_data['description'], is_system=r_data['is_system'] ) db.session.add(role) db.session.flush() print(f" [+] Rol '{role.name}' creado.") else: role.description = r_data['description'] role.is_system = r_data['is_system'] if role.name == 'Admin': admin_role = role # Sincronizar permisos del rol for mod in module_ids: level = r_data['permissions'].get(mod, 'none') role.set_permission(mod, level) db.session.commit() print("[OK] Roles y matriz de permisos configurados correctamente.") # 3b. Deduplicar rol Bedelia con acento (Bedelía) si existe print("[*] Verificando duplicados del rol Bedelia...") bedelia_accented = Role.query.filter( Role.name.in_(['Bedelía', 'Bedel\u00eda', 'BEDELÍA']) ).first() bedelia_canonical = Role.query.filter_by(name='Bedelia').first() if bedelia_accented and bedelia_canonical and bedelia_accented.id != bedelia_canonical.id: affected = User.query.filter_by(role_id=bedelia_accented.id).all() for u in affected: u.role_id = bedelia_canonical.id u.role = 'BEDELIA' print(f" [~] Usuario {u.email} migrado de '{bedelia_accented.name}' a 'Bedelia'") db.session.flush() db.session.delete(bedelia_accented) db.session.commit() print(f" [OK] Rol duplicado '{bedelia_accented.name}' eliminado. {len(affected)} usuario(s) migrado(s).") else: print(" [OK] Sin duplicados de Bedelia.") # 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: print("[*] Verificando usuarios institucionales y credenciales...") roles_by_name = {r.name: r for r in Role.query.all()} institutional_users = [ { 'email': 'admin@edu-space.com', 'name': 'Administrador del Sistema', 'role_name': 'Admin', 'role_code': 'ADMIN' }, { 'email': 'bedelia@edu-space.com', 'name': 'Operador de Bedelía', 'role_name': 'Bedelia', 'role_code': 'BEDELIA' }, { 'email': 'docente@edu-space.com', 'name': 'Profesor Titular', 'role_name': 'Docente', 'role_code': 'DOCENTE' }, { 'email': 'alumno@edu-space.com', 'name': 'Estudiante Regular', 'role_name': 'Alumno', 'role_code': 'ALUMNO' } ] for u_info in institutional_users: u_obj = User.query.filter_by(email=u_info['email']).first() r_match = roles_by_name.get(u_info['role_name']) if not u_obj: u_obj = User( email=u_info['email'], name=u_info['name'], role=u_info['role_code'], role_id=r_match.id if r_match else None, is_active=True, preferred_language="es", theme_preference="auto" ) u_obj.set_password("admin123") db.session.add(u_obj) db.session.commit() print(f" [+] Usuario creado: {u_info['email']} | Clave: admin123 | Rol: {u_info['role_name']}") else: # Preservar contraseña y estado si ya existen para no pisar cambios administrativos if r_match and u_obj.role_id != r_match.id: u_obj.role_id = r_match.id if not u_obj.role: u_obj.role = u_info['role_code'] if not u_obj.password_hash: u_obj.set_password("admin123") if not u_obj.preferred_language: u_obj.preferred_language = "es" if not u_obj.theme_preference: u_obj.theme_preference = "auto" db.session.commit() print(f" [OK] Usuario {u_info['email']} validado (Rol: {u_info['role_name']}).") # 5. Inicializar edificios institucionales oficiales 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.") 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...") from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES for item in BASE_MILESTONE_TYPES: mt = MilestoneType.query.filter_by(code=item['code']).first() if not mt: mt = MilestoneType( name=item['name'], code=item['code'], color=item['color'], description=item['description'], sort_order=item['sort_order'], is_active=True ) db.session.add(mt) else: if not mt.color: mt.color = item['color'] if not mt.sort_order: mt.sort_order = item['sort_order'] db.session.commit() print(f"[OK] Catalogo de tipificaciones inicializado ({MilestoneType.query.count()} tipos disponibles).") # 7. Inicializar aulas, materias, comisiones y reservas de muestra 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 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(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() 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.") 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.") except Exception as e: # Safe print to avoid UnicodeEncodeError in exception strings try: print(f"\n[ERROR] Error al inicializar la base de datos: {str(e)}") except UnicodeEncodeError: print("\n[ERROR] Error al inicializar la base de datos (se omiten caracteres no-ascii).") print("\nVerifica que:") print("1. El servicio de PostgreSQL este corriendo.") print("2. La base de datos 'classrooms_db' haya sido creada.") print("3. La variable DATABASE_URL en el archivo .env tenga el usuario y contraseña correctos.") sys.exit(1) if __name__ == "__main__": init_database()