From d1d4be04285db9ac30ee267f459140dd95a8aefb Mon Sep 17 00:00:00 2001 From: Carlos Tello Date: Wed, 2 Sep 2026 23:45:13 -0300 Subject: [PATCH] feat(i18n & ux): auditoria, traduccion completa, correcciones ux y gestion de idioma en perfil --- .env.example | 24 + .vscode/settings.json | 6 + CHANGELOG_SESSION.md | 84 +++ DEPLOYMENT_AUDIT.md | 443 +++++++++++++ app.py | 1 + app/__init__.py | 40 +- app/forms/auth.py | 9 +- app/forms/classroom.py | 43 +- app/forms/reservation.py | 17 +- app/models/user.py | 1 + app/routes/auth.py | 17 +- app/routes/classrooms.py | 1 + app/routes/main.py | 5 + app/templates/auth/403.html | 26 + app/templates/auth/change_password.html | 62 ++ app/templates/auth/login.html | 54 +- app/templates/auth/profile.html | 161 +++++ app/templates/base.html | 43 +- app/templates/classrooms/add.html | 45 +- app/templates/classrooms/list.html | 50 +- app/templates/dashboard.html | 40 +- app/templates/genetic_optimizer.html | 312 ++++----- app/templates/partials/language_switcher.html | 4 +- app/templates/schedule/calendar.html | 51 +- app/templates/schedule/commissions.html | 6 +- app/templates/schedule/today.html | 36 +- babel.cfg | 4 +- config/config.py | 4 +- init_db.py | 43 ++ translations/en/LC_MESSAGES/messages.po | 623 +++++++++++++++++- translations/es/LC_MESSAGES/messages.po | 589 ++++++++++++++++- 31 files changed, 2356 insertions(+), 488 deletions(-) create mode 100644 .env.example create mode 100644 .vscode/settings.json create mode 100644 CHANGELOG_SESSION.md create mode 100644 DEPLOYMENT_AUDIT.md create mode 100644 app/templates/auth/403.html create mode 100644 app/templates/auth/change_password.html create mode 100644 app/templates/auth/profile.html create mode 100644 init_db.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ca9acf2 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# ============================================================================== +# Edu-Space Admin - Plantilla de Variables de Entorno +# Copie este archivo como .env y complete con valores de producción seguros +# ============================================================================== + +# Entorno Flask +FLASK_APP=app.py +FLASK_ENV=production + +# Seguridad: Clave secreta para sesiones y protección CSRF +# Generar una clave aleatoria con: python -c 'import secrets; print(secrets.token_hex(32))' +SECRET_KEY=change-this-to-a-secure-random-secret-key-in-production + +# Base de Datos PostgreSQL +DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db +DB_USERNAME=postgres +DB_PASSWORD=password + +# Integración con API externa (Microservicio Spring Boot) +API_BASE_URL=http://localhost:8080/api + +# Configuración de Internacionalización (Babel) +BABEL_DEFAULT_LOCALE=es +BABEL_DEFAULT_TIMEZONE=America/Argentina/Buenos_Aires diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5a0431e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}\\venv\\Scripts\\python.exe", + "python.analysis.extraPaths": [ + "${workspaceFolder}\\venv\\Lib\\site-packages" + ] +} diff --git a/CHANGELOG_SESSION.md b/CHANGELOG_SESSION.md new file mode 100644 index 0000000..e812aba --- /dev/null +++ b/CHANGELOG_SESSION.md @@ -0,0 +1,84 @@ +# Resumen de Cambios - Sesión de Desarrollo (Rama `dev`) + +**Fecha:** 2026-09-02 / 2026-09-03 +**Proyecto:** Admin Edu-Space (Flask + PostgreSQL) +**Rama:** `dev` + +--- + +## 1. Auditoría Git y Puesta en Producción +* **Informe Técnico Completo (`DEPLOYMENT_AUDIT.md`):** + * Análisis de ramas locales y remotas (`main`, `dev`, `origin/planning`, `origin/commissions`, `origin/spanish`). + * Manual de despliegue en producción con arquitectura WSGI (Gunicorn), Nginx como proxy inverso, Systemd unit y contenedorización Docker / Docker Compose. +* **Archivos Base:** + * Creación de `.env.example` con todas las variables requeridas (PostgreSQL, Flask, Secret Key, Babel). + * Creación de `init_db.py` para bootstrap inicial seguro de base de datos y usuario administrador por defecto (`admin@edu-space.com` / `admin123`). + +--- + +## 2. Base de Datos y Modelos +* **Preferencia de Idioma Persistente:** + * Migración en PostgreSQL agregando la columna `preferred_language VARCHAR(10)` a la tabla `users`. + * Actualización del modelo `User` en `app/models/user.py` con el campo `preferred_language`. + +--- + +## 3. Implementación de Vistas y Plantillas Faltantes +* **Perfil de Usuario (`app/templates/auth/profile.html`):** + * Vista completa con datos del usuario, rol, estado, fecha de registro y reservas recientes. + * Formulario interactivo para configurar la **Preferencia de Idioma** (*Detectar automáticamente del navegador*, *Español*, *English*). +* **Cambio de Contraseña (`app/templates/auth/change_password.html`):** + * Formulario seguro para actualización de credenciales. +* **Manejo de Errores (`app/templates/auth/403.html`):** + * Pantalla de error 403 Forbidden estilizada. + +--- + +## 4. Correcciones de UX y Bugs de Rutas +* **Navbar Limpio (`app/templates/base.html`):** + * Eliminación del bloque duplicado del menú de usuario `System Administrator`. +* **Corrección de BuildError en Comisiones:** + * En `app/routes/classrooms.py`, se agregó el alias de ruta `@classrooms_bp.route('/', endpoint='index')` para resolver enlaces a `classrooms.index`. + * En `app/templates/schedule/commissions.html`, se reemplazó el filtro inválido de Django `truncatewords(20)` por el filtro estándar de Jinja2 `truncate(100)`. +* **Corrección Visual en `/genetic-optimizer` (`app/templates/genetic_optimizer.html`):** + * Se agregó el espaciado `pt-5 mt-4` evitando que la barra de navegación fija superior (`fixed-top`) se monte sobre el contenido. + * Rediseño de tarjeta hero con contraste óptimo y reemplazo de iconos faltantes por Bootstrap Icons (`bi bi-...`). +* **Corrección en Calendario (`app/templates/schedule/calendar.html`):** + * Inclusión de contenedor con espaciado superior y bundle de localización `locales-all.min.js` para FullCalendar. + +--- + +## 5. Internacionalización (i18n) y Localización al Español +* **Configuración del Motor Babel (`config/config.py` y `babel.cfg`):** + * Configuración de `BABEL_TRANSLATION_DIRECTORIES` con ruta absoluta. + * Actualización del método de extracción Jinja2 en `babel.cfg`. +* **Formularios con Traducción Lazy (`lazy_gettext`):** + * `app/forms/auth.py` (`LoginForm`). + * `app/forms/classroom.py` (`ClassroomForm`, `ClassroomResourceForm`). + * `app/forms/reservation.py` (`ReservationForm`). +* **Traducción Integral de Plantillas:** + * `app/templates/auth/login.html`: Pantalla de login traducida y con selector de idioma visible. + * `app/templates/dashboard.html`: Todas las tarjetas estadísticas, acciones rápidas y tablas. + * `app/templates/classrooms/list.html` y `add.html`: Filtros, estados vacíos, cabeceras y formularios. + * `app/templates/schedule/today.html`: Horario del día, métricas y botones. + * `app/templates/schedule/calendar.html`: Vistas, leyendas y eventos. + * `app/templates/genetic_optimizer.html`: Estado del algoritmo y controles. + * `app/templates/partials/language_switcher.html`: Muestra "Español" en lugar de "Spanish". +* **Compilación de Catálogos:** + * Compilación binaria de `messages.po` a `messages.mo` en `translations/es/` y `translations/en/` con 0 mensajes pendientes. + +--- + +## 6. Lógica de Detección y Precedencia de Idiomas +En `app/__init__.py` (`get_locale()`): +1. **Parámetro URL (`?lang=`):** Máxima prioridad para cambio explícito. +2. **Preferencia en Perfil del Usuario (`current_user.preferred_language`):** Sobrescribe la configuración del navegador en toda la sesión si el usuario está autenticado. +3. **Preferencia de Sesión (`session['language']`):** Cambio manual mediante el dropdown. +4. **Detección Automática del Navegador (`request.accept_languages.best_match()`):** Valor por defecto para visitantes no autenticados y pantalla de login. +* Al cerrar sesión (`logout`), se limpia la sesión para que el login use el navegador. +* Al cambiar el idioma en el selector siendo usuario autenticado, se sincroniza automáticamente con su perfil en base de datos. + +--- + +## 7. Configuración del Entorno de Desarrollo +* Se creó `.vscode/settings.json` apuntando al intérprete del entorno virtual (`venv\Scripts\python.exe`), solucionando falsos positivos del analizador estático para `flask_wtf` y `flask_babel`. diff --git a/DEPLOYMENT_AUDIT.md b/DEPLOYMENT_AUDIT.md new file mode 100644 index 0000000..afae7bd --- /dev/null +++ b/DEPLOYMENT_AUDIT.md @@ -0,0 +1,443 @@ +# Auditoría Técnica y DevOps: Git, Arquitectura y Puesta en Producción + +**Repositorio:** `admin-edu-space` +**Rol:** Tech Lead / DevOps Lead +**Fecha de Auditoría:** 2 de Septiembre de 2026 +**Entorno de Auditoría:** Windows / Git 2.x / Python 3.12.4 + +--- + +## Resumen Ejecutivo + +El presente documento constituye la auditoría integral de código, gestión de versiones (Git), arquitectura de software y preparación para producción del proyecto **Edu-Space Admin Panel** (`admin-edu-space`). + +### Conclusiones Principales: +1. **Rama seleccionada para despliegue:** **`main`** (commit `3d4e999`). Es la única rama que integra la totalidad de los cambios, bugfixes y features desarrollados en las ramas remotas (`planning`, `commissions`, `spanish`). No existen ramas divergentes sin fusionar. +2. **Nivel de Madurez CI/CD:** **Crítico (0% Automatización)**. No existen flujos de trabajo en GitHub Actions, GitLab CI ni validación de pruebas automatizadas previas al merge. +3. **Seguridad & Configuración:** **Riesgo Alto**. El archivo `.env` se encuentra actualmente trackeado en el repositorio Git con credenciales por defecto. Se requiere eliminarlo del índice, generar `.env.example` y rotar credenciales. +4. **Infraestructura de Despliegue:** El repositorio carece de `Dockerfile`, `docker-compose.yml`, scripts de systemd y no incluye un servidor WSGI de producción (`gunicorn`) en `requirements.txt`. El paso a producción requiere los artefactos y procedimientos estandarizados que se detallan en este informe. + +--- + +# Fase 1: Análisis de Ramas (Git Audit) + +## 1.1 Inventario de Ramas y Commits + +Se ejecutó la inspección exhaustiva de referencias locales y remotas: + +```bash +git fetch --all +git branch -a --sort=-committerdate +git for-each-ref --format='%(refname:short) | %(committerdate:iso) | %(subject) | %(authorname)' refs/heads refs/remotes +``` + +### Tabla Comparativa de Ramas: + +| Rama | Tipo | Último Commit | Fecha del Commit | Autor | Estado respecto a `main` | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **`main`** | Local / Remota | `3d4e999` | 2026-04-17 18:18:00 -0300 | `ale-vz` | **Rama Primaria (HEAD)** | +| **`origin/planning`** | Remota | `ec977bf` | 2026-04-17 18:14:59 -0300 | `Alejandro Vazquez` | **100% Fusionada** (Merge PR #2 en `3d4e999`) | +| **`origin/commissions`**| Remota | `18f1d3a` | 2026-03-30 17:37:53 -0300 | `Alejandro Vazquez` | **100% Fusionada** (Incluida en historia de `planning`) | +| **`origin/spanish`** | Remota | `9af25df` | 2026-03-30 17:11:13 -0300 | `Alejandro Vazquez` | **100% Fusionada** (Merge PR #1 en `d4281cd` y PR #2) | + +### Verificación de Divergencias: +Al ejecutar `git branch -r --no-merged main`, el resultado es **vacío**. +Al verificar conteo simétrico `git rev-list --left-right --count ...main`: +- `origin/planning...main`: `0 2` (0 commits pendientes, 2 commits por delante en `main` por los merges). +- `origin/commissions...main`: `0 6` (0 commits pendientes, 6 commits por delante en `main`). +- `origin/spanish...main`: `0 7` (0 commits pendientes, 7 commits por delante en `main`). + +### Tags y Releases: +- `git tag -l`: **Vacío**. No existen versiones etiquetadas (e.g. `v1.0.0`, `v0.1.0`) ni releases formales en el historial. + +## 1.2 Auditoría de CI/CD y Calidad de Código +- **Workflows automatizados:** Inexistentes. No hay directorio `.github/workflows/`, ni `.gitlab-ci.yml`, ni archivo de integración continua de ningún proveedor. +- **Validación de Tests en Pull Requests:** Los merges de los Pull Requests `#1` y `#2` se realizaron sin puertas de enlace automatizadas (quality gates). +- **Cobertura de pruebas en repo:** Existe únicamente `tests/test_genetic_algorithm.py` enfocado en el motor heurístico, pero no se ejecuta en ningún pipeline. + +## 1.3 Dictamen Conclusivo de Rama para Despliegue + +> ### 🏆 Rama Determinada: **`main`** (Commit `3d4e999`) +> +> **Justificación Técnica:** +> 1. **Consolidación completa:** `main` agrupa de forma lineal y validada todas las funcionalidades de las ramas temáticas: +> - Incorpora las traducciones y arreglos de internacionalización (`spanish`). +> - Incorpora la lógica y vistas de comisiones (`commissions`). +> - Incorpora el motor de optimización de aulas y correcciones de UI (`planning`). +> 2. **Cero pérdida de código (No regressions):** No existe ningún commit en las ramas remotas que no esté ya contenido en `main`. +> 3. **Estabilidad comprobada:** Resuelve errores críticos de SQLAlchemy con carga ansiosa (`joinedload`) documentados en la evolución del repositorio. + +--- + +# Fase 2: Informe Técnico del Proyecto + +## 2.1 Stack Tecnológico y Propósito del Sistema + +### Propósito +**Edu-Space Admin Panel** es una interfaz web de administración para la gestión, reserva y optimización algorítmica de aulas universitarias/académicas. El sistema permite: +- Gestionar edificios, aulas, capacidad física y recursos técnicos (proyectores, computadoras). +- Visualizar horarios y agendas en tiempo real mediante calendarios interactivos. +- Administrar materias y comisiones docentes. +- Asignar aulas automáticamente utilizando un **Algoritmo Genético** que evalúa aforo, prioridades horarias y resuelve conflictos de superposición de reservas. + +### Stack de Tecnologías + +| Componente | Tecnología | Versión | Propósito / Responsabilidad | +| :--- | :--- | :--- | :--- | +| **Lenguaje Base** | Python | `>=3.8` (Óptimo: `3.11` / `3.12`) | Runtime principal de ejecución | +| **Framework Web** | Flask | `3.0.0` | Arquitectura web basada en Application Factory | +| **Capa ORM** | SQLAlchemy / Flask-SQLAlchemy | `2.0.23` / `3.1.1` | Modelado de datos relacional y queries | +| **Driver de BD** | psycopg2-binary | `2.9.9` | Conector nativo de alto rendimiento a PostgreSQL | +| **Migraciones** | Flask-Migrate | `4.0.5` | Wrapper de Alembic (requiere inicialización) | +| **Autenticación** | Flask-Login / Werkzeug | `0.6.3` / `3.0.1` | Manejo de sesiones, roles y hash `pbkdf2:sha256` | +| **Formularios & CSRF** | Flask-WTF / WTForms | `1.2.1` / `3.1.0` | Validación y protección contra ataques CSRF | +| **Internacionalización** | Flask-Babel / Babel | `4.0.0` / `2.14.0` | Detección y renderizado multilingüe (ES / EN) | +| **Frontend** | Bootstrap 5, FullCalendar, Chart.js | Bundled | UI responsiva, calendarios y estadísticas | +| **Microservicio Backend**| Spring Boot (Java) | `8080` (Externo) | API de negocio y persistencia central (opcional/compartida) | + +### Arquitectura de Componentes y Flujo de Datos + +```mermaid +graph TD + Client[Navegador / Cliente Web] -->|HTTP / HTTPS| Nginx[Nginx Reverse Proxy & SSL] + Nginx -->|WSGI Proxy :5000| Gunicorn[Gunicorn WSGI Server] + Gunicorn --> App[Flask App Factory: create_app] + + subgraph "Flask Application Blueprints" + App --> AuthBP[auth_bp: /login, /logout, /profile] + App --> MainBP[main_bp: /dashboard, /api/dashboard-stats] + App --> ClassroomsBP[classrooms_bp: /classrooms] + App --> ScheduleBP[schedule_bp: /schedule] + App --> GeneticBP[genetic_bp: /api/genetic & /genetic-optimizer] + end + + subgraph "Core Engine" + GeneticBP --> GAEngine[Algoritmo Genético: ReservationOptimizer] + GAEngine --> FitnessEval[Evaluación: Aforo 40%, Horario 30%, Conflicto 20%] + end + + subgraph "Persistencia & Servicios" + App --> SQLAlchemy[SQLAlchemy ORM + joinedload] + SQLAlchemy --> PostgreSQL[(PostgreSQL 14+ / classrooms_db)] + App -.->|Integración Configurada| SpringBoot[Spring Boot API :8080/api] + end +``` + +--- + +## 2.2 Requisitos de Entorno y Variables de Configuración + +### ⚠️ Hallazgos Críticos de Seguridad +1. **Archivo `.env` versionado:** Se encontró el archivo `.env` físico bajo control de versiones Git con contraseñas en texto plano (`password`). + - *Acción correctiva obligatoria:* Ejecutar `git rm --cached .env`, añadirlo a `.gitignore` y rotar todas las credenciales antes de salir a producción. +2. **Traducciones no compiladas:** El archivo `.gitignore` excluye deliberadamente `*.mo`. Por ende, el paso de build debe compilar los `.po` mediante `pybabel compile -d translations`. + +### Especificación de Variables de Entorno (`.env.example`) + +```env +# ============================================================================== +# Edu-Space Admin - Configuración de Entorno de Producción +# ============================================================================== + +# Entorno Flask +FLASK_APP=app.py +FLASK_ENV=production +DEBUG=False + +# Seguridad: Clave secreta para cookies de sesión y CSRF (Mínimo 64 caracteres aleatorios) +# Generar con: python -c 'import secrets; print(secrets.token_hex(32))' +SECRET_KEY=generate-a-strong-random-secret-key-for-production-use + +# Conexión a Base de Datos PostgreSQL +# Formato: postgresql://:@:/ +DATABASE_URL=postgresql://eduspace_user:StrongProductionPassword123!@db-host:5432/classrooms_db + +# Parámetros de compatibilidad si aplica +DB_USERNAME=eduspace_user +DB_PASSWORD=StrongProductionPassword123! + +# Integración con API externa (Spring Boot) +API_BASE_URL=http://springboot-service:8080/api + +# Configuración de Internacionalización y Carga de Archivos +BABEL_DEFAULT_LOCALE=es +BABEL_DEFAULT_TIMEZONE=America/Argentina/Buenos_Aires +MAX_CONTENT_LENGTH=16777216 +``` + +--- + +## 2.3 Puesta en Producción (Deployment Determinado) + +### 2.3.1 Estrategia de Contenedores (Docker + Docker Compose) + +Para garantizar un despliegue repetible e inmutable, se especifica el contenedor de producción: + +#### `Dockerfile` (Multi-stage / Producción) +```dockerfile +# Stage 1: Build de dependencias y compilación de traducciones +FROM python:3.11-slim AS builder + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir --user -r requirements.txt && \ + pip install --no-cache-dir --user gunicorn==21.2.0 + +COPY . . +# Compilar catálogos de traducción i18n +RUN python -m babel.messages.frontend compile -d translations + +# Stage 2: Runtime limpio y seguro +FROM python:3.11-slim AS runner + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Crear usuario sin privilegios +RUN useradd -m -u 1001 appuser + +COPY --from=builder /root/.local /home/appuser/.local +COPY --from=builder --chown=appuser:appuser /app /app + +ENV PATH=/home/appuser/.local/bin:$PATH +ENV PYTHONUNBUFFERED=1 +ENV FLASK_ENV=production + +USER appuser + +EXPOSE 5000 + +# Healthcheck interno del contenedor +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:5000/login || exit 1 + +CMD ["gunicorn", "--workers=4", "--threads=2", "--bind=0.0.0.0:5000", "--access-logfile=-", "--error-logfile=-", "app:create_app()"] +``` + +#### `docker-compose.yml` (Stack Completo con PostgreSQL y Nginx) +```yaml +version: '3.8' + +services: + db: + image: postgres:15-alpine + container_name: eduspace_db + restart: always + environment: + POSTGRES_DB: classrooms_db + POSTGRES_USER: eduspace_user + POSTGRES_PASSWORD: StrongProductionPassword123! + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U eduspace_user -d classrooms_db"] + interval: 10s + timeout: 5s + retries: 5 + + web: + build: + context: . + dockerfile: Dockerfile + container_name: eduspace_admin + restart: always + depends_on: + db: + condition: service_healthy + env_file: + - .env + expose: + - "5000" + + nginx: + image: nginx:alpine + container_name: eduspace_proxy + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./app/static:/app/static:ro + depends_on: + - web + +volumes: + pgdata: +``` + +### 2.3.2 Despliegue en Máquina Virtual Linux (Systemd + Gunicorn + Nginx) + +Si el despliegue se efectúa en un servidor Linux (Ubuntu/Debian) sin Docker: + +#### 1. Pasos de Instalación y Preparación: +```bash +# 1. Clonar rama main +git clone -b main /var/www/admin-edu-space +cd /var/www/admin-edu-space + +# 2. Configurar entorno virtual +python3.11 -m venv venv +source venv/bin/activate + +# 3. Instalar dependencias del proyecto + Servidor WSGI +pip install --upgrade pip +pip install -r requirements.txt +pip install gunicorn==21.2.0 + +# 4. Configurar variables de entorno seguras +cp .env.example .env +chmod 600 .env +nano .env # Ajustar SECRET_KEY y DATABASE_URL + +# 5. Compilar traducciones +pybabel compile -d translations + +# 6. Inicializar esquema de base de datos (si la base está vacía) +python -c "from app import create_app, db; app = create_app(); app.app_context().push(); db.create_all()" +``` + +#### 2. Configuración de Servicio Systemd (`/etc/systemd/system/eduspace.service`): +```ini +[Unit] +Description=Edu-Space Admin Gunicorn Daemon +After=network.target postgresql.service + +[Service] +User=www-data +Group=www-data +WorkingDirectory=/var/www/admin-edu-space +Environment="PATH=/var/www/admin-edu-space/venv/bin" +EnvironmentFile=/var/www/admin-edu-space/.env +ExecStart=/var/www/admin-edu-space/venv/bin/gunicorn \ + --workers 4 \ + --threads 2 \ + --bind 127.0.0.1:5000 \ + --access-logfile /var/log/eduspace/access.log \ + --error-logfile /var/log/eduspace/error.log \ + "app:create_app()" + +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +#### 3. Configuración del Reverse Proxy Nginx (`/etc/nginx/sites-available/eduspace`): +```nginx +server { + listen 80; + server_name admin.edu-space.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name admin.edu-space.com; + + ssl_certificate /etc/letsencrypt/live/admin.edu-space.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/admin.edu-space.com/privkey.pem; + + client_max_body_size 16M; + + # Entrega directa de archivos estáticos por Nginx + location /static/ { + alias /var/www/admin-edu-space/app/static/; + expires 30d; + add_header Cache-Control "public, no-transform"; + } + + # Proxy hacia el daemon Gunicorn + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + } +} +``` + +--- + +## 2.4 Verificación, Monitoreo y Health Checks + +### 2.4.1 Endpoint de Verificación Recomendado (`/healthz`) +Actualmente el proyecto solo cuenta con endpoints protegidos por autenticación (`@login_required`). Para orquestadores (Kubernetes, AWS ALB, Docker), se debe incorporar una ruta pública de verificación en `app/routes/main.py`: + +```python +@main_bp.route('/healthz') +def healthz(): + """Health check endpoint para balanceadores de carga y monitoreo""" + try: + # Validar conectividad con PostgreSQL + db.session.execute(db.text('SELECT 1')) + return jsonify({ + 'status': 'HEALTHY', + 'database': 'CONNECTED', + 'timestamp': datetime.utcnow().isoformat() + }), 200 + except Exception as e: + return jsonify({ + 'status': 'UNHEALTHY', + 'database': str(e), + 'timestamp': datetime.utcnow().isoformat() + }), 503 +``` + +### 2.4.2 Pruebas de Humo (Smoke Tests) Post-Despliegue + +Ejecutar la siguiente suite de verificación inmediata tras desplegar: + +```bash +# 1. Comprobación de servicio y respuesta HTTP +curl -I -k https://admin.edu-space.com/login +# Respuesta esperada: HTTP/1.1 200 OK + +# 2. Comprobación del Health Endpoint +curl -s https://admin.edu-space.com/healthz | jq . +# Respuesta esperada: {"status": "HEALTHY", "database": "CONNECTED"} + +# 3. Comprobación de entrega de estáticos +curl -I https://admin.edu-space.com/static/css/style.css +# Respuesta esperada: HTTP/1.1 200 OK (o 304 Not Modified) + +# 4. Comprobación de logs de Gunicorn +sudo journalctl -u eduspace.service -n 50 --no-pager +``` + +### 2.4.3 Matriz de Monitoreo y Observabilidad + +| Dimensión | Métrica Clave | Umbral de Alerta | Acción Correctiva | +| :--- | :--- | :--- | :--- | +| **Disponibilidad** | Tasa de Respuestas HTTP 5xx | `> 1%` en 5 min | Inspeccionar `error.log` de Gunicorn; revisar pool de conexiones BD | +| **Latencia** | Tiempo de respuesta P95 | `> 1.5s` en endpoints | Revisar índices en tablas `reservations` y `classrooms` | +| **Base de Datos** | Pool de conexiones agotado | Conexiones activas `> 85%` | Aumentar `max_overflow` en SQLAlchemy o activar PgBouncer | +| **Optimización** | Tiempo de ejecución de Algoritmo Genético | `> 10s` en `/api/genetic/optimize` | Limitar rango de comisiones enviadas o desacoplar a Celery/Redis | + +--- + +## 2.5 Roadmap de Mejoras DevOps & Calidad (Tech Lead Recommendations) + +1. **Implementar CI/CD con GitHub Actions:** + - Crear `.github/workflows/ci.yml` para ejecutar linting (`flake8` / `ruff`) y la suite de pruebas `unittest` en cada PR. +2. **Desacoplar `.env` de Git:** + - Remover `.env` del repositorio (`git rm --cached .env`) e incorporar `python-dotenv` seguro mediante inyección de secretos en el pipeline. +3. **Gestión Formal de Migraciones:** + - Ejecutar `flask db init` y generar el primer script de migración versionado en `migrations/versions/`. +4. **Asincronía para el Algoritmo Genético:** + - Actualmente, la optimización heurística se ejecuta de forma sincrónica en el worker HTTP. Si el volumen de comisiones crece, puede bloquear el worker de Gunicorn (timeout 30s). Se recomienda migrar la ejecución pesada a una cola asíncrona con **Celery** o **Redis Queue (RQ)**. +5. **Alinear Rutas Duplicadas:** + - Resolver la colisión de ruta entre `main_bp.route('/genetic-optimizer')` y `genetic_web_bp.route('/genetic-optimizer')`. diff --git a/app.py b/app.py index 3536bfc..6eeeb8a 100644 --- a/app.py +++ b/app.py @@ -3,4 +3,5 @@ from app import create_app app = create_app() if __name__ == '__main__': + # Server entrypoint with auto-reload (locale auto-detection & profile override) app.run(debug=True, host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index ad7c9ec..84e97c2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -41,28 +41,34 @@ def create_app(config_class=Config): app.register_blueprint(genetic_bp) app.register_blueprint(genetic_web_bp) -# Babel language selector + # Language selector function + def get_locale(): + # 1. Check if language is explicitly set in URL parameters + lang = request.args.get('lang') + if lang and lang in app.config['LANGUAGES']: + return lang + + # 2. Check if user is authenticated and has a configured language in profile + from flask_login import current_user + if current_user.is_authenticated and getattr(current_user, 'preferred_language', None): + if current_user.preferred_language in app.config['LANGUAGES']: + return current_user.preferred_language + + # 3. Check if language is stored in session (e.g. manual switcher) + if 'language' in session and session['language'] in app.config['LANGUAGES']: + return session['language'] + + # 4. Check browser language preference by default (especially on login/guest) + return request.accept_languages.best_match(app.config['LANGUAGES']) or app.config['BABEL_DEFAULT_LOCALE'] + + # Babel context processor @app.context_processor def inject_conf_vars(): return dict( languages=app.config['LANGUAGES'], - current_lang=session.get('language', request.accept_languages.best_match(app.config['LANGUAGES']) or app.config['BABEL_DEFAULT_LOCALE']) + current_lang=str(get_locale()) ) - - # Language selector function - def get_locale(): - # 1. Check if language is explicitly set in URL parameters - if request.args.get('lang'): - lang = request.args.get('lang') - if lang in app.config['LANGUAGES']: - return lang - - # 2. Check if language is stored in session - if 'language' in session: - return session['language'] - - # 3. Check browser language preference - return request.accept_languages.best_match(app.config['LANGUAGES']) or app.config['BABEL_DEFAULT_LOCALE'] + # Configure Babel babel.init_app(app, locale_selector=get_locale) diff --git a/app/forms/auth.py b/app/forms/auth.py index 8122e48..dd27ad9 100644 --- a/app/forms/auth.py +++ b/app/forms/auth.py @@ -1,13 +1,14 @@ from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms.validators import DataRequired, Email, Length +from flask_babel import lazy_gettext as _l class LoginForm(FlaskForm): - email = StringField('Email', validators=[ + email = StringField(_l('Email'), validators=[ DataRequired(), Email(), Length(max=120) ]) - password = PasswordField('Password', validators=[DataRequired()]) - remember_me = BooleanField('Remember me') - submit = SubmitField('Sign In') \ No newline at end of file + password = PasswordField(_l('Password'), validators=[DataRequired()]) + remember_me = BooleanField(_l('Remember me')) + submit = SubmitField(_l('Sign In')) \ No newline at end of file diff --git a/app/forms/classroom.py b/app/forms/classroom.py index 1a57377..021fe57 100644 --- a/app/forms/classroom.py +++ b/app/forms/classroom.py @@ -1,44 +1,45 @@ from flask_wtf import FlaskForm from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField, SelectField from wtforms.validators import DataRequired, Length, NumberRange, Optional +from flask_babel import lazy_gettext as _l class ClassroomForm(FlaskForm): - # campus_id = SelectField('Campus', coerce=int, validators=[DataRequired()]) - building = StringField('Building', validators=[ + # campus_id = SelectField(_l('Campus'), coerce=int, validators=[DataRequired()]) + building = StringField(_l('Building'), validators=[ DataRequired(), Length(min=2, max=100) ]) - room_number = StringField('Room Number', validators=[ + room_number = StringField(_l('Room Number'), validators=[ DataRequired(), Length(min=1, max=50) ]) - floor = IntegerField('Floor', validators=[ + floor = IntegerField(_l('Floor'), validators=[ DataRequired(), NumberRange(min=1, max=50) ]) - capacity = IntegerField('Capacity', validators=[ + capacity = IntegerField(_l('Capacity'), validators=[ DataRequired(), NumberRange(min=1, max=500) ]) - description = TextAreaField('Description', validators=[ + description = TextAreaField(_l('Description'), validators=[ Optional(), Length(max=500) ]) - is_active = BooleanField('Active', default=True) - submit = SubmitField('Save Classroom') + is_active = BooleanField(_l('Active'), default=True) + submit = SubmitField(_l('Save Classroom')) class ClassroomResourceForm(FlaskForm): - resource_type = SelectField('Resource Type', choices=[ - ('PROJECTOR', 'Projector'), - ('WHITEBOARD', 'Whiteboard'), - ('COMPUTER', 'Computer'), - ('SOUND_SYSTEM', 'Sound System'), - ('MICROPHONE', 'Microphone'), - ('AIR_CONDITIONER', 'Air Conditioner'), - ('OTHER', 'Other') + resource_type = SelectField(_l('Resource Type'), choices=[ + ('PROJECTOR', _l('Projector')), + ('WHITEBOARD', _l('Whiteboard')), + ('COMPUTER', _l('Computer')), + ('SOUND_SYSTEM', _l('Sound System')), + ('MICROPHONE', _l('Microphone')), + ('AIR_CONDITIONER', _l('Air Conditioner')), + ('OTHER', _l('Other')) ], validators=[DataRequired()]) - brand = StringField('Brand', validators=[Optional(), Length(max=100)]) - model = StringField('Model', validators=[Optional(), Length(max=100)]) - specifications = TextAreaField('Specifications', validators=[Optional()]) - functional = BooleanField('Functional', default=True) - submit = SubmitField('Save Resource') \ No newline at end of file + brand = StringField(_l('Brand'), validators=[Optional(), Length(max=100)]) + model = StringField(_l('Model'), validators=[Optional(), Length(max=100)]) + specifications = TextAreaField(_l('Specifications'), validators=[Optional()]) + functional = BooleanField(_l('Functional'), default=True) + submit = SubmitField(_l('Save Resource')) \ No newline at end of file diff --git a/app/forms/reservation.py b/app/forms/reservation.py index be9e6c7..05ebede 100644 --- a/app/forms/reservation.py +++ b/app/forms/reservation.py @@ -1,26 +1,27 @@ from flask_wtf import FlaskForm from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError +from flask_babel import lazy_gettext as _l from datetime import datetime, timedelta class ReservationForm(FlaskForm): - classroom_id = SelectField('Classroom', coerce=int, validators=[DataRequired()]) - commission_id = SelectField('Commission/Subject', coerce=int, validators=[DataRequired()]) - start_time = DateTimeLocalField('Start Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()]) - end_time = DateTimeLocalField('End Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()]) - purpose = StringField('Purpose', validators=[ + classroom_id = SelectField(_l('Classroom'), coerce=int, validators=[DataRequired()]) + commission_id = SelectField(_l('Commission/Subject'), coerce=int, validators=[DataRequired()]) + start_time = DateTimeLocalField(_l('Start Time'), format='%Y-%m-%dT%H:%M', validators=[DataRequired()]) + end_time = DateTimeLocalField(_l('End Time'), format='%Y-%m-%dT%H:%M', validators=[DataRequired()]) + purpose = StringField(_l('Purpose'), validators=[ DataRequired(), Length(min=3, max=500) ]) - expected_attendees = IntegerField('Expected Attendees', validators=[ + expected_attendees = IntegerField(_l('Expected Attendees'), validators=[ DataRequired(), NumberRange(min=1, max=500) ]) - notes = TextAreaField('Notes', validators=[ + notes = TextAreaField(_l('Notes'), validators=[ Optional(), Length(max=1000) ]) - submit = SubmitField('Make Reservation') + submit = SubmitField(_l('Make Reservation')) def validate_end_time(self, field): if field.data <= self.start_time.data: diff --git a/app/models/user.py b/app/models/user.py index 9b87063..208f1b7 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -14,6 +14,7 @@ class User(UserMixin, db.Model): is_active = db.Column(db.Boolean, default=True, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) last_login = db.Column(db.DateTime) + preferred_language = db.Column(db.String(10), default=None, nullable=True) # Virtual fields for future compatibility @property diff --git a/app/routes/auth.py b/app/routes/auth.py index e5a56a7..27d5126 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -25,6 +25,10 @@ def login(): login_user(user, remember=form.remember_me.data) user.update_last_login() + # Si el usuario tiene idioma configurado en su perfil, sobrescribe el navegador en toda la sesión + if user.preferred_language and user.preferred_language in ['es', 'en']: + session['language'] = user.preferred_language + next_page = request.args.get('next') if not next_page or not next_page.startswith('/'): next_page = url_for('main.dashboard') @@ -41,15 +45,26 @@ def login(): def logout(): """Handle user logout""" logout_user() + # Al cerrar sesión, se limpia el idioma para que el login use el del navegador por defecto + session.pop('language', None) flash('You have been logged out successfully.', 'info') return redirect(url_for('auth.login')) -@auth_bp.route('/profile') +@auth_bp.route('/profile', methods=['GET', 'POST']) @login_required def profile(): """User profile page""" + if request.method == 'POST': + lang = request.form.get('preferred_language') + if lang in ['es', 'en']: + current_user.preferred_language = lang + session['language'] = lang + db.session.commit() + flash('Idioma de preferencia actualizado correctamente.', 'success') + return redirect(url_for('auth.profile')) return render_template('auth/profile.html') + @auth_bp.route('/change_password', methods=['GET', 'POST']) @login_required def change_password(): diff --git a/app/routes/classrooms.py b/app/routes/classrooms.py index 092f566..91f60b0 100644 --- a/app/routes/classrooms.py +++ b/app/routes/classrooms.py @@ -52,6 +52,7 @@ def add_classroom(): return render_template('classrooms/add.html', form=form, title='Add Classroom') +@classrooms_bp.route('/', endpoint='index') @classrooms_bp.route('/list') @login_required def list_classrooms(): diff --git a/app/routes/main.py b/app/routes/main.py index 9121f31..ca919c6 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -1,5 +1,6 @@ from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash from flask_login import login_required, current_user +from app import db from app.models.reservation import Reservation from app.models.classroom import Classroom from app.models.subject import Subject @@ -124,4 +125,8 @@ def set_language(language=None): language = 'en' session['language'] = language + if current_user.is_authenticated: + current_user.preferred_language = language + db.session.commit() + return redirect(request.referrer or url_for('main.dashboard')) \ No newline at end of file diff --git a/app/templates/auth/403.html b/app/templates/auth/403.html new file mode 100644 index 0000000..16d31af --- /dev/null +++ b/app/templates/auth/403.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block title %}Acceso Denegado (403) - Edu-Space Admin{% endblock %} + +{% block content %} +
+
+
+
+
+ +
+

403 - Acceso Denegado

+

+ No tienes los permisos necesarios para acceder a este recurso. Esta sección está restringida exclusivamente a administradores. +

+ +
+
+
+
+{% endblock %} diff --git a/app/templates/auth/change_password.html b/app/templates/auth/change_password.html new file mode 100644 index 0000000..2da6804 --- /dev/null +++ b/app/templates/auth/change_password.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} + +{% block title %}Cambiar Contraseña - Edu-Space Admin{% endblock %} + +{% block content %} +
+
+
+ +
+

+ Cambiar Contraseña +

+ + Volver al Perfil + +
+ + +
+
+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
Debe tener al menos 6 caracteres.
+
+ +
+ +
+ + +
+
+ +
+ + + Cancelar + +
+
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 6b8d7ee..9ffc30b 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -1,42 +1,54 @@ - + - Login - Edu-Space Admin + {% trans %}Login - Edu-Space Admin{% endtrans %}