diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bfde238 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_style = space +indent_size = 4 + +[*.{js,html,css}] +indent_style = space +indent_size = 2 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b511c9f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +## Descripción + + +## Tipo de cambio +- [ ] feat +- [ ] fix +- [ ] refactor +- [ ] chore +- [ ] docs +- [ ] test + +## Cómo probar + + +## Checklist +- [ ] Sigue las convenciones de commits del proyecto +- [ ] Las pruebas existentes pasan sin errores +- [ ] Se agregaron pruebas para el nuevo comportamiento (si aplica) +- [ ] No se incluyen archivos .env ni credenciales diff --git a/.github/workflows/.gitkeep b/.github/workflows/.gitkeep new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/.github/workflows/.gitkeep @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.github/workflows/daily-pipeline.yml b/.github/workflows/daily-pipeline.yml new file mode 100644 index 0000000..cb9089c --- /dev/null +++ b/.github/workflows/daily-pipeline.yml @@ -0,0 +1,33 @@ +name: Daily Pipeline + +on: + # Disparo programado: todos los días a las 00:00 UTC. + schedule: + - cron: "0 0 * * *" + + # Disparo manual desde la interfaz de GitHub para poder probar sin esperar al horario programado o depurar fallos. + workflow_dispatch: + +jobs: + trigger-pipeline: + name: Trigger Daily Pipeline + runs-on: ubuntu-latest + + steps: + - name: POST /api/admin/trigger-pipeline + run: | + HTTP_STATUS=$(curl --silent --output /tmp/pipeline_response.json \ + --write-out "%{http_code}" \ + --request POST \ + --header "X-Pipeline-Trigger-Key: ${{ secrets.PIPELINE_TRIGGER_SECRET }}" \ + --header "Content-Type: application/json" \ + "${{ secrets.PIPELINE_BACKEND_URL }}/api/admin/trigger-pipeline") + + echo "HTTP status: $HTTP_STATUS" + echo "Response body:" + cat /tmp/pipeline_response.json + + if [ "$HTTP_STATUS" != "200" ]; then + echo "::error::El pipeline falló con HTTP $HTTP_STATUS. Ver el body de arriba para detalles." + exit 1 + fi diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..bbfc2fc --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,84 @@ +name: Tests + +on: + push: + branches: [develop] + pull_request: + branches: [develop] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: usuario + POSTGRES_PASSWORD: password + POSTGRES_DB: skillstat_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + working-directory: backend + run: | + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Run database migrations + working-directory: backend + run: flask db upgrade + env: + FLASK_ENV: "testing" + SECRET_KEY: "test-secret-key" + TEST_DATABASE_URL: "postgresql://usuario:password@localhost:5432/skillstat_test" + JWT_SECRET_KEY: "test-jwt-secret" + GOOGLE_CLIENT_ID: "test-google-client-id" + FRONTEND_BASE_URL: "http://localhost:5500/frontend" + PIPELINE_TRIGGER_SECRET: "test-pipeline-secret" + RESEND_API_KEY: "test-resend-api-key" + + - name: Install PostgreSQL 18 client tools + run: | + sudo apt-get install -y curl ca-certificates + sudo install -d /usr/share/postgresql-common/pgdg + sudo curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc + sudo sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' + sudo apt-get update + sudo apt-get install -y postgresql-client-18 + pg_dump --version + + - name: Run tests with coverage + working-directory: backend + run: pytest --cov=app --cov-report=xml + env: + FLASK_ENV: "testing" + SECRET_KEY: "test-secret-key" + TEST_DATABASE_URL: "postgresql://usuario:password@localhost:5432/skillstat_test" + JWT_SECRET_KEY: "test-jwt-secret" + GOOGLE_CLIENT_ID: "test-google-client-id" + FRONTEND_BASE_URL: "http://localhost:5500/frontend" + PIPELINE_TRIGGER_SECRET: "test-pipeline-secret" + RESEND_API_KEY: "test-resend-api-key" + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: backend/coverage.xml + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..635cbe8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +.Python +*.egg-info/ +dist/ +build/ +.eggs/ +venv/ +.venv/ +env/ + +# Environment +.env +.env.local +.env.*.local + +# Database +*.db +*.sqlite3 + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Migrations (solo rastrear estructura, no datos generados) +migrations/versions/ +*.log.* + +# Backups +backend/data/backups/*.sql diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fb737e7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Normativa de Colaboración y Desarrollo + +Este documento establece las reglas operativas inquebrantables para el equipo de SkillStat. No son sugerencias, son requisitos para la integración de código. + +## 1. Flujo de Git y Ramas +* **Ramas Protegidas:** `main` y `develop` están protegidas. Nadie hace un push directo a estas ramas bajo ninguna circunstancia. +* **Nomenclatura de Ramas:** Toda rama nueva se crea a partir de `develop` utilizando el formato: `tipo/descripcion-en-kebab-case` (ej. `feature/sqlalchemy-models`). +* **Integración:** Todo código entra a `develop` exclusivamente mediante un Pull Request revisado. No se deja deuda técnica sin corregir. + +## 2. Historial y Commits (Conventional Commits) +Cada sub-ronda o bloque de trabajo debe ser un commit atómico. El mensaje debe seguir el estándar: +`tipo(scope): descripción en imperativo` +Tipos permitidos: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf`. + +## 3. Convención Estricta de Comentarios en Código +Todo comentario debe explicar el **POR QUÉ**, nunca el **QUÉ**. +* **Regla:** Si el código no deja clara la intención por sí solo, refactoriza. Si la regla de negocio es compleja, se comenta el razonamiento. +* **Tono:** Primera persona del plural implícita en español. Sin tecnicismos innecesarios, sin emojis, sin numeración de pasos. +* **Correcto:** `Descartamos las vacantes ya procesadas para no duplicar los resultados.` +* **Incorrecto:** `Filtramos jobs / Función para filtrar` + +## 4. Seguridad +* El archivo `.env` nunca se sube al repositorio. +* Las contraseñas se manejan siempre mediante hashes (bcrypt) y el acceso a rutas protegidas se valida estrictamente por roles mediante tokens JWT. \ No newline at end of file diff --git a/INDUCCION_EQUIPO.md b/INDUCCION_EQUIPO.md new file mode 100644 index 0000000..48dec08 --- /dev/null +++ b/INDUCCION_EQUIPO.md @@ -0,0 +1,142 @@ +# Cómo trabajamos en SkillStat + +Este documento describe cómo organizamos el trabajo, cómo nos comunicamos +a través del historial de cambios y cómo escribimos código. Lo leemos todos +antes de tocar cualquier archivo del repositorio. + +## Ramas del repositorio + +Tenemos dos ramas permanentes que nunca se modifican directamente: + +- `main` - contiene el código que está en producción. Solo recibe cambios + desde `develop` a través de un Pull Request revisado y aprobado. +- `develop` - es la rama de trabajo activo del equipo. Aquí integramos + todo antes de que llegue a producción. + +Para cualquier tarea nueva creamos una rama temporal que nace desde +`develop` y muere cuando hacemos merge: + +| Tipo | Cuándo usarla | Ejemplo | +|------|---------------|---------| +| `feature/` | Funcionalidad nueva | `feature/jwt-authentication` | +| `fix/` | Corrección de error en desarrollo | `fix/city-normalization` | +| `hotfix/` | Corrección urgente en producción | `hotfix/token-exposure` | +| `refactor/` | Reorganización sin cambiar comportamiento | `refactor/ingestion-cleanup` | +| `chore/` | Mantenimiento, dependencias, configuración | `chore/update-requirements` | +| `docs/` | Documentación únicamente | `docs/guia-entorno` | +| `test/` | Pruebas nuevas o actualizadas | `test/trends-service-unit` | +| `release/` | Preparación de versión para producción | `release/v1.0.0` | + +Los nombres van en minúsculas, con guiones y sin acentos. + +## Flujo de trabajo paso a paso + +Cada vez que vamos a trabajar en algo seguimos estos pasos: + +```bash +# Nos aseguramos de que develop esté al día antes de empezar +git checkout develop +git pull origin develop + +# Creamos nuestra rama desde develop +git checkout -b feature/nombre-descriptivo + +# Trabajamos y guardamos nuestros avances en commits +git add . +git commit -m "feat(scope): descripcion del cambio" + +# Mantenemos nuestra rama sincronizada con develop mientras trabajamos +git fetch origin +git rebase origin/develop + +# Subimos nuestra rama y abrimos el Pull Request hacia develop +git push origin feature/nombre-descriptivo +``` + +Nadie toca `main` ni `develop` directamente. Todo pasa por un Pull Request. +Nadie hace merge de su propio trabajo sin que alguien más lo haya revisado. +Después del merge borramos la rama. + +## Formato de commits + +Cada commit sigue esta estructura: + +``` +tipo(alcance): descripcion breve en imperativo +``` + +Los tipos disponibles: + +| Tipo | Cuándo usarlo | +|------|---------------| +| `feat` | Funcionalidad nueva | +| `fix` | Corrección de error | +| `docs` | Cambio de documentación | +| `style` | Formato sin cambio de lógica | +| `refactor` | Reorganización sin cambio de comportamiento | +| `test` | Agregar o modificar pruebas | +| `chore` | Mantenimiento, dependencias, configuración | +| `perf` | Mejora de rendimiento | + +Ejemplos reales del proyecto: + +``` +feat(auth): implement JWT login and token validation +fix(nlp): resolve phrase matcher failure on multi-word skills +chore(deps): add Flask-JWT-Extended and spaCy to requirements +docs(contributing): add commit format and branching guide +test(trends): add unit tests for weekly growth rate calculation +refactor(ingestion): isolate Adzuna client from ingestion logic +``` + +## Cómo escribimos comentarios en el código + +Comentamos solo cuando el código por sí solo no deja clara la intención +detrás de lo que hace. Si el código se entiende solo, no ponemos comentario. + +El comentario explica el por qué, no el qué. El qué ya lo dice el código. + +Escribimos en primera persona del plural, en español, sin tecnicismos, +sin emojis y sin numerar los pasos. Aplica a todos los lenguajes del +proyecto: Python, JavaScript, CSS e HTML. + +Así escribimos: + +```python +# Descartamos las vacantes que ya procesamos para no duplicar los resultados. +pending = jobs.filter(processed=False) + +# Guardamos el hash para detectar si la descripcion cambio en la fuente original. +job.description_hash = compute_hash(raw_description) + +# Si el umbral ya se supero avisamos al usuario antes de continuar. +if demand_count >= alert.threshold: + notify_user(alert) +``` + +```javascript +// Esperamos el token antes de hacer la peticion para no enviar una solicitud sin autenticar. +const token = await getAuthToken(); + +// Mostramos el mensaje directamente para que el usuario sepa que paso sin tener que buscar. +showErrorMessage(error.message); +``` + +Así no escribimos: + +```python +# 1. Filtramos los jobs +# Funcion para filtrar vacantes usando ORM +# Filter unprocessed jobs from database +# 🔍 Buscamos vacantes sin procesar +``` + +## Reglas que no se negocian + +- Nadie hace push directo a `main` ni a `develop`. +- Nadie hace merge de su propio Pull Request sin revision previa. +- Un commit representa un solo cambio logico. No mezclamos cosas distintas. +- No dejamos deuda tecnica sin documentar. Si algo quedo incompleto + abrimos un issue o lo anotamos en el PR. +- Si una rama lleva mas de una semana sin actividad, revisamos si sigue + siendo necesaria o la cerramos. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f66f325 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Elías Ochoa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 73b4856..5910cfb 100644 Binary files a/README.md and b/README.md differ diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d39bf3b --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,43 @@ +# Entorno de aplicación +# Valores válidos para FLASK_ENV: development, production, testing +FLASK_ENV=development +FLASK_DEBUG=1 +SECRET_KEY=cambiar-por-una-clave-segura + +# Base de datos +# Formato: postgresql://usuario:password@host:puerto/nombre_db +DATABASE_URL=postgresql://usuario:password@localhost:5432/skillstat_dev + +# JSON Web Tokens +JWT_SECRET_KEY=cambiar-por-una-clave-segura +# Tiempo de vida del token en segundos. 86400 = 24 horas. +JWT_ACCESS_TOKEN_EXPIRES=86400 +JWT_COOKIE_SECURE=false + +# APIs externas +GOOGLE_CLIENT_ID=891817364914-fpq222eqf2jk1ticoldkuq5u74h5lurt.apps.googleusercontent.com +ADZUNA_APP_ID=app-id-de-adzuna +ADZUNA_APP_KEY=api-key-de-adzuna +RESEND_API_KEY=api-key-de-resend + +# Almacenamiento externo de respaldos (Cloudflare R2) +R2_ENDPOINT_URL=https://tu-account-id.r2.cloudflarestorage.com +R2_ACCESS_KEY_ID=access-key-id-de-r2 +R2_SECRET_ACCESS_KEY=secret-access-key-de-r2 +R2_BUCKET_NAME=skillstat-backups + +# Scheduler +# En desarrollo mantenemos el scheduler desactivado para no consumir la cuota de la API de Adzuna mientras programamos. +SCHEDULER_ENABLED=false +INGESTION_INTERVAL_HOURS=6 +TRENDS_INTERVAL_HOURS=24 + +# CORS +# Lista de orígenes permitidos separados por coma. +CORS_ORIGINS=http://localhost:5500,http://localhost:3000,http://127.0.0.1:5500,http://127.0.0.1:5501 + +# Base URL del frontend. Este valor debe coincidir con la raíz real que sirve los archivos de frontend, y cambiará por completo al desplegar a producción. +FRONTEND_BASE_URL=http://localhost:5500 + +# Autenticación del pipeline automatizado +PIPELINE_TRIGGER_SECRET=reemplazar-por-cadena-aleatoria-segura diff --git a/backend/.flake8 b/backend/.flake8 new file mode 100644 index 0000000..c740fed --- /dev/null +++ b/backend/.flake8 @@ -0,0 +1,8 @@ +[flake8] +max-line-length = 88 +exclude = + .venv, + venv, + env, + __pycache__ +extend-ignore = E501 \ No newline at end of file diff --git a/backend/.flaskenv b/backend/.flaskenv new file mode 100644 index 0000000..7e1068b --- /dev/null +++ b/backend/.flaskenv @@ -0,0 +1 @@ +FLASK_APP=run diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..a8954c2 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,39 @@ +# backend/ + +Aqui vive la API REST, los servicios de negocio, los modelos de datos, +los repositorios y los clientes de APIs externas. + +## Estructura principal + +``` +backend/ +├── app/ # El nucleo de la aplicacion Flask +├── clients/ # Clientes para APIs externas +├── scheduler/ # Procesos automatizados periodicos +├── migrations/ # Migraciones de base de datos +├── tests/ # Pruebas unitarias e integracion +└── run.py # Punto de entrada del servidor +``` + +## Como arranca la aplicacion + +El archivo `run.py` inicia el servidor. La aplicacion se construye +en `app/__init__.py` usando el patron application factory, que permite +crear instancias independientes para desarrollo, produccion y pruebas. + +## Variables de entorno + +Todas las configuraciones sensibles (claves de API, URL de base de datos, +secreto JWT) viven en el archivo `.env`. Nunca se sube al repositorio. +El archivo `.env.example` muestra que variables se necesitan sin revelar +sus valores reales. + +## Flujo de Ingesta y Snapshots + +Para que los datos analíticos del sistema se mantengan actualizados, existe una secuencia obligatoria de dos comandos. **Siempre** se deben ejecutar en este orden antes de que los datos nuevos aparezcan en `/api/panorama/geo` o cualquier otra vista que consuma tendencias: + +1. **Ingesta cruda:** `flask ingest-jobs --what "desarrollador" --pages 1` + Extrae vacantes de Adzuna y las guarda en la base de datos (con su respectiva geolocalización a través de Nominatim). No clasifica habilidades ni calcula tendencias. + +2. **Generación de Snapshots:** `flask generate-snapshots` + Recalcula las métricas analíticas (TrendSnapshots) agrupando por habilidad, ciudad y estado usando los datos más recientes. Utiliza un upsert (ON CONFLICT DO UPDATE) por lo que es totalmente seguro y necesario ejecutarlo múltiples veces el mismo día sin perder la información ya existente. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..d2ab3c4 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,156 @@ +import os +import logging_config +from flask import Flask + +from app.config import config_map +from app.extensions import db, jwt, cors, migrate, limiter + +def create_app(env: str = None) -> Flask: + # Aplicamos el patrón Application Factory porque aislar la inicialización nos permite instanciar aplicaciones independientes durante las pruebas automatizadas, previniendo choques por estado global. + + app = Flask(__name__) + + env = env or os.environ.get("FLASK_ENV", "development") + config_class = config_map.get(env, config_map["development"]) + app.config.from_object(config_class) + + _init_extensions(app) + _register_jwt_handlers(app) + _register_error_handlers(app) + _register_blueprints(app) + + return app + +def _init_extensions(app: Flask) -> None: + db.init_app(app) + jwt.init_app(app) + migrate.init_app(app, db) + # Restringimos CORS al prefijo de la API para que el frontend pueda consumirla desde su propio origen sin bloqueos del navegador. + cors.init_app(app, resources={r"/api/*": {"origins": app.config.get("CORS_ORIGINS", "*"), "supports_credentials": True}}) + limiter.init_app(app) + + +def _register_jwt_handlers(app: Flask) -> None: + # Unificamos el formato de los errores que flask-jwt-extended genera directamente (antes de llegar a nuestras rutas) con el mismo formato {"error": {"code", "message"}} que usa el resto de la API. + from app.utils.response import error_response + + @jwt.unauthorized_loader + def handle_missing_token(reason): + return error_response( + code="UNAUTHORIZED", + message="No se encontró una sesión activa.", + status_code=401, + ) + + @jwt.invalid_token_loader + def handle_invalid_token(reason): + return error_response( + code="TOKEN_INVALID", + message="La sesión no es válida.", + status_code=401, + ) + + @jwt.expired_token_loader + def handle_expired_token(jwt_header, jwt_payload): + return error_response( + code="TOKEN_EXPIRED", + message="La sesión ha expirado, vuelve a iniciar sesión.", + status_code=401, + ) + + @jwt.revoked_token_loader + def handle_revoked_token(jwt_header, jwt_payload): + return error_response( + code="TOKEN_REVOKED", + message="La sesión ha sido revocada.", + status_code=401, + ) + + @jwt.token_in_blocklist_loader + def check_if_token_revoked(jwt_header, jwt_payload): + # Importación diferida para evitar ciclo de importación con db/User. + from app.repositories.user_repository import UserRepository + from datetime import datetime, timezone as tz + + user_id = jwt_payload.get("sub") + if not user_id: + return False + + user = UserRepository.get_by_id(int(user_id)) + if not user: + # Si Usuario no encontrado, entonces no podemos validar nada, dejamos pasar (flask-jwt-extended ya maneja tokens huérfanos en otros callbacks). + return False + + if not user.is_active: + # Si Usuario desactivado, entonces revocamos inmediatamente todas sus sesiones activas sin importar cuándo fue emitido el token. + return True + + if user.password_changed_at is None: + # Si Usuario exclusivamente OAuth (sin contraseña propia), no aplicamos invalidación por cambio de contraseña. + return False + + # El claim "iat" (issued-at) es un timestamp Unix con precisión de segundos. password_changed_at tiene microsegundos; truncamos al segundo para que un token emitido en el mismo segundo que el reset no quede bloqueado falsamente. El ataque de "token emitido justo antes del reset" sigue bloqueado correctamente porque iat < pca_floor cuando la diferencia es de al menos 1 segundo completo. + iat = jwt_payload.get("iat", 0) + pca = user.password_changed_at + if pca.tzinfo is None: + pca = pca.replace(tzinfo=tz.utc) + + # Truncamos la marca de tiempo de cambio de contraseña a segundos exactos porque el claim 'iat' del JWT no tiene milisegundos; esto previene que revoquemos accidentalmente un token legítimo emitido durante el mismo segundo del cambio. + pca_floor = pca.replace(microsecond=0) + + token_issued_at = datetime.fromtimestamp(iat, tz=tz.utc) + return token_issued_at < pca_floor + + +def _register_error_handlers(app: Flask) -> None: + # Traduce cualquier AppError (o subclase) no atrapada en un punto mas especifico de la pila a una respuesta HTTP consistente con el resto de la API, sin que cada controller tenga que envolver cada llamada a un repositorio en su propio try/except. + from app.utils.response import error_response + from app.utils.errors import AppError + + @app.errorhandler(AppError) + def handle_app_error(error): + return error_response( + code=error.code, + message=error.message, + status_code=error.status_code, + ) + + @app.errorhandler(429) + def handle_rate_limit_exceeded(error): + return error_response( + code="RATE_LIMIT_EXCEEDED", + message="Has excedido el limite de solicitudes. Intenta de nuevo mas tarde.", + status_code=429, + ) + + @app.errorhandler(404) + def handle_not_found(error): + return error_response( + code="NOT_FOUND", + message="El recurso solicitado no existe.", + status_code=404, + ) + + @app.errorhandler(500) + def handle_internal_error(error): + return error_response( + code="INTERNAL_ERROR", + message="Ocurrio un error interno. Intenta de nuevo mas tarde.", + status_code=500, + ) + + +def _register_blueprints(app: Flask) -> None: + # Importaciones diferidas para prevenir dependencias circulares antes de inicializar Flask + from app.controllers.auth_bp import auth_bp + from app.controllers.panorama_bp import panorama_bp + from app.controllers.alerts_bp import alerts_bp + from app.controllers.admin_bp import admin_bp + from app.controllers.profile_bp import profile_bp + + app.register_blueprint(auth_bp, url_prefix="/api/auth") + app.register_blueprint(panorama_bp, url_prefix="/api/panorama") + app.register_blueprint(alerts_bp, url_prefix="/api/alerts") + app.register_blueprint(admin_bp, url_prefix="/api/admin") + app.register_blueprint(profile_bp, url_prefix="/api/profile") + diff --git a/backend/app/clients/__init__.py b/backend/app/clients/__init__.py new file mode 100644 index 0000000..a6131c1 --- /dev/null +++ b/backend/app/clients/__init__.py @@ -0,0 +1 @@ +# init diff --git a/backend/app/clients/adzuna_client.py b/backend/app/clients/adzuna_client.py new file mode 100644 index 0000000..a67f500 --- /dev/null +++ b/backend/app/clients/adzuna_client.py @@ -0,0 +1,33 @@ +import requests +from flask import current_app +from app.utils.errors import AppError + +class AdzunaClient: + # Encapsulamos la comunicación con Adzuna para aislar la lógica HTTP del resto del sistema. Si Adzuna cambia su API, solo modificamos este archivo. + BASE_URL = "https://api.adzuna.com/v1/api/jobs" + + @classmethod + def get_jobs(cls, country: str = "mx", page: int = 1, what: str = "IT", where: str = "") -> dict: + app_id = current_app.config.get("ADZUNA_APP_ID") + app_key = current_app.config.get("ADZUNA_APP_KEY") + + if not app_id or not app_key: + raise AppError("Credenciales de Adzuna no configuradas.", status_code=500) + + url = f"{cls.BASE_URL}/{country}/search/{page}" + params = { + "app_id": app_id, + "app_key": app_key, + "what": what, + "where": where, + "results_per_page": 50, + "content-type": "application/json" + } + + try: + response = requests.get(url, params=params, timeout=10) + response.raise_for_status() + return response.json() + except requests.RequestException as e: + # Levantamos una excepción de negocio pura en lugar de un error HTTP genérico + raise AppError(f"Error al comunicar con Adzuna: {str(e)}", code="EXTERNAL_API_ERROR") diff --git a/backend/app/clients/nominatim_client.py b/backend/app/clients/nominatim_client.py new file mode 100644 index 0000000..9712e52 --- /dev/null +++ b/backend/app/clients/nominatim_client.py @@ -0,0 +1,108 @@ +import time +import logging +import requests +from requests.exceptions import RequestException + +logger = logging.getLogger(__name__) + +class NominatimClient: + BASE_URL = "https://nominatim.openstreetmap.org/search" + USER_AGENT = "SkillStat/1.0 (proyecto academico UTCJ, contacto: 195959137+Ochoa-Stack@users.noreply.github.com)" + + # Valid types that represent a real city/town/village entity. + VALID_TYPES = {"city", "town", "village", "municipality"} + + # Desambiguación para mapear queries ambiguos (o estados homónimos) a sus ciudades reales. + QUERY_DISAMBIGUATION = { + "cdmx": "Ciudad de Mexico", + "distrito federal": "Ciudad de Mexico", + "df": "Ciudad de Mexico", + "mexico df": "Ciudad de Mexico", + "puebla": "Puebla de Zaragoza", + "queretaro": "Santiago de Queretaro", + "oaxaca": "Oaxaca, Oaxaca", + "guanajuato": "Guanajuato, Guanajuato", + "campeche": "Campeche, Campeche", + "colima": "Colima, Colima", + "chihuahua": "Chihuahua, Chihuahua", + "durango": "Durango, Durango", + "tlaxcala": "Tlaxcala, Tlaxcala", + "zacatecas": "Zacatecas, Zacatecas" + } + + @classmethod + def _resolve_query(cls, query: str) -> str: + import unicodedata + normalized_query = query.strip().lower() + normalized_query = ''.join(c for c in unicodedata.normalize('NFD', normalized_query) if unicodedata.category(c) != 'Mn') + normalized_query = normalized_query.replace(".", "") + return cls.QUERY_DISAMBIGUATION.get(normalized_query, query) + + @classmethod + def _is_valid_place_type(cls, result: dict) -> bool: + place_type = result.get("type", "").lower() + place_class = result.get("class", "").lower() + addresstype = result.get("addresstype", "").lower() + return place_type in cls.VALID_TYPES or place_class in cls.VALID_TYPES or addresstype in cls.VALID_TYPES + + @classmethod + def _extract_city_name(cls, result: dict) -> str | None: + address = result.get("address", {}) + return address.get("city") or address.get("town") or address.get("village") or address.get("municipality") or result.get("name") + + @classmethod + def geocode_city(cls, query: str) -> dict | None: + """ Geocodes a city name using Nominatim API. Returns a dict with 'name', 'state', 'lat', 'lon' or None if it fails, timeouts, or doesn't meet the confidence threshold (must have state, must be a valid city type) """ + actual_query = cls._resolve_query(query) + + # Sleep to respect Nominatim's strict 1 req/sec limit + time.sleep(1.1) + + headers = { + "User-Agent": cls.USER_AGENT + } + params = { + "q": actual_query, + "format": "jsonv2", + "countrycodes": "mx", + "limit": 1, + "addressdetails": 1 + } + + try: + response = requests.get(cls.BASE_URL, headers=headers, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + if not data: + return None + + result = data[0] + + if not cls._is_valid_place_type(result): + return None + + address = result.get("address", {}) + state = address.get("state") + + if not state: + return None + + name = cls._extract_city_name(result) + + if not name: + return None + + return { + "name": name, + "state": state, + "lat": float(result.get("lat")), + "lon": float(result.get("lon")) + } + + except RequestException as e: + logger.warning(f"Error de conexion o timeout al contactar Nominatim para query '{query}': {e}") + return None + except ValueError as e: + logger.warning(f"Error decodificando respuesta JSON de Nominatim para query '{query}': {e}") + return None diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..0df98fe --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,132 @@ +import os +from datetime import timedelta + + +class BaseConfig: + + SECRET_KEY = os.environ.get("SECRET_KEY", "dev-insecure-key-change-in-production") + + SQLALCHEMY_TRACK_MODIFICATIONS = False + + SQLALCHEMY_ENGINE_OPTIONS = { + "pool_pre_ping": True, + "pool_recycle": 300, + } + + JWT_SECRET_KEY = os.environ.get( + "JWT_SECRET_KEY", "jwt-insecure-key-change-in-production" + ) + + # Validación explícita condicionada a producción para claves secretas + if os.environ.get("FLASK_ENV") == "production": + if SECRET_KEY == "dev-insecure-key-change-in-production": + raise ValueError( + "Error de arranque: SECRET_KEY es obligatoria y no está configurada en el entorno (se está usando el valor inseguro de fallback en producción)." + ) + if JWT_SECRET_KEY == "jwt-insecure-key-change-in-production": + raise ValueError( + "Error de arranque: JWT_SECRET_KEY es obligatoria y no está configurada en el entorno (se está usando el valor inseguro de fallback en producción)." + ) + + JWT_ACCESS_TOKEN_EXPIRES = timedelta( + seconds=int(os.environ.get("JWT_ACCESS_TOKEN_EXPIRES", 7200)) + ) + JWT_ERROR_MESSAGE_KEY = "error" + # El JWT vive en una cookie httpOnly en vez de viajar en el cuerpo JSON, para que un script de XSS no pueda leerlo directamente. + JWT_TOKEN_LOCATION = ["cookies"] + JWT_BLOCKLIST_TOKEN_CHECKS = ["access", "refresh"] + JWT_COOKIE_SECURE = os.environ.get("JWT_COOKIE_SECURE", "false").lower() == "true" + JWT_COOKIE_CSRF_PROTECT = True + JWT_CSRF_IN_COOKIES = True + + CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "http://localhost:5500").split(",") + FRONTEND_BASE_URL = os.environ.get("FRONTEND_BASE_URL", "http://localhost:5500/frontend") + + GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID") + ADZUNA_APP_ID = os.environ.get("ADZUNA_APP_ID") + ADZUNA_APP_KEY = os.environ.get("ADZUNA_APP_KEY") + RESEND_API_KEY = os.environ.get("RESEND_API_KEY") + RESEND_FROM_EMAIL = os.environ.get("RESEND_FROM_EMAIL", "onboarding@resend.dev") + + # Validación explícita en arranque (guard incondicional) + if not RESEND_API_KEY: + raise ValueError("Error de arranque: RESEND_API_KEY es obligatoria y no está configurada en el entorno.") + + R2_ENDPOINT_URL = os.environ.get("R2_ENDPOINT_URL") + R2_ACCESS_KEY_ID = os.environ.get("R2_ACCESS_KEY_ID") + R2_SECRET_ACCESS_KEY = os.environ.get("R2_SECRET_ACCESS_KEY") + R2_BUCKET_NAME = os.environ.get("R2_BUCKET_NAME") + + SCHEDULER_ENABLED = os.environ.get("SCHEDULER_ENABLED", "false").lower() == "true" + INGESTION_INTERVAL_HOURS = int(os.environ.get("INGESTION_INTERVAL_HOURS", 6)) + TRENDS_INTERVAL_HOURS = int(os.environ.get("TRENDS_INTERVAL_HOURS", 24)) + + # Clave secreta para el endpoint POST /api/admin/trigger-pipeline, que es invocado por GitHub Actions sin sesión de usuario. Se valida via el header X-Pipeline-Trigger-Key usando comparación de tiempo constante (hmac.compare_digest). + PIPELINE_TRIGGER_SECRET = os.environ.get("PIPELINE_TRIGGER_SECRET") + + # Validación explícita en arranque (guard incondicional) + if not PIPELINE_TRIGGER_SECRET: + raise ValueError( + "Error de arranque: PIPELINE_TRIGGER_SECRET es obligatoria y no está configurada en el entorno." + ) + +class DevelopmentConfig(BaseConfig): + + DEBUG = True + SQLALCHEMY_DATABASE_URI = os.environ.get( + "DATABASE_URL", + "postgresql://postgres:postgres@localhost:5432/skillstat_dev", + ) + SQLALCHEMY_ENGINE_OPTIONS = { + **BaseConfig.SQLALCHEMY_ENGINE_OPTIONS, + "connect_args": { + "client_encoding": "utf8", + "options": "-c lc_messages=C", + }, + } + JWT_COOKIE_SAMESITE = "Lax" + +class ProductionConfig(BaseConfig): + + DEBUG = False + TESTING = False + + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") + + SQLALCHEMY_ENGINE_OPTIONS = { + **BaseConfig.SQLALCHEMY_ENGINE_OPTIONS, + "pool_size": 10, + "max_overflow": 20, + } + + # SameSite=None es obligatorio en producción porque el frontend (skillstat-ss.onrender.com) y el backend (skillstat.onrender.com) son dominios distintos (cross-site). SameSite=None requiere Secure=True, que ya está activo vía JWT_COOKIE_SECURE=true en el entorno de Render. + JWT_COOKIE_SAMESITE = "None" + +class TestingConfig(BaseConfig): + + TESTING = True + DEBUG = True + + SQLALCHEMY_DATABASE_URI = os.environ.get( + "TEST_DATABASE_URL", + "postgresql://postgres:Ochoa-Stack@localhost:5432/skillstat_test", + ) + SQLALCHEMY_ENGINE_OPTIONS = { + **BaseConfig.SQLALCHEMY_ENGINE_OPTIONS, + "connect_args": { + "client_encoding": "utf8", + "options": "-c lc_messages=C", + }, + } + + JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=5) + + SCHEDULER_ENABLED = False + JWT_COOKIE_SAMESITE = "Lax" + + +config_map = { + "development": DevelopmentConfig, + "production": ProductionConfig, + "testing": TestingConfig, +} diff --git a/backend/app/controllers/README.md b/backend/app/controllers/README.md new file mode 100644 index 0000000..e579f91 --- /dev/null +++ b/backend/app/controllers/README.md @@ -0,0 +1,26 @@ +# controllers/ + +Aqui viven los Blueprints de Flask. Cada archivo corresponde a un +dominio de la aplicacion y agrupa las rutas relacionadas con ese dominio. + +## Lo que va aqui + +- Las rutas HTTP (endpoints) organizadas por Blueprint +- La validacion del formato de la peticion entrante +- La llamada al servicio correspondiente +- La construccion de la respuesta que se devuelve al cliente + +## Lo que no va aqui + +La logica de negocio no vive en los controladores. Si nos encontramos +escribiendo condiciones complejas o consultas a la base de datos dentro +de un Blueprint, eso pertenece a un servicio o un repositorio. + +## Los Blueprints del proyecto + +| Archivo | Dominio | +|---------|---------| +| `auth_bp.py` | Registro, login y gestion de sesion | +| `panorama_bp.py` | Tendencias, rankings y datos del dashboard | +| `alerts_bp.py` | Configuracion y gestion de alertas del usuario | +| `admin_bp.py` | Administracion de usuarios y respaldos | diff --git a/backend/app/controllers/__init__.py b/backend/app/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/controllers/admin_bp.py b/backend/app/controllers/admin_bp.py new file mode 100644 index 0000000..1079307 --- /dev/null +++ b/backend/app/controllers/admin_bp.py @@ -0,0 +1,281 @@ +import hmac +import logging +from flask import Blueprint, request, current_app +from flask_jwt_extended import jwt_required, get_jwt_identity +from marshmallow import ValidationError + +from app.services.ingestion_service import IngestionService +from app.services.backup_service import BackupService +from app.repositories.backup_repository import BackupRepository +from app.repositories.user_repository import UserRepository +from app.schemas.admin_schema import UserRoleSchema, UserStatusSchema, BackupRestoreConfirmSchema +from app.utils.response import success_response, error_response +from app.utils.decorators import role_required + +logger = logging.getLogger(__name__) + +admin_bp = Blueprint("admin_bp", __name__) + + +@admin_bp.route("/ingest", methods=["POST"]) +@jwt_required() +@role_required("ADMIN") +def trigger_ingestion(): + payload = request.get_json() or {} + pages = payload.get("pages", 1) + + # Mandamos llamar al orquestador principal de la Capa de Servicios + stats = IngestionService.run_ingestion(pages=pages) + + return success_response(data=stats, status_code=200) + + +@admin_bp.route("/backup", methods=["POST"]) +@jwt_required() +@role_required("ADMIN") +def trigger_backup(): + user_id = int(get_jwt_identity()) + + # Delegamos la ejecución del dump físico al servicio operativo + result = BackupService.execute_database_backup(requested_by=user_id) + + return success_response(data=result, status_code=200) + + +@admin_bp.route("/backups", methods=["GET"]) +@jwt_required() +@role_required("ADMIN") +def list_backups(): + page = request.args.get("page", default=1, type=int) + per_page = request.args.get("per_page", default=20, type=int) + + if per_page < 1 or per_page > 100: + return error_response( + code="VALIDATION_ERROR", + message="per_page debe estar entre 1 y 100.", + status_code=422, + ) + + items, total = BackupRepository.get_paginated(page=page, per_page=per_page) + + result = { + "items": [ + { + "id": b.id, + "filename": b.filename, + "storage_url": b.storage_url, + "status": b.status, + "created_at": b.created_at.isoformat() if b.created_at else None, + "user_id": b.user_id, + "user_email": email, + } + for b, email in items + ], + "total": total, + "page": page, + "per_page": per_page, + # División entera con techo; si no hay registros, devolvemos 0 en vez de -1 por el offset de la resta + "total_pages": (total + per_page - 1) // per_page if total > 0 else 0, + } + + return success_response(data=result, status_code=200) + +from app.utils.errors import AppError + +@admin_bp.route("/backups//restore", methods=["POST"]) +@jwt_required() +@role_required("ADMIN") +def restore_backup(backup_id): + actor_id = int(get_jwt_identity()) + try: + payload = BackupRestoreConfirmSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + backup = BackupRepository.get_by_id(backup_id) + if not backup: + return error_response(code="NOT_FOUND", message="El respaldo no existe.", status_code=404) + if payload["confirm_filename"] != backup.filename: + logger.warning( + "Intento de restauracion con nombre de archivo no coincidente: admin %s intento restaurar backup %s con confirmacion '%s'.", + actor_id, backup_id, payload["confirm_filename"] + ) + return error_response( + code="FILENAME_MISMATCH", + message="El nombre de archivo no coincide con el respaldo seleccionado.", + status_code=422, + ) + try: + result = BackupService.restore_database_backup(backup_id=backup_id, requested_by=actor_id) + except AppError as e: + logger.error( + "Restauracion fallida: admin %s intento restaurar backup %s (%s). Error: %s", + actor_id, backup_id, backup.filename, str(e) + ) + return error_response(code=e.code or "RESTORE_ERROR", message=str(e), status_code=e.status_code or 500) + logger.info( + "Restauracion exitosa: admin %s restauro backup %s (%s). Backup de seguridad generado: %s.", + actor_id, backup_id, backup.filename, result.get("safety_backup") + ) + return success_response(data=result, status_code=200) + + +@admin_bp.route("/users", methods=["GET"]) +@jwt_required() +@role_required("ADMIN") +def list_users(): + + page = request.args.get("page", default=1, type=int) + per_page = request.args.get("per_page", default=20, type=int) + + if per_page < 1 or per_page > 100: + return error_response( + code="VALIDATION_ERROR", + message="per_page debe estar entre 1 y 100.", + status_code=422, + ) + + items, total = UserRepository.get_paginated(page=page, per_page=per_page) + + result = { + "items": [ + { + "id": u.id, + "email": u.email, + "first_name": u.first_name, + "last_name": u.last_name, + "role": u.role, + "is_active": u.is_active, + "created_at": u.created_at.isoformat() if u.created_at else None, + } + for u in items + ], + "total": total, + "page": page, + "per_page": per_page, + "total_pages": (total + per_page - 1) // per_page if total > 0 else 0, + } + + return success_response(data=result, status_code=200) + + +@admin_bp.route("/users//role", methods=["PATCH"]) +@jwt_required() +@role_required("ADMIN") +def update_user_role(user_id): + actor_id = int(get_jwt_identity()) + try: + payload = UserRoleSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response( + code="VALIDATION_ERROR", + message=err.messages, + status_code=422, + ) + + target = UserRepository.get_by_id(user_id) + if not target: + return error_response( + code="NOT_FOUND", + message="Usuario no encontrado.", + status_code=404, + ) + + new_role = payload["role"] + # Si el actor se esta auto-modificando y la operacion lo saca de ADMIN, protegemos contra dejar el sistema sin ningun admin activo. + if target.id == actor_id and target.role == "ADMIN" and new_role != "ADMIN": + if UserRepository.is_last_active_admin(actor_id): + return error_response( + code="LAST_ADMIN_PROTECTED", + message="No puedes quitarte el rol de ADMIN: eres el unico administrador activo.", + status_code=403, + ) + + previous_role = target.role + target.role = new_role + UserRepository.save(target) + + logger.info( + "Cambio de rol: admin %s cambio a usuario %s (%s) de %s a %s.", + actor_id, target.id, target.email, previous_role, new_role + ) + return success_response( + data={"id": target.id, "role": target.role}, status_code=200 + ) + + +@admin_bp.route("/users//status", methods=["PATCH"]) +@jwt_required() +@role_required("ADMIN") +def update_user_status(user_id): + actor_id = int(get_jwt_identity()) + try: + payload = UserStatusSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response( + code="VALIDATION_ERROR", + message=err.messages, + status_code=422, + ) + + target = UserRepository.get_by_id(user_id) + if not target: + return error_response( + code="NOT_FOUND", + message="Usuario no encontrado.", + status_code=404, + ) + + new_status = payload["is_active"] + # Misma proteccion de ultimo-admin, aplicada a desactivacion en vez de cambio de rol. + if target.id == actor_id and target.role == "ADMIN" and new_status is False: + if UserRepository.is_last_active_admin(actor_id): + return error_response( + code="LAST_ADMIN_PROTECTED", + message="No puedes desactivar tu cuenta: eres el unico administrador activo.", + status_code=403, + ) + + previous_status = target.is_active + target.is_active = new_status + UserRepository.save(target) + + logger.info( + "Cambio de estado: admin %s cambio a usuario %s (%s) de is_active=%s a %s.", + actor_id, target.id, target.email, previous_status, new_status + ) + return success_response( + data={"id": target.id, "is_active": target.is_active}, status_code=200 + ) + + +@admin_bp.route("/trigger-pipeline", methods=["POST"]) +def trigger_pipeline(): + """Dispara el pipeline diario bajo demanda. Autenticado exclusivamente via el header X-Pipeline-Trigger-Key, comparado con PIPELINE_TRIGGER_SECRET usando tiempo constante para evitar timing attacks. Diseñado para ser invocado por GitHub Actions, sin sesión de usuario.""" + provided_key = request.headers.get("X-Pipeline-Trigger-Key", "") + expected_key = current_app.config.get("PIPELINE_TRIGGER_SECRET", "") + + # hmac.compare_digest previene timing attacks, el tiempo de comparación no varía según cuántos caracteres coincidan, a diferencia del operador ==. + if not hmac.compare_digest(provided_key, expected_key): + return error_response( + code="UNAUTHORIZED", + message="Clave de autenticación de servicio inválida o ausente.", + status_code=401, + ) + + from app.services.market_trends_service import MarketTrendsService + from app.services.alerts_service import AlertsService + + snapshots_count = MarketTrendsService.generate_snapshots() + notifications_sent = AlertsService.evaluate_and_notify() + + logger.info( + "Pipeline disparado via endpoint: %d snapshots generados, %d notificaciones enviadas.", + snapshots_count, notifications_sent, + ) + return success_response( + data={ + "snapshots_generated": snapshots_count, + "notifications_sent": notifications_sent, + }, + status_code=200, + ) diff --git a/backend/app/controllers/alerts_bp.py b/backend/app/controllers/alerts_bp.py new file mode 100644 index 0000000..b8d1bc2 --- /dev/null +++ b/backend/app/controllers/alerts_bp.py @@ -0,0 +1,74 @@ +from flask import Blueprint, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from marshmallow import ValidationError + +from app.schemas.alert_schema import AlertRequestSchema, AlertResponseSchema, AlertStatusUpdateSchema +from app.repositories.alert_repository import AlertRepository +from app.utils.response import success_response, error_response +from app.extensions import limiter + +alerts_bp = Blueprint("alerts_bp", __name__) + +@alerts_bp.route("/", methods=["POST"]) +@jwt_required() +@limiter.limit("10 per hour") +def create_alert(): + try: + data = AlertRequestSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + + user_id = get_jwt_identity() + + user_alerts = AlertRepository.get_by_user_id(int(user_id)) + active_count = sum(1 for a in user_alerts if a.active) + if active_count >= 20: + return error_response(code="LIMIT_EXCEEDED", message="Has alcanzado el límite de 20 alertas activas.", status_code=422) + + # Forzamos el ID extraído del token criptográfico sobre la carga de datos para erradicar ataques de asignación cruzada o escalamiento horizontal. + data["user_id"] = int(user_id) + + alert = AlertRepository.create(data) + result = AlertResponseSchema().dump(alert) + return success_response(data=result, status_code=201) + +@alerts_bp.route("/", methods=["GET"]) +@jwt_required() +def get_alerts(): + user_id = int(get_jwt_identity()) + + # Delegamos el filtro al repositorio para que la consulta ocurra en la base de datos y no en memoria de la aplicación. + user_alerts = AlertRepository.get_by_user_id(user_id) + + result = AlertResponseSchema(many=True).dump(user_alerts) + return success_response(data=result, status_code=200) + +@alerts_bp.route("/", methods=["DELETE"]) +@jwt_required() +def delete_alert(alert_id): + user_id = int(get_jwt_identity()) + alert = AlertRepository.get_by_id(alert_id) + + if not alert or alert.user_id != user_id: + # Devolvemos un estado no encontrado general en lugar de un error de acceso para no confirmar la existencia de IDs reales ante un escaneo malicioso. + return error_response(code="NOT_FOUND", message="Alerta no encontrada.", status_code=404) + + AlertRepository.delete(alert_id) + return success_response(data={"deleted": True}, status_code=200) + +@alerts_bp.route("//status", methods=["PATCH"]) +@jwt_required() +def update_alert_status(alert_id): + user_id = int(get_jwt_identity()) + try: + payload = AlertStatusUpdateSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + + alert = AlertRepository.get_by_id(alert_id) + if not alert or alert.user_id != user_id: + return error_response(code="NOT_FOUND", message="Alerta no encontrada.", status_code=404) + + alert.active = payload["active"] + AlertRepository.save(alert) + return success_response(data={"id": alert.id, "active": alert.active}, status_code=200) diff --git a/backend/app/controllers/auth_bp.py b/backend/app/controllers/auth_bp.py new file mode 100644 index 0000000..19c03bb --- /dev/null +++ b/backend/app/controllers/auth_bp.py @@ -0,0 +1,256 @@ +import logging + +from flask import Blueprint, request, current_app +from flask_jwt_extended import set_access_cookies, unset_jwt_cookies, jwt_required, get_jwt +from marshmallow import ValidationError + +from app.schemas.auth_schema import ( + UserRegistrationSchema, + UserLoginSchema, + UserResponseSchema, + ForgotPasswordSchema, + EmailOnlySchema, + ResetPasswordSchema, +) +from app.services.auth_service import AuthService +from app.utils.response import success_response, error_response +from app.utils.errors import AppError, ConflictError +from app.extensions import limiter + +logger = logging.getLogger(__name__) + +auth_bp = Blueprint("auth_bp", __name__) + +@auth_bp.route("/register", methods=["POST"]) +@limiter.limit("5 per hour") +def register(): + try: + data = UserRegistrationSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + + try: + user, plain_token = AuthService.register(data) + except ConflictError: + return error_response(code="CONFLICT", message="El correo ya está registrado.", status_code=409) + + from app.services.email_service import send_verification_email, EmailDeliveryError, build_verification_link + try: + logger.info(f"[DEV] Verification link para {user.email}: {build_verification_link(plain_token)}") + send_verification_email(user.email, plain_token) + return success_response(data={"message": "Cuenta creada. Revisa tu correo para verificarla."}, status_code=201) + except EmailDeliveryError: + return success_response(data={"message": "Cuenta creada pero no pudimos enviar el correo. Intenta reenviarlo."}, status_code=201) + +@auth_bp.route("/login", methods=["POST"]) +@limiter.limit("10 per 15 minutes") +def login(): + try: + data = UserLoginSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + + try: + user, tokens = AuthService.login(data) + except AppError as err: + return error_response(code=err.code, message=err.message, status_code=err.status_code) + + user_data = UserResponseSchema().dump(user) + + # El token nunca viaja en el cuerpo JSON ya que si lo devolvieramos aqui, un script de XSS podria leerlo desde la respuesta del fetch aunque la cookie sea httpOnly, anulando la proteccion que buscamos. + response, status_code = success_response(data=user_data, status_code=200) + set_access_cookies(response, tokens["access_token"]) + return response, status_code + +@auth_bp.route("/logout", methods=["POST"]) +def logout(): + response, status_code = success_response( + data={"message": "Sesión cerrada correctamente."}, status_code=200 + ) + unset_jwt_cookies(response) + return response, status_code + +@auth_bp.route("/csrf-token", methods=["GET"]) +@jwt_required() +def csrf_token(): + # Expone el claim csrf ya presente en el JWT actual. El frontend lo solicita bajo demanda la primera vez que necesita hacer una peticion mutable (POST/PATCH/DELETE), sorteando la restriccion de la Public Suffix List que impide leer csrf_access_token desde document.cookie cuando backend y frontend viven en subdominios distintos de onrender.com. + claims = get_jwt() + return success_response(data={"csrf_token": claims.get("csrf")}, status_code=200) + +@auth_bp.route("/google", methods=["POST"]) +@limiter.limit("10 per 15 minutes") +def google_login(): + data = request.get_json() or {} + credential = data.get("credential") + + if not credential: + return error_response( + code="VALIDATION_ERROR", + message="El token de Google es obligatorio.", + status_code=422, + ) + + try: + user, tokens = AuthService.authenticate_with_google( + credential=credential, + google_client_id=current_app.config["GOOGLE_CLIENT_ID"], + ) + except AppError as err: + if err.code == "ACCOUNT_LINK_PENDING": + # El servicio detectó que ya existe una cuenta con ese email. Se devuelve 200 con ACCOUNT_LINK_PENDING y el link_token para que el frontend muestre la pantalla de confirmación antes de vincular. No se emite cookie de sesión todavía. + return success_response( + data={ + "code": err.code, + "message": err.message, + **(err.detail or {}), + }, + status_code=200, + ) + return error_response(code=err.code, message=err.message, status_code=err.status_code) + + result = UserResponseSchema().dump(user) + response, status_code = success_response(data=result, status_code=200) + set_access_cookies(response, tokens["access_token"]) + return response, status_code + + +@auth_bp.route("/google/confirm-link", methods=["POST"]) +@limiter.limit("10 per 15 minutes") +def google_confirm_link(): + """ Completa la vinculación de cuenta Google pendiente tras confirmación explícita del usuario. Recibe el link_token emitido por POST /google cuando se detectó una cuenta existente con el mismo email. Si el token es válido, crea el OAuthAccount, lo marca como usado, y emite la cookie de sesión. """ + data = request.get_json() or {} + link_token = data.get("link_token") + + if not link_token: + return error_response( + code="VALIDATION_ERROR", + message="El link_token es obligatorio.", + status_code=422, + ) + + try: + user, tokens = AuthService.confirm_google_link(link_token) + except AppError as err: + return error_response(code=err.code, message=err.message, status_code=err.status_code) + + result = UserResponseSchema().dump(user) + response, status_code = success_response(data=result, status_code=200) + set_access_cookies(response, tokens["access_token"]) + return response, status_code + + +@auth_bp.route("/verify-email", methods=["GET"]) +def verify_email_check(): + """GET solo valida el token, sin marcar nada como usado ni verificar la cuenta. Seguro para ser prefetcheado por escáneres de correo, no tiene efectos secundarios. Responde 200 con {valid: true, email} si el token sigue siendo válido. """ + token = request.args.get("token") + if not token: + return error_response( + code="VALIDATION_ERROR", + message="El token es obligatorio.", + status_code=422, + ) + + try: + result = AuthService.verify_email_check(token) + except AppError as err: + return error_response(code=err.code, message=err.message, status_code=err.status_code) + + return success_response(data=result, status_code=200) + +@auth_bp.route("/verify-email", methods=["POST"]) +def verify_email_confirm(): + """POST ejecuta la verificación real tras la confirmación explícita del usuario. Vuelve a validar el token para cubrir la ventana entre el GET y el clic del usuario (race condition o token consumido en paralelo). Si sigue siendo válido, muta el estado: marca email_verified_at en el usuario y used_at en el token. """ + data = request.get_json() or {} + token = data.get("token") + if not token: + return error_response( + code="VALIDATION_ERROR", + message="El token es obligatorio.", + status_code=422, + ) + + try: + AuthService.verify_email_confirm(token) + except AppError as err: + return error_response(code=err.code, message=err.message, status_code=err.status_code) + + return success_response( + data={"message": "Correo verificado correctamente."}, + status_code=200, + ) + +@auth_bp.route("/resend-verification", methods=["POST"]) +@limiter.limit("3 per hour") +def resend_verification(): + try: + data = EmailOnlySchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + + generic_ok, status_code = success_response(data={"message": "Si el correo existe y no ha sido verificado, se envió un nuevo enlace."}, status_code=200) + + try: + plain_token, user_email = AuthService.resend_verification(data["email"]) + except AppError as e: + logger.error(f"Error creando token de verificacion: {e.message}") + return generic_ok, status_code + + if plain_token is None: + return generic_ok, status_code + + from app.services.email_service import send_verification_email, build_verification_link + try: + logger.info(f"[DEV] Verification link para {user_email}: {build_verification_link(plain_token)}") + send_verification_email(user_email, plain_token) + except Exception as e: + logger.error(f"Error reenviando correo de verificación a {user_email}: {str(e)}") + + return generic_ok, status_code + +@auth_bp.route("/forgot-password", methods=["POST"]) +@limiter.limit("3 per hour") +def forgot_password(): + try: + data = ForgotPasswordSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + + # Respuesta identica si el correo existe o no; evita que un atacante enumere qué correos están registrados en la plataforma. + generic_ok = success_response( + data={"message": "Si el correo existe, se enviará un enlace de recuperación."}, + status_code=200, + ) + + try: + plain_token, user_email = AuthService.forgot_password(data["email"]) + except AppError as e: + logger.error(f"Error creando token de recuperacion: {e.message}") + return generic_ok + + if plain_token is None: + return generic_ok + + from app.services.email_service import send_password_reset_email, EmailDeliveryError, build_password_reset_link + try: + reset_url = build_password_reset_link(plain_token) + logger.info("[DEV] Reset link para %s: %s", user_email, reset_url) + send_password_reset_email(user_email, plain_token) + except EmailDeliveryError as e: + logger.error(f"Error enviando correo de recuperación a {user_email}: {str(e)}") + + return generic_ok + +@auth_bp.route("/reset-password", methods=["POST"]) +@limiter.limit("3 per hour") +def reset_password(): + try: + data = ResetPasswordSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + + try: + AuthService.reset_password(data["token"], data["new_password"]) + except AppError as err: + return error_response(code=err.code, message=err.message, status_code=err.status_code) + + return success_response(data={"message": "Contraseña actualizada correctamente."}, status_code=200) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py new file mode 100644 index 0000000..9fa34b9 --- /dev/null +++ b/backend/app/controllers/panorama_bp.py @@ -0,0 +1,152 @@ +from flask import Blueprint, request +from app.services.panorama_service import PanoramaService +from app.schemas.skill_schema import SkillResponseSchema +from app.schemas.panorama_schema import ( + CatalogsResponseSchema, + SummaryResponseSchema, + SkillTrendSchema, + TrendsResponseSchema, + GeoResponseSchema, + SalaryResponseSchema, + CompareResponseSchema, +) +from app.utils.response import success_response, error_response + +panorama_bp = Blueprint("panorama_bp", __name__) + +@panorama_bp.route("/skills", methods=["GET"]) +def get_skills(): + skills = PanoramaService.get_all_skills() + result = SkillResponseSchema(many=True).dump(skills) + return success_response(data=result, status_code=200) + +@panorama_bp.route("/catalogs", methods=["GET"]) +def get_catalogs(): + payload = PanoramaService.get_catalogs_data() + result = CatalogsResponseSchema().dump(payload) + return success_response(data=result, status_code=200) + +@panorama_bp.route("/summary", methods=["GET"]) +def get_summary(): + payload = PanoramaService.get_summary_data() + result = SummaryResponseSchema().dump(payload) + return success_response(data=result, status_code=200) + +@panorama_bp.route("/skills/top", methods=["GET"]) +def get_top_skills(): + limit = request.args.get("limit", default=10, type=int) + # Acotamos el limite para evitar que un valor arbitrario en la query fuerce una consulta desproporcionada contra la base de datos — validacion de entrada HTTP, no logica de negocio. + limit = max(1, min(limit, 50)) + payload = PanoramaService.get_top_skills_data(limit=limit) + result = SkillTrendSchema(many=True).dump(payload) + return success_response(data=result, status_code=200) + +@panorama_bp.route("/trends", methods=["GET"]) +def get_trends(): + skill_id = request.args.get("skill_id", type=int) + + if not skill_id: + return error_response( + code="VALIDATION_ERROR", + message="El parametro skill_id es obligatorio.", + status_code=422, + ) + + payload = PanoramaService.get_trends_data(skill_id) + if payload is None: + return error_response( + code="NOT_FOUND", + message="La habilidad solicitada no existe.", + status_code=404, + ) + + result = TrendsResponseSchema().dump(payload) + return success_response(data=result, status_code=200) + +@panorama_bp.route("/geo", methods=["GET"]) +def get_geo(): + skill_id = request.args.get("skill_id", type=int) + group_by = request.args.get("group_by", default="city", type=str) + + if group_by not in ["city", "state"]: + return error_response( + code="VALIDATION_ERROR", + message="El parametro group_by debe ser 'city' o 'state'.", + status_code=422, + ) + + payload = PanoramaService.get_geo_data(skill_id=skill_id, group_by=group_by) + if isinstance(payload, tuple) and payload[0] == "NOT_FOUND": + return error_response( + code="NOT_FOUND", + message="La habilidad solicitada no existe.", + status_code=404, + ) + + result = GeoResponseSchema().dump(payload) + return success_response(data=result, status_code=200) + +@panorama_bp.route("/salaries", methods=["GET"]) +def get_salaries(): + skill_id = request.args.get("skill_id", type=int) + + if not skill_id: + return error_response( + code="VALIDATION_ERROR", + message="El parametro skill_id es obligatorio.", + status_code=422, + ) + + payload = PanoramaService.get_salaries_data(skill_id) + if payload is None: + return error_response( + code="NOT_FOUND", + message="La habilidad solicitada no existe.", + status_code=404, + ) + + result = SalaryResponseSchema().dump(payload) + return success_response(data=result, status_code=200) + +@panorama_bp.route("/compare", methods=["GET"]) +def get_compare(): + # Comparacion lado a lado de multiples habilidades. El frontend la usa para la vista de comparar.html con grafica multi-linea + raw_param = request.args.get("skill_ids", default="", type=str) + + if not raw_param.strip(): + return error_response( + code="VALIDATION_ERROR", + message="El parametro skill_ids es obligatorio.", + status_code=422, + ) + + try: + skill_ids = [int(s.strip()) for s in raw_param.split(",") if s.strip()] + except ValueError: + return error_response( + code="VALIDATION_ERROR", + message="skill_ids debe ser una lista de enteros separados por comas.", + status_code=422, + ) + + # Acotamos entre 2 y 5 habilidades, puesto que comparar una sola no tiene sentido funcional, y mas de 5 degrada la lectura de la grafica + if len(skill_ids) < 2 or len(skill_ids) > 5: + return error_response( + code="VALIDATION_ERROR", + message="skill_ids debe contener entre 2 y 5 habilidades.", + status_code=422, + ) + + data = PanoramaService.get_compare_data(skill_ids) + + if data["missing_ids"]: + return error_response( + code="NOT_FOUND", + message=f"Las siguientes habilidades no existen: {data['missing_ids']}.", + status_code=404, + ) + + blocks = data["blocks"] + + result = CompareResponseSchema().dump({"skills": blocks}) + return success_response(data=result, status_code=200) diff --git a/backend/app/controllers/profile_bp.py b/backend/app/controllers/profile_bp.py new file mode 100644 index 0000000..f6734a3 --- /dev/null +++ b/backend/app/controllers/profile_bp.py @@ -0,0 +1,131 @@ +import logging +from flask import Blueprint, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from marshmallow import ValidationError + +from app.repositories.skill_repository import SkillRepository +from app.repositories.user_repository import UserRepository +from app.repositories.user_skill_repository import UserSkillRepository +from app.services.profile_service import ProfileService +from app.schemas.profile_schema import ( + UpdateProfileSchema, + AddSkillSchema, + ChangePasswordSchema, +) +from app.utils.decorators import role_required +from app.utils.response import success_response, error_response + +logger = logging.getLogger(__name__) + +profile_bp = Blueprint("profile_bp", __name__) + + +@profile_bp.route("/me", methods=["GET"]) +@jwt_required() +@role_required("REGISTERED", "ADMIN") +def get_profile(): + user_id = int(get_jwt_identity()) + user = UserRepository.get_by_id(user_id) + if not user: + return error_response(code="NOT_FOUND", message="Usuario no encontrado.", status_code=404) + + return success_response(data={ + "id": user.id, + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "role": user.role, + "intent": user.intent, + "created_at": user.created_at.isoformat() if user.created_at else None, + }) + + +@profile_bp.route("/me", methods=["PATCH"]) +@jwt_required() +@role_required("REGISTERED", "ADMIN") +def update_profile(): + try: + data = UpdateProfileSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + + user_id = int(get_jwt_identity()) + user = ProfileService.update_profile(user_id, data) + if not user: + return error_response(code="NOT_FOUND", message="Usuario no encontrado.", status_code=404) + + return success_response(data={ + "id": user.id, + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "role": user.role, + "intent": user.intent, + "created_at": user.created_at.isoformat() if user.created_at else None, + }) + + +@profile_bp.route("/skill-gap", methods=["GET"]) +@jwt_required() +@role_required("REGISTERED", "ADMIN") +def get_skill_gap(): + user_id = int(get_jwt_identity()) + result = ProfileService.get_skill_gap(user_id) + return success_response(data=result) + + +@profile_bp.route("/skills", methods=["POST"]) +@jwt_required() +@role_required("REGISTERED", "ADMIN") +def add_skill(): + try: + data = AddSkillSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + + skill_id = data["skill_id"] + skill = SkillRepository.get_by_id(skill_id) + if not skill: + return error_response(code="SKILL_NOT_FOUND", message="La habilidad no existe.", status_code=404) + + user_id = int(get_jwt_identity()) + UserSkillRepository.add_skill(user_id, skill_id) + return success_response(data={"skill_id": skill_id, "name": skill.name}, status_code=201) + + +@profile_bp.route("/skills/", methods=["DELETE"]) +@jwt_required() +@role_required("REGISTERED", "ADMIN") +def remove_skill(skill_id: int): + user_id = int(get_jwt_identity()) + # La eliminacion es idempotente dado qué responde 200 tanto si existia la relacion como si no. + UserSkillRepository.remove_skill(user_id, skill_id) + return success_response(data={"message": "Habilidad eliminada del perfil."}) + + +@profile_bp.route("/change-password", methods=["POST"]) +@jwt_required() +@role_required("REGISTERED", "ADMIN") +def change_password(): + try: + data = ChangePasswordSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + + user_id = int(get_jwt_identity()) + try: + ProfileService.change_password( + user_id, + data["current_password"], + data["new_password"], + ) + except ValueError as e: + if str(e) == "INVALID_CREDENTIALS": + return error_response( + code="INVALID_CREDENTIALS", + message="La contrasena actual es incorrecta.", + status_code=400, + ) + raise + + return success_response(data={"message": "Contrasena actualizada correctamente."}) diff --git a/backend/app/extensions.py b/backend/app/extensions.py new file mode 100644 index 0000000..c785385 --- /dev/null +++ b/backend/app/extensions.py @@ -0,0 +1,17 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_jwt_extended import JWTManager +from flask_cors import CORS +from apscheduler.schedulers.background import BackgroundScheduler +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address + +db = SQLAlchemy() +migrate = Migrate() +jwt = JWTManager() +cors = CORS() +scheduler = BackgroundScheduler() +limiter = Limiter( + key_func=get_remote_address, + default_limits=["200 per day", "50 per hour"], +) diff --git a/backend/app/models/README.md b/backend/app/models/README.md new file mode 100644 index 0000000..7625c05 --- /dev/null +++ b/backend/app/models/README.md @@ -0,0 +1,22 @@ +# models/ + +Aqui definimos la estructura de los datos del sistema usando SQLAlchemy. +Cada archivo representa una tabla de la base de datos. + +## Lo que va aqui + +- La definicion de columnas, tipos de dato y restricciones +- Las relaciones entre tablas (claves foraneas, backrefs) +- Las restricciones de integridad (UNIQUE, CHECK, NOT NULL) + +## Lo que no va aqui + +La logica de negocio no vive en los modelos. Los modelos describen +la forma de los datos, no lo que hacemos con ellos. + +## Las entidades del proyecto + +`category`, `city`, `skill`, `job`, `job_skill`, `user`, `alert`, +`trend_snapshot`, `backup`. + +El diagrama entidad-relacion completo esta en `docs/diagramas/er.png`. diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..0db1b1e --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,31 @@ +from .category import Category +from .city import City +from .skill import Skill +from .job import Job +from .job_skill import JobSkill +from .user import User +from .oauth_account import OAuthAccount +from .password_reset_token import PasswordResetToken +from .email_verification_token import EmailVerificationToken +from .alert import Alert +from .trend_snapshot import TrendSnapshot +from .backup import Backup +from .user_skill import UserSkill +from .google_link_token import GoogleLinkToken + +__all__ = [ + "Category", + "City", + "Skill", + "Job", + "JobSkill", + "User", + "OAuthAccount", + "PasswordResetToken", + "EmailVerificationToken", + "Alert", + "TrendSnapshot", + "Backup", + "UserSkill", + "GoogleLinkToken", +] diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py new file mode 100644 index 0000000..59b7647 --- /dev/null +++ b/backend/app/models/alert.py @@ -0,0 +1,28 @@ +from app.extensions import db +from datetime import datetime, timezone + + +class Alert(db.Model): + __tablename__ = "user_alerts" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + skill_id = db.Column(db.Integer, db.ForeignKey("skills.id"), nullable=False) + alert_type = db.Column(db.String(20), nullable=False, default="ABSOLUTE") + # Nullable porque solo aplica a alertas de tipo ABSOLUTE + threshold_value = db.Column(db.Integer, nullable=True) + # Nullable porque solo aplica a alertas de tipo TREND + threshold_percentage = db.Column(db.Numeric(5, 2), nullable=True) + active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + __table_args__ = ( + db.CheckConstraint( + "alert_type IN ('ABSOLUTE', 'TREND')", name="chk_alerts_type" + ), + db.CheckConstraint( + "(alert_type = 'ABSOLUTE' AND threshold_value IS NOT NULL AND threshold_percentage IS NULL) OR " + "(alert_type = 'TREND' AND threshold_percentage IS NOT NULL AND threshold_value IS NULL)", + name="chk_alerts_threshold_matches_type", + ), + ) diff --git a/backend/app/models/backup.py b/backend/app/models/backup.py new file mode 100644 index 0000000..3f660ed --- /dev/null +++ b/backend/app/models/backup.py @@ -0,0 +1,21 @@ +from app.extensions import db +from datetime import datetime, timezone + + +class Backup(db.Model): + __tablename__ = "backups" + + id = db.Column(db.Integer, primary_key=True) + # Habilitamos nulos para soportar la ejecución de respaldos automáticos desde el scheduler sin un usuario físico atado + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + filename = db.Column(db.String(255), nullable=False) + storage_url = db.Column(db.String(500), nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + status = db.Column(db.String(20), nullable=False) + file_size_bytes = db.Column(db.BigInteger, nullable=True) + + __table_args__ = ( + db.CheckConstraint( + "status IN ('PENDING', 'COMPLETED', 'FAILED')", name="chk_backups_status" + ), + ) diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 0000000..3c0cd28 --- /dev/null +++ b/backend/app/models/category.py @@ -0,0 +1,10 @@ +from app.extensions import db + + +class Category(db.Model): + __tablename__ = "categories" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), nullable=False, unique=True) + + skills = db.relationship("Skill", backref="category", lazy=True) diff --git a/backend/app/models/city.py b/backend/app/models/city.py new file mode 100644 index 0000000..8dee8fe --- /dev/null +++ b/backend/app/models/city.py @@ -0,0 +1,16 @@ +from app.extensions import db + + +class City(db.Model): + __tablename__ = "cities" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False, unique=True) + state = db.Column(db.String(100), nullable=True) + # Establecemos México como default tanto a nivel aplicación como base de datos + country = db.Column(db.String(10), default="MX", server_default="MX", nullable=True) + lat = db.Column(db.Numeric(9, 6), nullable=True) + lon = db.Column(db.Numeric(9, 6), nullable=True) + + jobs = db.relationship("Job", backref="city", lazy=True) + trend_snapshots = db.relationship("TrendSnapshot", backref="city", lazy=True) diff --git a/backend/app/models/email_verification_token.py b/backend/app/models/email_verification_token.py new file mode 100644 index 0000000..d05efa9 --- /dev/null +++ b/backend/app/models/email_verification_token.py @@ -0,0 +1,28 @@ +from datetime import datetime, timezone +from app.extensions import db + + +class EmailVerificationToken(db.Model): + __tablename__ = "email_verification_tokens" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + token_hash = db.Column(db.String(64), unique=True, nullable=False) + expires_at = db.Column(db.DateTime, nullable=False) + used_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column( + db.DateTime, + default=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + __table_args__ = ( + db.Index("ix_email_verification_tokens_token_hash", "token_hash"), + ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/google_link_token.py b/backend/app/models/google_link_token.py new file mode 100644 index 0000000..2d8a522 --- /dev/null +++ b/backend/app/models/google_link_token.py @@ -0,0 +1,38 @@ +from datetime import datetime, timezone +from app.extensions import db + +class GoogleLinkToken(db.Model): + """ Token de corta vida que transporta la identidad de Google entre el primer intento de login (POST /google) y la confirmación explícita del usuario (POST /google/confirm-link). Solo se genera cuando existe ya un User con el mismo email pero sin OAuthAccount vinculado; el caso de cuenta nueva no pasa por este flujo. """ + + __tablename__ = "google_link_tokens" + + id = db.Column(db.Integer, primary_key=True) + # El usuario cuya cuenta se va a vincular si el token se confirma. + user_id = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + # Hash SHA-256 del token en texto plano. El token plano NUNCA se persiste, solo existe en la respuesta JSON que el controlador devuelve al frontend para que lo reenvíe en la confirmación. + token_hash = db.Column(db.String(64), unique=True, nullable=False) + # Identidad de Google que se vinculará al confirmar; necesaria para crear el OAuthAccount sin volver a verificar el token de Google. + google_user_id = db.Column(db.String(255), nullable=False) + # Datos del perfil de Google que se usarán si se actualiza el perfil del usuario tras la vinculación (fuera de alcance en esta ronda, pero presentes en la tabla para no requerir una migración adicional después). + pending_email = db.Column(db.String(254), nullable=False) + pending_first_name = db.Column(db.String(100), nullable=True) + pending_last_name = db.Column(db.String(100), nullable=True) + # Ventana de confirmación: 15 minutos es suficiente para que el usuario lea la pantalla de confirmación y haga clic, sin ser tan larga como para acumular tokens no confirmados indefinidamente. + expires_at = db.Column(db.DateTime, nullable=False) + used_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column( + db.DateTime, + default=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + __table_args__ = ( + db.Index("ix_google_link_tokens_token_hash", "token_hash"), + ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/job.py b/backend/app/models/job.py new file mode 100644 index 0000000..82ec882 --- /dev/null +++ b/backend/app/models/job.py @@ -0,0 +1,30 @@ +from app.extensions import db +from datetime import datetime, timezone + + +class Job(db.Model): + __tablename__ = "jobs" + + id = db.Column(db.Integer, primary_key=True) + source = db.Column(db.String(50), nullable=False) + title = db.Column(db.String(255), nullable=False) + company = db.Column(db.String(255), nullable=True) + # Permitimos nulos en city_id para no bloquear la ingesta de vacantes remotas o sin geolocalización + city_id = db.Column(db.Integer, db.ForeignKey("cities.id"), nullable=True) + salary_min = db.Column(db.Numeric(10, 2), nullable=True) + salary_max = db.Column(db.Numeric(10, 2), nullable=True) + raw_description = db.Column(db.Text, nullable=False) + + # Guardamos el hash de la descripción para detectar rápidamente si una vacante ya fue procesada o si cambió en su origen + description_hash = db.Column(db.String(64), nullable=False, unique=True) + processed = db.Column(db.Boolean, default=False, nullable=False) + remote = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("false")) + + created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + job_skills = db.relationship("JobSkill", backref="job", lazy=True) diff --git a/backend/app/models/job_skill.py b/backend/app/models/job_skill.py new file mode 100644 index 0000000..efed216 --- /dev/null +++ b/backend/app/models/job_skill.py @@ -0,0 +1,13 @@ +from app.extensions import db +from datetime import datetime, timezone + + +class JobSkill(db.Model): + __tablename__ = "job_skills" + + # Utilizamos llave primaria compuesta para evitar identificadores subrogados innecesarios y cumplir la 3FN + job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"), primary_key=True) + skill_id = db.Column(db.Integer, db.ForeignKey("skills.id"), primary_key=True) + + confidence_score = db.Column(db.Numeric(4, 3), nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/oauth_account.py b/backend/app/models/oauth_account.py new file mode 100644 index 0000000..9ffe9b4 --- /dev/null +++ b/backend/app/models/oauth_account.py @@ -0,0 +1,26 @@ +from datetime import datetime, timezone +from app.extensions import db + + +class OAuthAccount(db.Model): + __tablename__ = "oauth_accounts" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + provider = db.Column(db.String(20), nullable=False) + provider_user_id = db.Column(db.String(255), nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + # Restringimos a los proveedores que el equipo planea soportar, igual que el CHECK de role en User, para que agregar GitHub o Apple despues solo amplie esta lista, sin rediseñar la tabla. + __table_args__ = ( + db.CheckConstraint( + "provider IN ('google', 'github', 'apple')", + name="chk_oauth_accounts_provider", + ), + db.UniqueConstraint( + "provider", "provider_user_id", name="uq_oauth_accounts_provider_identity" + ), + ) + + def __repr__(self): + return f" user_id={self.user_id}>" diff --git a/backend/app/models/password_reset_token.py b/backend/app/models/password_reset_token.py new file mode 100644 index 0000000..d8a81c0 --- /dev/null +++ b/backend/app/models/password_reset_token.py @@ -0,0 +1,31 @@ +from datetime import datetime, timezone +from app.extensions import db + + +class PasswordResetToken(db.Model): + __tablename__ = "password_reset_tokens" + + id = db.Column(db.Integer, primary_key=True) + # Si el usuario se elimina, sus tokens de reset se eliminan con el (ondelete CASCADE). + user_id = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + # Almacenamos el hash SHA-256 del token en texto plano. El token plano NUNCA se persiste, solo existe en el log de desarrollo y en el correo que el usuario recibe. + token_hash = db.Column(db.String(64), unique=True, nullable=False) + expires_at = db.Column(db.DateTime, nullable=False) + # used_at queda null mientras el token no se ha consumido. + used_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column( + db.DateTime, + default=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + __table_args__ = ( + db.Index("ix_password_reset_tokens_token_hash", "token_hash"), + ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/skill.py b/backend/app/models/skill.py new file mode 100644 index 0000000..c991256 --- /dev/null +++ b/backend/app/models/skill.py @@ -0,0 +1,16 @@ +from app.extensions import db + + +class Skill(db.Model): + __tablename__ = "skills" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + canonical_name = db.Column(db.String(100), nullable=False, unique=True) + category_id = db.Column(db.Integer, db.ForeignKey("categories.id"), nullable=False) + + # Relacionamos bidireccionalmente mediante el patrón Association Object para mantener la normalización 3FN en job_skills + job_skills = db.relationship("JobSkill", backref="skill", lazy=True) + alerts = db.relationship("Alert", backref="skill", lazy=True) + trend_snapshots = db.relationship("TrendSnapshot", backref="skill", lazy=True) + user_skills = db.relationship("UserSkill", backref="skill", lazy=True) diff --git a/backend/app/models/trend_snapshot.py b/backend/app/models/trend_snapshot.py new file mode 100644 index 0000000..a5caf39 --- /dev/null +++ b/backend/app/models/trend_snapshot.py @@ -0,0 +1,20 @@ +from app.extensions import db + + +class TrendSnapshot(db.Model): + __tablename__ = "trend_snapshots" + + id = db.Column(db.Integer, primary_key=True) + skill_id = db.Column(db.Integer, db.ForeignKey("skills.id"), nullable=False) + city_id = db.Column(db.Integer, db.ForeignKey("cities.id"), nullable=True) + date = db.Column(db.Date, nullable=False) + demand_count = db.Column(db.Integer, default=0, nullable=True) + growth_rate = db.Column(db.Numeric(6, 2), nullable=True) + avg_salary = db.Column(db.Numeric(10, 2), nullable=True) + + # Forzamos unicidad combinada para garantizar que no existan métricas duplicadas para la misma ciudad, habilidad y fecha + __table_args__ = ( + db.UniqueConstraint( + "skill_id", "city_id", "date", name="uq_trend_snapshot_skill_city_date" + ), + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..0d07a97 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,41 @@ +from app.extensions import db +from datetime import datetime, timezone + + +class User(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True, nullable=False) + first_name = db.Column(db.String(50), nullable=False) + last_name = db.Column(db.String(50), nullable=False) + # Nullable porque un usuario que entra solo via Google/GitHub/Apple nunca define una contrasena propia. + password_hash = db.Column(db.String(255), nullable=True) + role = db.Column(db.String(20), nullable=False) + created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + # Null para usuarios exclusivamente OAuth (sin contraseña propia). El blocklist callback trata null como "sin restricción"; esos usuarios nunca se desloguean por este mecanismo porque no tienen contraseña que cambiar. + password_changed_at = db.Column(db.DateTime, nullable=True) + # Null es el estado valido para "sin definir"; el valor se puede completar mas adelante desde el perfil. + intent = db.Column(db.String(20), nullable=True) + # Fecha de verificacion de correo, null si no esta verificado + email_verified_at = db.Column(db.DateTime, nullable=True) + # Soft-delete / desactivación de cuenta. False bloquea todas las sesiones activas via token_in_blocklist_loader sin eliminar el registro. + is_active = db.Column(db.Boolean, nullable=False, default=True) + + alerts = db.relationship("Alert", backref="user", lazy=True) + backups = db.relationship("Backup", backref="user", lazy=True) + oauth_accounts = db.relationship("OAuthAccount", backref="user", lazy=True) + user_skills = db.relationship("UserSkill", backref="user", lazy=True) + + # Restringimos los roles y los intents permitidos directamente en la base de datos por seguridad + __table_args__ = ( + db.CheckConstraint( + "role IN ('REGISTERED', 'ADMIN')", name="chk_users_role" + ), + db.CheckConstraint( + "intent IS NULL OR intent IN ('ESTUDIANTE', 'RECLUTADOR')", name="chk_users_intent" + ), + ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/user_skill.py b/backend/app/models/user_skill.py new file mode 100644 index 0000000..b0bd11c --- /dev/null +++ b/backend/app/models/user_skill.py @@ -0,0 +1,12 @@ +from app.extensions import db +from datetime import datetime, timezone + + +class UserSkill(db.Model): + __tablename__ = "user_skills" + + # Utilizamos llave primaria compuesta para evitar identificadores subrogados innecesarios y cumplir la tercera forma normal, siguiendo el mismo patron que ya implementamos en job_skills. + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), primary_key=True) + skill_id = db.Column(db.Integer, db.ForeignKey("skills.id"), primary_key=True) + + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/repositories/README.md b/backend/app/repositories/README.md new file mode 100644 index 0000000..6ccddd5 --- /dev/null +++ b/backend/app/repositories/README.md @@ -0,0 +1,23 @@ +# repositories/ + +Aqui vive toda la logica de acceso a la base de datos. Los repositorios +son el unico lugar del sistema donde hablamos directamente con PostgreSQL +a traves del ORM. + +## Lo que va aqui + +- Las consultas a la base de datos (lecturas, escrituras, filtros) +- La logica de paginacion y ordenamiento +- Las operaciones CRUD de cada entidad + +## Lo que no va aqui + +La logica de negocio no vive aqui. Si necesitamos hacer algo con los +datos despues de leerlos, ese trabajo pertenece al servicio que llamo +al repositorio. + +## Como funciona + +`base_repository.py` contiene las operaciones comunes (guardar, buscar +por ID, listar, eliminar). Cada repositorio especifico extiende esa base +y agrega las consultas particulares que su entidad necesita. diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..5a5e1a9 --- /dev/null +++ b/backend/app/repositories/__init__.py @@ -0,0 +1,21 @@ +from .category_repository import CategoryRepository +from .city_repository import CityRepository +from .skill_repository import SkillRepository +from .job_repository import JobRepository +from .job_skill_repository import JobSkillRepository +from .user_repository import UserRepository +from .alert_repository import AlertRepository +from .trend_snapshot_repository import TrendSnapshotRepository +from .backup_repository import BackupRepository + +__all__ = [ + "CategoryRepository", + "CityRepository", + "SkillRepository", + "JobRepository", + "JobSkillRepository", + "UserRepository", + "AlertRepository", + "TrendSnapshotRepository", + "BackupRepository", +] diff --git a/backend/app/repositories/alert_repository.py b/backend/app/repositories/alert_repository.py new file mode 100644 index 0000000..8794e9c --- /dev/null +++ b/backend/app/repositories/alert_repository.py @@ -0,0 +1,16 @@ +from app.repositories.base_repository import BaseRepository +from app.models import Alert + + +class AlertRepository(BaseRepository): + model = Alert + + @classmethod + def get_by_user_id(cls, user_id: int) -> list: + # Filtramos directamente en la base de datos para no traer alertas ajenas al usuario en memoria innecesariamente. + return Alert.query.filter_by(user_id=user_id).all() + + @classmethod + def get_active(cls) -> list: + # Filtramos alertas inactivas para no seguir notificando sobre alertas que el usuario ya desactivó. AlertsService.evaluate_and_notify() usaba get_all() sin filtro hasta esta corrección. + return Alert.query.filter_by(active=True).all() diff --git a/backend/app/repositories/backup_repository.py b/backend/app/repositories/backup_repository.py new file mode 100644 index 0000000..7e51bac --- /dev/null +++ b/backend/app/repositories/backup_repository.py @@ -0,0 +1,26 @@ +from app.repositories.base_repository import BaseRepository +from app.models import Backup +from app.extensions import db + + +from app.models.user import User + + +class BackupRepository(BaseRepository): + model = Backup + + @classmethod + def get_paginated(cls, page: int = 1, per_page: int = 20): + # Ordenamos descendente por fecha para que el backup más reciente aparezca primero en el panel de administración + query = ( + db.select(cls.model, User.email) + .outerjoin(User, User.id == cls.model.user_id) + .order_by(cls.model.created_at.desc()) + ) + total = db.session.execute( + db.select(db.func.count()).select_from(cls.model) + ).scalar_one() + rows = db.session.execute( + query.limit(per_page).offset((page - 1) * per_page) + ).all() + return rows, total diff --git a/backend/app/repositories/base_repository.py b/backend/app/repositories/base_repository.py new file mode 100644 index 0000000..eb448e0 --- /dev/null +++ b/backend/app/repositories/base_repository.py @@ -0,0 +1,81 @@ +import logging +from sqlalchemy.exc import IntegrityError +from sqlalchemy import inspect +from app.extensions import db +from app.utils.errors import AppError, ConflictError + +logger = logging.getLogger(__name__) + + +class BaseRepository: + # Repositorio genérico con soporte de instanciación dinámica y segura + model = None + + @classmethod + def create(cls, data: dict): + # Extraemos solo las llaves que corresponden a columnas reales en la base de datos, ignorando cualquier metadato extra proveniente de APIs externas o DTOs mal alineados + mapper = inspect(cls.model) + valid_keys = mapper.columns.keys() + + filtered_data = {k: v for k, v in data.items() if k in valid_keys} + + entity = cls.model(**filtered_data) + return cls.save(entity) + + @classmethod + def save(cls, entity): + db.session.add(entity) + try: + db.session.commit() + return entity + except IntegrityError as e: + db.session.rollback() + logger.warning( + "Violacion de integridad al guardar %s: %s", + cls.model.__name__, str(e) + ) + raise ConflictError( + f"No se pudo guardar {cls.model.__name__}: conflicto de integridad de datos." + ) + except Exception as e: + db.session.rollback() + logger.error( + "Fallo inesperado al guardar %s: %s", + cls.model.__name__, str(e) + ) + raise AppError( + f"Error interno al guardar {cls.model.__name__}.", + code="DATABASE_ERROR", + status_code=500, + ) + + @classmethod + def get_by_id(cls, entity_id: int): + return db.session.get(cls.model, entity_id) + + @classmethod + def get_all(cls): + return db.session.execute(db.select(cls.model)).scalars().all() + + @classmethod + def delete(cls, entity_id: int) -> bool: + entity = cls.get_by_id(entity_id) + if entity: + db.session.delete(entity) + db.session.commit() + return True + return False + + @classmethod + def update(cls, entity_id: int, data: dict): + # Localizamos la entidad, aplicamos solo las llaves que corresponden a columnas reales (mismo criterio que create) y persistimos vía save para mantener el mismo contrato de commit/rollback. + entity = cls.get_by_id(entity_id) + if not entity: + # "No encontrado" es semánticamente distinto a un fallo de persistencia -- no se convierte en excepción + return None + mapper = inspect(cls.model) + valid_keys = mapper.columns.keys() + for key, value in data.items(): + if key in valid_keys: + setattr(entity, key, value) + return cls.save(entity) diff --git a/backend/app/repositories/category_repository.py b/backend/app/repositories/category_repository.py new file mode 100644 index 0000000..01712e7 --- /dev/null +++ b/backend/app/repositories/category_repository.py @@ -0,0 +1,6 @@ +from app.repositories.base_repository import BaseRepository +from app.models import Category + + +class CategoryRepository(BaseRepository): + model = Category diff --git a/backend/app/repositories/city_repository.py b/backend/app/repositories/city_repository.py new file mode 100644 index 0000000..531ddbd --- /dev/null +++ b/backend/app/repositories/city_repository.py @@ -0,0 +1,26 @@ +from app.repositories.base_repository import BaseRepository +from app.models.city import City +from app.extensions import db + +class CityRepository(BaseRepository): + model = City + + @classmethod + def get_by_name(cls, name: str): + return db.session.execute( + db.select(City).filter_by(name=name) + ).scalar_one_or_none() + + @classmethod + def find_by_normalized_name(cls, raw_name: str): + # Usa unaccent() nativo de PostgreSQL para busqueda insensible a acentos y mayusculas, reemplazando el escaneo completo en memoria que hacia _normalize. Sin indice funcional (unaccent de un solo argumento es STABLE, no IMMUTABLE, y el wrapper IMMUTABLE resulto incompatible entre versiones de PostgreSQL), aceptable dado el volumen actual del catalogo de ciudades; escanea la tabla completa en el motor, no en Python, que ya es la mejora real sobre el comportamiento anterior. + if not raw_name: + return None + raw_name = raw_name.strip() + from sqlalchemy import func + normalized_input = func.unaccent(func.lower(raw_name)) + return db.session.execute( + db.select(City).filter( + func.unaccent(func.lower(City.name)) == normalized_input + ) + ).scalar_one_or_none() diff --git a/backend/app/repositories/email_verification_token_repository.py b/backend/app/repositories/email_verification_token_repository.py new file mode 100644 index 0000000..53bd667 --- /dev/null +++ b/backend/app/repositories/email_verification_token_repository.py @@ -0,0 +1,20 @@ +from datetime import datetime, timezone +from app.models.email_verification_token import EmailVerificationToken +from app.repositories.base_repository import BaseRepository +from app.extensions import db + +class EmailVerificationTokenRepository(BaseRepository): + model = EmailVerificationToken + + @classmethod + def get_by_token_hash(cls, token_hash: str) -> EmailVerificationToken: + return cls.model.query.filter_by(token_hash=token_hash).first() + + @classmethod + def mark_as_used(cls, token_instance: EmailVerificationToken) -> EmailVerificationToken: + token_instance.used_at = datetime.now(timezone.utc) + return cls.save(token_instance) + + @classmethod + def get_unused_by_user_id(cls, user_id: int): + return cls.model.query.filter_by(user_id=user_id, used_at=None).all() diff --git a/backend/app/repositories/google_link_token_repository.py b/backend/app/repositories/google_link_token_repository.py new file mode 100644 index 0000000..7635f2a --- /dev/null +++ b/backend/app/repositories/google_link_token_repository.py @@ -0,0 +1,20 @@ +from datetime import datetime, timezone +from app.models.google_link_token import GoogleLinkToken +from app.repositories.base_repository import BaseRepository +from app.extensions import db + +class GoogleLinkTokenRepository(BaseRepository): + model = GoogleLinkToken + + @classmethod + def get_by_token_hash(cls, token_hash: str): + # Búsqueda principal del flujo de confirmación: localiza el token por su hash para validar vigencia y uso antes de completar la vinculación de cuenta. + return db.session.execute( + db.select(GoogleLinkToken).filter_by(token_hash=token_hash) + ).scalar_one_or_none() + + @classmethod + def mark_as_used(cls, token: GoogleLinkToken): + # Invalida el token en el mismo instante en que se consume, impide que el mismo link de confirmación sirva para vincular dos veces. + token.used_at = datetime.now(timezone.utc) + return cls.save(token) diff --git a/backend/app/repositories/job_repository.py b/backend/app/repositories/job_repository.py new file mode 100644 index 0000000..a2260e1 --- /dev/null +++ b/backend/app/repositories/job_repository.py @@ -0,0 +1,31 @@ +from app.models.job import Job +from app.repositories.base_repository import BaseRepository +from app.extensions import db + +class JobRepository(BaseRepository): + model = Job + + @classmethod + def create(cls, data: dict): + # Capa Anticorrupción (ACL) + mapped_data = data.copy() + + # Traducción de vocabulario + if "description" in mapped_data: + mapped_data["raw_description"] = mapped_data.pop("description") + + # Eliminación de datos no mapeados en BD + if "url" in mapped_data: + del mapped_data["url"] + + # Inyectar la fuente de forma centralizada para que los consumidores de job_data no necesiten conocer el detalle del proveedor externo + mapped_data["source"] = "Adzuna" + + return super().create(mapped_data) + + + @classmethod + def get_by_hash(cls, description_hash: str): + return db.session.execute( + db.select(Job).filter_by(description_hash=description_hash) + ).scalar_one_or_none() diff --git a/backend/app/repositories/job_skill_repository.py b/backend/app/repositories/job_skill_repository.py new file mode 100644 index 0000000..4155738 --- /dev/null +++ b/backend/app/repositories/job_skill_repository.py @@ -0,0 +1,18 @@ +from datetime import datetime, timedelta, timezone +from app.models.job_skill import JobSkill +from app.models.job import Job +from app.repositories.base_repository import BaseRepository +from app.extensions import db + +class JobSkillRepository(BaseRepository): + model = JobSkill + + @classmethod + def get_active(cls, days: int = 30): + # Filtramos por antigüedad de la vacante asociada para que demand_count refleje actividad reciente del mercado en vez de un acumulado historico que nunca puede bajar. Sin esto, growth_rate no podria detectar declive real de ninguna habilidad. + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + return db.session.execute( + db.select(JobSkill) + .join(Job, Job.id == JobSkill.job_id) + .filter(Job.created_at >= cutoff) + ).scalars().all() diff --git a/backend/app/repositories/oauth_account_repository.py b/backend/app/repositories/oauth_account_repository.py new file mode 100644 index 0000000..e78d50c --- /dev/null +++ b/backend/app/repositories/oauth_account_repository.py @@ -0,0 +1,16 @@ +from app.repositories.base_repository import BaseRepository +from app.models.oauth_account import OAuthAccount +from app.extensions import db + + +class OAuthAccountRepository(BaseRepository): + model = OAuthAccount + + @classmethod + def get_by_provider_identity(cls, provider: str, provider_user_id: str): + # Busqueda especializada para el flujo de login: identifica si esta combinacion de proveedor + id externo ya esta vinculada a una cuenta existente. + return db.session.execute( + db.select(OAuthAccount).filter_by( + provider=provider, provider_user_id=provider_user_id + ) + ).scalar_one_or_none() diff --git a/backend/app/repositories/password_reset_token_repository.py b/backend/app/repositories/password_reset_token_repository.py new file mode 100644 index 0000000..d28f361 --- /dev/null +++ b/backend/app/repositories/password_reset_token_repository.py @@ -0,0 +1,21 @@ +from datetime import datetime, timezone +from app.repositories.base_repository import BaseRepository +from app.models.password_reset_token import PasswordResetToken +from app.extensions import db + + +class PasswordResetTokenRepository(BaseRepository): + model = PasswordResetToken + + @classmethod + def get_by_token_hash(cls, token_hash: str): + # Búsqueda principal del flujo de reset, localiza el token por su hash para poder validar vigencia y uso antes de permitir el cambio de contraseña. + return db.session.execute( + db.select(PasswordResetToken).filter_by(token_hash=token_hash) + ).scalar_one_or_none() + + @classmethod + def mark_as_used(cls, token: PasswordResetToken): + # Invalida el token en el mismo instante en que se consume, impide que un mismo link de reset sirva para cambiar la contraseña dos veces. + token.used_at = datetime.now(timezone.utc) + return cls.save(token) diff --git a/backend/app/repositories/skill_repository.py b/backend/app/repositories/skill_repository.py new file mode 100644 index 0000000..c4a0303 --- /dev/null +++ b/backend/app/repositories/skill_repository.py @@ -0,0 +1,45 @@ +from app.models.skill import Skill +from app.repositories.base_repository import BaseRepository +from app.extensions import db + +class SkillRepository(BaseRepository): + model = Skill + + @classmethod + def get_by_name(cls, name: str) -> Skill: + # Buscamos ignorando mayúsculas/minúsculas para evitar duplicados en ingesta + from sqlalchemy import func + return db.session.execute( + db.select(Skill).filter(func.lower(Skill.name) == name.lower()) + ).scalar_one_or_none() + + @classmethod + def get_by_canonical_name(cls, canonical_name: str) -> Skill: + return db.session.execute( + db.select(Skill).filter_by(canonical_name=canonical_name) + ).scalar_one_or_none() + + @classmethod + def get_by_ids(cls, skill_ids: list[int]) -> list: + return db.session.execute( + db.select(Skill).filter(Skill.id.in_(skill_ids)) + ).scalars().all() + + @classmethod + def get_salary_stats(cls, skill_id: int): + from sqlalchemy import func + from app.models.job import Job + from app.models.job_skill import JobSkill + + # Solo consideramos vacantes que efectivamente declaran ambos extremos del rango salarial para no distorsionar el promedio con ceros o valores parciales. + return db.session.execute( + db.select( + func.avg(Job.salary_min).label("avg_salary_min"), + func.avg(Job.salary_max).label("avg_salary_max"), + func.count(Job.id).label("sample_size"), + ) + .join(JobSkill, JobSkill.job_id == Job.id) + .filter(JobSkill.skill_id == skill_id) + .filter(Job.salary_min.isnot(None)) + .filter(Job.salary_max.isnot(None)) + ).first() diff --git a/backend/app/repositories/trend_snapshot_repository.py b/backend/app/repositories/trend_snapshot_repository.py new file mode 100644 index 0000000..a5a170f --- /dev/null +++ b/backend/app/repositories/trend_snapshot_repository.py @@ -0,0 +1,197 @@ +from sqlalchemy import desc +from app.repositories.base_repository import BaseRepository +from app.models.trend_snapshot import TrendSnapshot +from app.extensions import db + + +class TrendSnapshotRepository(BaseRepository): + model = TrendSnapshot + + @classmethod + def get_latest_by_skill(cls, skill_id: int): + return db.session.execute( + db.select(TrendSnapshot) + .filter_by(skill_id=skill_id) + .order_by(desc(TrendSnapshot.date)) + .limit(1) + ).scalar_one_or_none() + + @classmethod + def get_latest_by_skill_ids(cls, skill_ids: list[int]) -> list: + # DISTINCT ON requiere que el primer campo de ORDER BY coincida con la columna de distinct, así garantizamos una fila por skill_id: la de fecha mas reciente (date DESC). + return db.session.execute( + db.select(TrendSnapshot) + .filter(TrendSnapshot.skill_id.in_(skill_ids)) + .distinct(TrendSnapshot.skill_id) + .order_by(TrendSnapshot.skill_id, desc(TrendSnapshot.date)) + ).scalars().all() + + @classmethod + def get_by_skill_city_date(cls, skill_id: int, city_id: int, target_date): + return db.session.execute( + db.select(TrendSnapshot) + .filter_by(skill_id=skill_id, city_id=city_id, date=target_date) + ).scalar_one_or_none() + + @classmethod + def upsert(cls, data: dict): + from sqlalchemy.dialects.postgresql import insert + + stmt = insert(cls.model).values(**data) + + # Al chocar con la constraint única de (skill_id, city_id, date), actualizamos los valores. Esto permite que el generador de snapshots sea idempotente y actualice métricas el mismo día si entran nuevos jobs + stmt = stmt.on_conflict_do_update( + constraint="uq_trend_snapshot_skill_city_date", + set_={ + "demand_count": stmt.excluded.demand_count, + "avg_salary": stmt.excluded.avg_salary, + "growth_rate": stmt.excluded.growth_rate, + } + ) + + db.session.execute(stmt) + try: + db.session.commit() + return True + except Exception as e: + db.session.rollback() + safe_msg = str(e).encode("ascii", errors="replace").decode("ascii") + print(f"\n[ERROR DE PERSISTENCIA] Fallo al upsertar TrendSnapshot en BD: {safe_msg}\n") + return False + + @classmethod + def get_top_skills(cls, limit: int = 10) -> list: + # Traemos los snapshots mas recientes ordenados por demanda para construir el ranking del endpoint skills/top + return db.session.execute( + db.select(TrendSnapshot) + .order_by(desc(TrendSnapshot.demand_count)) + .limit(limit) + ).scalars().all() + + @classmethod + def get_by_skill_id(cls, skill_id: int) -> list: + return db.session.execute( + db.select(TrendSnapshot) + .filter_by(skill_id=skill_id) + .order_by(TrendSnapshot.date) + ).scalars().all() + + @classmethod + def get_by_skill_ids(cls, skill_ids: list[int]) -> list: + return db.session.execute( + db.select(TrendSnapshot) + .filter(TrendSnapshot.skill_id.in_(skill_ids)) + .order_by(TrendSnapshot.skill_id, TrendSnapshot.date) + ).scalars().all() + + @classmethod + def get_by_city_id(cls, city_id: int) -> list: + return db.session.execute( + db.select(TrendSnapshot) + .filter_by(city_id=city_id) + .order_by(desc(TrendSnapshot.demand_count)) + ).scalars().all() + + @classmethod + def get_all_latest(cls) -> list: + # Subconsulta para obtener la fecha mas reciente por skill+city. Usamos esto para que summary y catalogs trabajen sobre datos actuales y no sobre historico acumulado + from sqlalchemy import func + subq = db.session.execute( + db.select( + TrendSnapshot.skill_id, + TrendSnapshot.city_id, + func.max(TrendSnapshot.date).label("max_date") + ).group_by(TrendSnapshot.skill_id, TrendSnapshot.city_id) + ).all() + return subq + + @classmethod + def get_salary_by_skill(cls, skill_id: int): + from sqlalchemy import func + return db.session.execute( + db.select( + func.avg(TrendSnapshot.avg_salary).label("avg_salary") + ).filter_by(skill_id=skill_id) + ).scalar_one_or_none() + + @classmethod + def get_summary_data(cls): + from sqlalchemy import func + from app.models.job import Job + from app.models.skill import Skill + + total_jobs = db.session.execute( + db.select(func.count(Job.id)) + ).scalar_one() + + total_skills_tracked = db.session.execute( + db.select(func.count(func.distinct(TrendSnapshot.skill_id))) + ).scalar_one() + + total_companies = db.session.execute( + db.select(func.count(func.distinct(Job.company))) + ).scalar_one() + + latest_date = db.session.execute( + db.select(func.max(TrendSnapshot.date)) + ).scalar_one_or_none() + + # Traemos el snapshot mas reciente por skill para identificar cual tiene mayor y menor demanda actual + top_emerging = db.session.execute( + db.select(TrendSnapshot, Skill.name) + .join(Skill, Skill.id == TrendSnapshot.skill_id) + .order_by(db.desc(TrendSnapshot.demand_count)) + .limit(1) + ).first() + + top_declining = db.session.execute( + db.select(TrendSnapshot, Skill.name) + .join(Skill, Skill.id == TrendSnapshot.skill_id) + .order_by(TrendSnapshot.demand_count) + .limit(1) + ).first() + + return { + "total_jobs": total_jobs, + "total_skills_tracked": total_skills_tracked, + "total_companies": total_companies, + "latest_date": latest_date, + "top_emerging": top_emerging, + "top_declining": top_declining, + } + + @classmethod + def get_geo_distribution(cls, skill_id: int = None, group_by: str = "city") -> list: + from sqlalchemy import func + from app.models.city import City + + if group_by == "state": + # Sumamos demand_count solo por estado + query = ( + db.select( + City.state.label("state"), + func.sum(TrendSnapshot.demand_count).label("total_demand"), + func.bool_or(City.id == 1).label("is_fallback"), + ) + .join(City, City.id == TrendSnapshot.city_id) + .group_by(City.state) + .order_by(func.sum(TrendSnapshot.demand_count).desc()) + ) + else: + # Comportamiento original (city) + query = ( + db.select( + City.id.label("city_id"), + City.name.label("city_name"), + City.state.label("state"), + func.sum(TrendSnapshot.demand_count).label("total_demand"), + ) + .join(City, City.id == TrendSnapshot.city_id) + .group_by(City.id, City.name, City.state) + .order_by(func.sum(TrendSnapshot.demand_count).desc()) + ) + + if skill_id is not None: + query = query.filter(TrendSnapshot.skill_id == skill_id) + + return db.session.execute(query).all() diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py new file mode 100644 index 0000000..0cf874a --- /dev/null +++ b/backend/app/repositories/user_repository.py @@ -0,0 +1,75 @@ +import logging +from sqlalchemy.exc import IntegrityError +from app.models.user import User +from app.extensions import db +from app.utils.errors import AppError, ConflictError + +logger = logging.getLogger(__name__) + + +class UserRepository: + # Encapsula el acceso a datos para la entidad User. Aísla las consultas SQLAlchemy de la lógica de negocio. + + @classmethod + def _commit_or_rollback(cls, user: User, action: str) -> User: + try: + db.session.commit() + return user + except IntegrityError as e: + db.session.rollback() + logger.warning(f"Violacion de integridad al {action} User: {e}") + raise ConflictError(f"No se pudo {action} el usuario: conflicto de integridad de datos.") + except Exception as e: + db.session.rollback() + logger.error(f"Fallo inesperado al {action} User: {e}") + raise AppError(f"Error interno al {action} el usuario.", code="DATABASE_ERROR", status_code=500) + + @classmethod + def create(cls, user_data: dict) -> User: + user = User(**user_data) + db.session.add(user) + return cls._commit_or_rollback(user, "crear") + + @classmethod + def get_by_id(cls, user_id: int) -> User: + return db.session.get(User, user_id) + + @classmethod + def get_by_email(cls, email: str) -> User: + # Búsqueda especializada indispensable para el flujo de autenticación y prevención de duplicados + return db.session.execute(db.select(User).filter_by(email=email)).scalar_one_or_none() + + @classmethod + def get_all(cls) -> list[User]: + return db.session.execute(db.select(User)).scalars().all() + + @classmethod + def get_paginated(cls, page: int = 1, per_page: int = 20): + # Ordenamos descendente por fecha de creación para que los usuarios más recientes aparezcan primero + query = db.select(User).order_by(User.created_at.desc()) + total = db.session.execute( + db.select(db.func.count()).select_from(User) + ).scalar_one() + items = db.session.execute( + query.limit(per_page).offset((page - 1) * per_page) + ).scalars().all() + return items, total + + @classmethod + def save(cls, user: User) -> User: + # Persiste cambios en una entidad ya existente, como el reseteo de password_hash; no crea un nuevo registro, solo hace commit. + db.session.add(user) + return cls._commit_or_rollback(user, "guardar") + + @classmethod + def count_active_admins_for_update(cls) -> int: + rows = db.session.execute( + db.select(User.id).filter_by( + role="ADMIN", is_active=True + ).with_for_update() + ).all() + return len(rows) + + @classmethod + def is_last_active_admin(cls, user_id: int) -> bool: + return cls.count_active_admins_for_update() <= 1 diff --git a/backend/app/repositories/user_skill_repository.py b/backend/app/repositories/user_skill_repository.py new file mode 100644 index 0000000..091a102 --- /dev/null +++ b/backend/app/repositories/user_skill_repository.py @@ -0,0 +1,39 @@ +from app.extensions import db +from app.models.user_skill import UserSkill +from app.models.skill import Skill +from sqlalchemy.exc import IntegrityError + + +class UserSkillRepository: + # Encapsula el acceso a datos de la tabla de relacion usuario-habilidad. Usa metodos idem potentes para que el controlador no tenga que verificar existencia antes de operar. + + @classmethod + def get_skills_by_user(cls, user_id: int) -> list: + # Hacemos join con Skill para traer el nombre canonico en una sola consulta, evitando N+1 queries. + return db.session.execute( + db.select(UserSkill, Skill) + .join(Skill, Skill.id == UserSkill.skill_id) + .filter(UserSkill.user_id == user_id) + ).all() + + @classmethod + def add_skill(cls, user_id: int, skill_id: int) -> bool: + # Intentamos insertar; si ya existe la llave compuesta, atrapamos la excepcion de integridad y devolvemos False sin lanzar, para que el endpoint POST sea naturalmente idempotente. + entry = UserSkill(user_id=user_id, skill_id=skill_id) + db.session.add(entry) + try: + db.session.commit() + return True + except IntegrityError: + db.session.rollback() + return False + + @classmethod + def remove_skill(cls, user_id: int, skill_id: int) -> bool: + # Eliminamos si existe, sin error si no existe, para que DELETE sea idempotente. + entry = db.session.get(UserSkill, (user_id, skill_id)) + if entry: + db.session.delete(entry) + db.session.commit() + return True + return False diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..a6131c1 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +# init diff --git a/backend/app/schemas/admin_schema.py b/backend/app/schemas/admin_schema.py new file mode 100644 index 0000000..97d7125 --- /dev/null +++ b/backend/app/schemas/admin_schema.py @@ -0,0 +1,25 @@ +from marshmallow import Schema, fields, validate + + +class UserRoleSchema(Schema): + role = fields.String( + required=True, + validate=validate.OneOf( + ["REGISTERED", "ADMIN"], + error="El rol debe ser REGISTERED o ADMIN.", + ), + error_messages={"required": "El campo role es obligatorio."}, + ) + + +class UserStatusSchema(Schema): + is_active = fields.Boolean( + required=True, + error_messages={"required": "El campo is_active es obligatorio."}, + ) + +class BackupRestoreConfirmSchema(Schema): + confirm_filename = fields.String( + required=True, + error_messages={"required": "Debes confirmar el nombre del archivo a restaurar."} + ) diff --git a/backend/app/schemas/alert_schema.py b/backend/app/schemas/alert_schema.py new file mode 100644 index 0000000..586ec46 --- /dev/null +++ b/backend/app/schemas/alert_schema.py @@ -0,0 +1,55 @@ +from marshmallow import Schema, fields, validate, validates_schema, ValidationError + + +class AlertRequestSchema(Schema): + skill_id = fields.Integer( + required=True, strict=True, + error_messages={"required": "El ID de la habilidad es obligatorio.", "invalid": "El ID debe ser un número entero."} + ) + alert_type = fields.String( + required=True, + validate=validate.OneOf(["ABSOLUTE", "TREND"], error="alert_type debe ser ABSOLUTE o TREND."), + error_messages={"required": "El tipo de alerta es obligatorio."} + ) + threshold_value = fields.Integer( + required=False, strict=True, allow_none=True, + validate=validate.Range(min=1), + error_messages={"validator_failed": "El umbral debe ser mayor a 0.", "invalid": "El umbral debe ser un número entero."} + ) + threshold_percentage = fields.Decimal( + required=False, allow_none=True, as_string=False, + validate=validate.Range(min=0.01), + error_messages={"validator_failed": "El porcentaje debe ser mayor a 0."} + ) + + @validates_schema + def validate_threshold_matches_type(self, data, **kwargs): + alert_type = data.get("alert_type") + has_value = data.get("threshold_value") is not None + has_percentage = data.get("threshold_percentage") is not None + if alert_type == "ABSOLUTE": + if not has_value: + raise ValidationError("threshold_value es obligatorio para alertas de tipo ABSOLUTE.", field_name="threshold_value") + if has_percentage: + raise ValidationError("threshold_percentage no debe enviarse para alertas de tipo ABSOLUTE.", field_name="threshold_percentage") + elif alert_type == "TREND": + if not has_percentage: + raise ValidationError("threshold_percentage es obligatorio para alertas de tipo TREND.", field_name="threshold_percentage") + if has_value: + raise ValidationError("threshold_value no debe enviarse para alertas de tipo TREND.", field_name="threshold_value") + + +class AlertResponseSchema(Schema): + # Exponemos la estructura completa de la alerta al frontend, incluyendo tipo y umbrales por variante. + id = fields.Integer(dump_only=True) + skill_id = fields.Integer(dump_only=True) + alert_type = fields.String(dump_only=True) + threshold_value = fields.Integer(dump_only=True, allow_none=True) + threshold_percentage = fields.Decimal(dump_only=True, allow_none=True, as_string=True) + created_at = fields.DateTime(dump_only=True) + +class AlertStatusUpdateSchema(Schema): + active = fields.Boolean( + required=True, + error_messages={"required": "El campo active es obligatorio."} + ) diff --git a/backend/app/schemas/auth_schema.py b/backend/app/schemas/auth_schema.py new file mode 100644 index 0000000..4537acc --- /dev/null +++ b/backend/app/schemas/auth_schema.py @@ -0,0 +1,91 @@ +import re +from marshmallow import Schema, fields, validate, pre_load, ValidationError + + +def validate_password_strength(password): + # Aplicamos longitud minima ya la valida validate.Length por separado, aqui solo checamos composicion. + if not re.search(r"[A-Z]", password): + raise ValidationError("La contraseña debe incluir al menos una mayúscula.") + if not re.search(r"[a-z]", password): + raise ValidationError("La contraseña debe incluir al menos una minúscula.") + if not re.search(r"\d", password): + raise ValidationError("La contraseña debe incluir al menos un número.") + if not re.search(r"[^A-Za-z0-9]", password): + raise ValidationError("La contraseña debe incluir al menos un carácter especial.") + +class UserRegistrationSchema(Schema): + # Alineación estricta con el modelo SQLAlchemy (first_name, last_name) + email = fields.Email(required=True, error_messages={"required": "El correo es obligatorio.", "invalid": "Formato de correo inválido."}) + password = fields.String( + required=True, + validate=[validate.Length(min=8, max=128), validate_password_strength], + error_messages={"required": "La contraseña es obligatoria."}, + ) + first_name = fields.String(required=True, validate=validate.Length(min=2, max=50), error_messages={"required": "El nombre es obligatorio."}) + last_name = fields.String(required=True, validate=validate.Length(min=2, max=50), error_messages={"required": "El apellido es obligatorio."}) + + @pre_load + def format_input(self, data, **kwargs): + if "email" in data and isinstance(data["email"], str): + data["email"] = data["email"].lower().strip() + return data + +class UserLoginSchema(Schema): + email = fields.Email(required=True, error_messages={"required": "El correo es obligatorio.", "invalid": "Formato de correo inválido."}) + password = fields.String(required=True, error_messages={"required": "La contraseña es obligatoria."}) + +class UserResponseSchema(Schema): + id = fields.Integer(dump_only=True) + email = fields.Email(dump_only=True) + first_name = fields.String(dump_only=True) + last_name = fields.String(dump_only=True) + role = fields.String(dump_only=True) + created_at = fields.DateTime(dump_only=True) + + +class ForgotPasswordSchema(Schema): + email = fields.Email( + required=True, + error_messages={ + "required": "El correo es obligatorio.", + "invalid": "Formato de correo inválido.", + }, + ) + + @pre_load + def normalize_email(self, data, **kwargs): + if "email" in data and isinstance(data["email"], str): + data["email"] = data["email"].lower().strip() + return data + + +class EmailOnlySchema(Schema): + """Schema mínimo para endpoints que sólo necesitan un correo electrónico. + Independiente de ForgotPasswordSchema para evitar acoplamiento conceptual entre flujos distintos (resend-verification vs. forgot-password)""" + + email = fields.Email( + required=True, + error_messages={ + "required": "El correo es obligatorio.", + "invalid": "Formato de correo inválido.", + }, + ) + + @pre_load + def normalize_email(self, data, **kwargs): + if "email" in data and isinstance(data["email"], str): + data["email"] = data["email"].lower().strip() + return data + + +class ResetPasswordSchema(Schema): + token = fields.String( + required=True, + error_messages={"required": "El token es obligatorio."}, + ) + # Reutilizamos el mismo validador de complejidad definido arriba en este mismo archivo, la regla vive en un solo lugar, no copiada. + new_password = fields.String( + required=True, + validate=[validate.Length(min=8, max=128), validate_password_strength], + error_messages={"required": "La nueva contraseña es obligatoria."}, + ) diff --git a/backend/app/schemas/pagination_schema.py b/backend/app/schemas/pagination_schema.py new file mode 100644 index 0000000..c7179f1 --- /dev/null +++ b/backend/app/schemas/pagination_schema.py @@ -0,0 +1,9 @@ +from marshmallow import Schema, fields + + +class PaginationMetaSchema(Schema): + # Schema reutilizable para envolver cualquier respuesta paginada del API. 'total_pages' se calcula en el controller para evitar duplicar la lógica de división en cada serialización. + total = fields.Integer(dump_only=True) + page = fields.Integer(dump_only=True) + per_page = fields.Integer(dump_only=True) + total_pages = fields.Integer(dump_only=True) diff --git a/backend/app/schemas/panorama_schema.py b/backend/app/schemas/panorama_schema.py new file mode 100644 index 0000000..f9a475c --- /dev/null +++ b/backend/app/schemas/panorama_schema.py @@ -0,0 +1,87 @@ +from marshmallow import Schema, fields + + +class SkillTrendSchema(Schema): + # Representa una habilidad con su metrica de demanda actual. Usado en skills/top y como bloque base de otros endpoints + skill_id = fields.Integer(dump_only=True) + name = fields.String(dump_only=True) + demand_count = fields.Integer(dump_only=True) + growth_rate = fields.Decimal(dump_only=True, allow_none=True, as_string=True) + avg_salary = fields.Decimal(dump_only=True, allow_none=True, as_string=True) + category = fields.String(dump_only=True, allow_none=True) + + +class SummaryResponseSchema(Schema): + # KPIs globales del Panorama, totales y tendencias destacadas + total_jobs = fields.Integer(dump_only=True) + total_skills_tracked = fields.Integer(dump_only=True) + total_companies = fields.Integer(dump_only=True) + top_emerging_skill = fields.Nested(SkillTrendSchema, dump_only=True, allow_none=True) + top_declining_skill = fields.Nested(SkillTrendSchema, dump_only=True, allow_none=True) + last_updated = fields.Date(dump_only=True, allow_none=True) + + +class CityOptionSchema(Schema): + id = fields.Integer(dump_only=True) + name = fields.String(dump_only=True) + + +class SkillOptionSchema(Schema): + id = fields.Integer(dump_only=True) + name = fields.String(dump_only=True) + + +class CatalogsResponseSchema(Schema): + # Listas livianas para alimentar selectores del frontend + skills = fields.List(fields.Nested(SkillOptionSchema), dump_only=True) + cities = fields.List(fields.Nested(CityOptionSchema), dump_only=True) + + +class TrendPointSchema(Schema): + # Un punto en la serie temporal de una habilidad especifica + date = fields.Date(dump_only=True) + demand_count = fields.Integer(dump_only=True) + + +class TrendsResponseSchema(Schema): + skill_id = fields.Integer(dump_only=True) + skill_name = fields.String(dump_only=True) + series = fields.List(fields.Nested(TrendPointSchema), dump_only=True) + + +class GeoDistributionSchema(Schema): + # Demanda de una habilidad agrupada por ciudad y estado, o solo por estado para vistas regionales + city_id = fields.Integer(dump_only=True, required=False) + city_name = fields.String(dump_only=True, required=False) + state = fields.String(dump_only=True) + demand_count = fields.Integer(dump_only=True) + is_fallback = fields.Boolean(dump_only=True, required=False) + + +class GeoResponseSchema(Schema): + skill_id = fields.Integer(dump_only=True, allow_none=True) + skill_name = fields.String(dump_only=True, allow_none=True) + distribution = fields.List(fields.Nested(GeoDistributionSchema), dump_only=True) + + +class SalaryResponseSchema(Schema): + # Cruce de habilidad contra rango salarial promedio + skill_id = fields.Integer(dump_only=True) + skill_name = fields.String(dump_only=True) + avg_salary_min = fields.Decimal(dump_only=True, allow_none=True, as_string=True) + avg_salary_max = fields.Decimal(dump_only=True, allow_none=True, as_string=True) + sample_size = fields.Integer(dump_only=True) + + +class CompareSkillBlockSchema(Schema): + # Bloque de metricas para una sola habilidad dentro de la comparacion + skill_id = fields.Integer(dump_only=True) + skill_name = fields.String(dump_only=True) + demand_count = fields.Integer(dump_only=True) + growth_rate = fields.Decimal(dump_only=True, allow_none=True, as_string=True) + avg_salary = fields.Decimal(dump_only=True, allow_none=True, as_string=True) + series = fields.List(fields.Nested(TrendPointSchema), dump_only=True) + + +class CompareResponseSchema(Schema): + skills = fields.List(fields.Nested(CompareSkillBlockSchema), dump_only=True) diff --git a/backend/app/schemas/profile_schema.py b/backend/app/schemas/profile_schema.py new file mode 100644 index 0000000..c81d3ad --- /dev/null +++ b/backend/app/schemas/profile_schema.py @@ -0,0 +1,49 @@ +from marshmallow import Schema, fields, validate, validates_schema, ValidationError + +from app.schemas.auth_schema import validate_password_strength + + +class UpdateProfileSchema(Schema): + first_name = fields.String( + load_default=None, + validate=validate.Length(min=1, max=50), + ) + last_name = fields.String( + load_default=None, + validate=validate.Length(min=1, max=50), + ) + intent = fields.String( + load_default=None, + validate=validate.OneOf( + ["ESTUDIANTE", "RECLUTADOR"], + error="El intent debe ser ESTUDIANTE o RECLUTADOR.", + ), + allow_none=True, + ) + + @validates_schema + def require_at_least_one_field(self, data, **kwargs): + # Un PATCH sin ningun campo modificado no tiene sentido funcional, lo rechazamos con un mensaje claro. + if not any(v is not None for v in data.values()): + raise ValidationError("Se requiere al menos un campo para actualizar el perfil.") + + +class AddSkillSchema(Schema): + skill_id = fields.Integer( + required=True, + strict=True, + error_messages={"required": "El skill_id es obligatorio."}, + ) + + +class ChangePasswordSchema(Schema): + current_password = fields.String( + required=True, + error_messages={"required": "La contrasena actual es obligatoria."}, + ) + # Reutilizamos el validador de complejidad importandolo directamente desde auth_schema, no duplicando la logica, para que cualquier cambio futuro a las reglas aplique en ambos flujos. + new_password = fields.String( + required=True, + validate=[validate.Length(min=8, max=128), validate_password_strength], + error_messages={"required": "La nueva contrasena es obligatoria."}, + ) diff --git a/backend/app/schemas/skill_schema.py b/backend/app/schemas/skill_schema.py new file mode 100644 index 0000000..84a47d9 --- /dev/null +++ b/backend/app/schemas/skill_schema.py @@ -0,0 +1,8 @@ +from marshmallow import Schema, fields + +class SkillResponseSchema(Schema): + # El catálogo de habilidades es de solo lectura para el cliente. + # Este esquema asegura que el dropdown del frontend reciba exactamente los tipos de datos esperados y no exponga metadata interna. + id = fields.Integer(dump_only=True) + name = fields.String(dump_only=True) + canonical_name = fields.String(dump_only=True) diff --git a/backend/app/services/README.md b/backend/app/services/README.md new file mode 100644 index 0000000..9de1d64 --- /dev/null +++ b/backend/app/services/README.md @@ -0,0 +1,28 @@ +# services/ + +Aqui vive la logica de negocio del sistema. Los servicios son el +corazon de SkillStat: procesan datos, toman decisiones y coordinan +el trabajo entre los distintos componentes. + +## Lo que va aqui + +- La logica que extrae habilidades de descripciones de vacantes +- La logica que calcula tendencias y metricas del mercado +- La logica que evalua alertas y decide cuando notificar +- La coordinacion entre repositorios y clientes externos + +## Lo que no va aqui + +Los servicios no conocen Flask. No manejan peticiones HTTP ni +construyen respuestas JSON. Tampoco acceden directamente a la base +de datos: para eso usan los repositorios. + +## Los servicios del proyecto + +| Archivo | Que hace | +|---------|----------| +| `ingestion_service.py` | Recopila vacantes desde la API de Adzuna | +| `skills_extraction_service.py` | Extrae habilidades tecnicas de descripciones | +| `market_trends_service.py` | Calcula metricas de demanda y tendencias | +| `alerts_service.py` | Evalua alertas y coordina las notificaciones | +| `backup_service.py` | Genera y restaura respaldos de la base de datos | diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/alerts_service.py b/backend/app/services/alerts_service.py new file mode 100644 index 0000000..1f2f3f7 --- /dev/null +++ b/backend/app/services/alerts_service.py @@ -0,0 +1,68 @@ +import logging +from app.repositories.alert_repository import AlertRepository +from app.repositories.trend_snapshot_repository import TrendSnapshotRepository +from app.repositories.user_repository import UserRepository +from app.repositories.skill_repository import SkillRepository +from app.services.email_service import send_alert_email +from app.utils.errors import AppError + +logger = logging.getLogger(__name__) + +class AlertsService: + @classmethod + def _build_notification_content(cls, alert, skill, latest_trend): + if alert.alert_type == "ABSOLUTE": + subject = f"Alerta SkillStat: {skill.name} ha superado tu umbral" + html_content = f""" +

Alerta de Mercado Laboral

+

Hola, tu alerta configurada para {skill.name} ha sido activada.

+

El mercado actual registra {latest_trend.demand_count} vacantes activas, superando tu umbral de {alert.threshold_value}.

+

Ver en el Panorama

+ """ + else: + subject = f"Alerta SkillStat: {skill.name} está en tendencia de crecimiento" + html_content = f""" +

Alerta de Mercado Laboral

+

Hola, tu alerta de tendencia configurada para {skill.name} ha sido activada.

+

Esta habilidad ha crecido {latest_trend.growth_rate}% en la última semana, superando tu umbral de {alert.threshold_percentage}%.

+

Ver en el Panorama

+ """ + return subject, html_content + + @classmethod + def evaluate_and_notify(cls) -> int: + active_alerts = AlertRepository.get_active() + if not active_alerts: + return 0 + notifications_sent = 0 + for alert in active_alerts: + latest_trend = TrendSnapshotRepository.get_latest_by_skill(alert.skill_id) + if not latest_trend: + continue + triggered = False + if alert.alert_type == "ABSOLUTE": + if latest_trend.demand_count is not None and latest_trend.demand_count >= alert.threshold_value: + triggered = True + elif alert.alert_type == "TREND": + # Sin historial de 7 dias, growth_rate es None, no evaluamos, no notificamos. Ausencia de dato no es lo mismo que "no se cumplio". + if latest_trend.growth_rate is not None and latest_trend.growth_rate >= alert.threshold_percentage: + triggered = True + if not triggered: + continue + user = UserRepository.get_by_id(alert.user_id) + skill = SkillRepository.get_by_id(alert.skill_id) + if not user or not skill: + continue + + subject, html_content = cls._build_notification_content(alert, skill, latest_trend) + + try: + send_alert_email(user.email, subject, html_content) + notifications_sent += 1 + except AppError as e: + logger.warning( + "Fallo al enviar correo de alerta: alert_id=%s user_id=%s. Error: %s", + alert.id, alert.user_id, str(e) + ) + continue + return notifications_sent diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..a05cdbe --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,382 @@ +import secrets +import hashlib +import logging +from datetime import datetime, timezone, timedelta + +from app.repositories.user_repository import UserRepository +from app.repositories.email_verification_token_repository import EmailVerificationTokenRepository +from app.repositories.password_reset_token_repository import PasswordResetTokenRepository +from app.repositories.google_link_token_repository import GoogleLinkTokenRepository +from app.repositories.oauth_account_repository import OAuthAccountRepository +from app.utils.hash import hash_password, verify_password +from app.utils.security import generate_tokens +from app.utils.errors import AppError, ConflictError +import google.oauth2.id_token as google_id_token +import google.auth.transport.requests as google_requests + +logger = logging.getLogger(__name__) + +class AuthService: + + @classmethod + def register(cls, data: dict) -> object: + """ Crea una cuenta nueva y emite el token de verificación de correo. + - Parámetros: data ya validado por UserRegistrationSchema (email, password, first_name, last_name). + + - Devuelve: (user, plain_token) donde plain_token es el token en texto plano que debe enviarse por correo; el hash nunca sale de esta capa. + + - Lanza: ConflictError si el correo ya está registrado. """ + if UserRepository.get_by_email(data["email"]): + raise ConflictError("El correo ya está registrado.") + + user_data = dict(data) + user_data["password_hash"] = hash_password(user_data.pop("password")) + user_data["role"] = "REGISTERED" + # Marcamos el instante de creación de contraseña para que el blocklist callback pueda invalidar sesiones anteriores si la contraseña cambia. + user_data["password_changed_at"] = datetime.now(timezone.utc) + + user = UserRepository.create(user_data) + + plain_token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(plain_token.encode()).hexdigest() + expires_at = datetime.now(timezone.utc) + timedelta(hours=24) + + EmailVerificationTokenRepository.create({ + "user_id": user.id, + "token_hash": token_hash, + "expires_at": expires_at, + }) + + return user, plain_token + + @classmethod + def login(cls, data: dict) -> tuple: + """ Valida credenciales y genera tokens de acceso. + - Parámetros: data ya validado por UserLoginSchema (email, password). + + - Devuelve: (user, tokens) donde tokens es el dict devuelto por generate_tokens, listo para que el controlador lo coloque en cookie. + + - Lanza: AppError con code UNAUTHORIZED si las credenciales son incorrectas o el correo no ha sido verificado. """ + user = UserRepository.get_by_email(data["email"]) + + if not user or user.password_hash is None or not verify_password(data["password"], user.password_hash): + raise AppError("Credenciales incorrectas.", code="UNAUTHORIZED", status_code=401) + + if user.email_verified_at is None: + raise AppError( + "Verifica tu correo antes de iniciar sesión.", + code="EMAIL_NOT_VERIFIED", + status_code=403, + ) + + tokens = generate_tokens(user_id=user.id, role=user.role) + return user, tokens + + @classmethod + def _validate_verification_token(cls, token: str) -> tuple: + """ Valida un token de verificación sin mutar ningún estado. + + - Devuelve: (token_row, user) si el token es válido. + + - Lanza: AppError con el code y status_code correspondiente si el token no existe, ya fue usado, o expiró. Seguro para llamarse desde un GET ya que no escribe en base de datos. """ + token_hash = hashlib.sha256(token.encode()).hexdigest() + token_row = EmailVerificationTokenRepository.get_by_token_hash(token_hash) + + if not token_row: + raise AppError( + "El enlace de verificación no es válido.", + code="TOKEN_INVALID", + status_code=404, + ) + + if token_row.used_at is not None: + raise AppError( + "Este correo ya fue verificado anteriormente.", + code="TOKEN_ALREADY_USED", + status_code=409, + ) + + now = datetime.now(timezone.utc) + expires_aware = ( + token_row.expires_at.replace(tzinfo=timezone.utc) + if token_row.expires_at.tzinfo is None + else token_row.expires_at + ) + if expires_aware < now: + raise AppError( + "El enlace expiró. Solicita uno nuevo.", + code="TOKEN_EXPIRED", + status_code=410, + ) + + user = UserRepository.get_by_id(token_row.user_id) + return token_row, user + + @classmethod + def verify_email_check(cls, token: str) -> dict: + """Valida el token sin marcarlo como usado ni verificar la cuenta. Seguro para prefetch de escáneres de correo — no tiene efectos + secundarios. + + - Devuelve: dict con {valid: True, email} si el token es válido. + + - Lanza: AppError si el token no es válido, ya fue usado, o expiró. """ + token_row, user = cls._validate_verification_token(token) + return {"valid": True, "email": user.email if user else None} + + @classmethod + def verify_email_confirm(cls, token: str) -> None: + """Ejecuta la verificación real tras la confirmación explícita del usuario. Vuelve a validar el token para cubrir la ventana entre el GET y el clic (race condition o token consumido en paralelo). Si sigue siendo válido, muta el estado: marca email_verified_at en el usuario y used_at en el token. + + - Lanza: AppError si el token ya no es válido en el momento del POST. """ + token_row, user = cls._validate_verification_token(token) + + now = datetime.now(timezone.utc) + if user: + user.email_verified_at = now + UserRepository.save(user) + + EmailVerificationTokenRepository.mark_as_used(token_row) + + @classmethod + def resend_verification(cls, email: str) -> None: + """ Invalida tokens anteriores y emite uno nuevo si el correo existe y no ha sido verificado. Diseñado para respuesta genérica (no revela si el correo existe): el controlador siempre devuelve 200 independientemente del resultado. + + - Devuelve: (plain_token, user_email) si se emitió un token nuevo, o (None, None) si el correo no existe o ya está verificado, en ambos casos el controlador responde igual. + + - Lanza: AppError si falla la creación del token en base de datos (propagado al logger del controlador, no al usuario final). """ + user = UserRepository.get_by_email(email) + if not user or user.email_verified_at is not None: + return None, None + + unused_tokens = EmailVerificationTokenRepository.get_unused_by_user_id(user.id) + for t in unused_tokens: + EmailVerificationTokenRepository.mark_as_used(t) + + plain_token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(plain_token.encode()).hexdigest() + expires_at = datetime.now(timezone.utc) + timedelta(hours=24) + + EmailVerificationTokenRepository.create({ + "user_id": user.id, + "token_hash": token_hash, + "expires_at": expires_at, + }) + + return plain_token, user.email + + @classmethod + def forgot_password(cls, email: str) -> tuple: + """Crea un token de recuperación de contraseña si el correo existe. Diseñado para respuesta genérica (no revela si el correo existe): el controlador siempre devuelve 200 independientemente del resultado. + + - Devuelve: (plain_token, user_email) si el usuario existe, o (None, None) si no existe, en ambos casos el controlador responde igual. """ + user = UserRepository.get_by_email(email) + if not user: + return None, None + + # El token en texto plano solo vive en memoria durante esta request; guardamos su hash SHA-256 en la base de datos, nunca el valor original. + plain_token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(plain_token.encode()).hexdigest() + expires_at = datetime.now(timezone.utc) + timedelta(minutes=30) + + PasswordResetTokenRepository.create({ + "user_id": user.id, + "token_hash": token_hash, + "expires_at": expires_at, + }) + + return plain_token, user.email + + @classmethod + def reset_password(cls, token: str, new_password: str) -> None: + """ Valida el token de recuperación y actualiza la contraseña del usuario. password_changed_at se actualiza en el mismo save() para invalidar todos los JWT emitidos antes de este momento; un atacante que hubiera robado un token activo queda bloqueado inmediatamente sin acción adicional. + + - Lanza: AppError si el token no es válido, ya fue usado, o expiró. AppError si el usuario asociado al token ya no existe. """ + token_hash = hashlib.sha256(token.encode()).hexdigest() + token_row = PasswordResetTokenRepository.get_by_token_hash(token_hash) + + now = datetime.now(timezone.utc) + expires_aware = ( + token_row.expires_at.replace(tzinfo=timezone.utc) + if token_row and token_row.expires_at.tzinfo is None + else (token_row.expires_at if token_row else None) + ) + + if ( + not token_row + or token_row.used_at is not None + or (expires_aware and expires_aware < now) + ): + raise AppError( + "El enlace de recuperación no es válido o ya expiró.", + code="INVALID_TOKEN", + status_code=400, + ) + + user = UserRepository.get_by_id(token_row.user_id) + if not user: + raise AppError( + "El usuario asociado a este token ya no existe.", + code="NOT_FOUND", + status_code=404, + ) + + # Actualizamos el hash de la contraseña con el mismo mecanismo bcrypt que usa el registro normal, no se reinventa ningún mecanismo de cifrado. + user.password_hash = hash_password(new_password) + user.password_changed_at = datetime.now(timezone.utc) + UserRepository.save(user) + + # Invalidamos el token de inmediato para que no pueda reutilizarse. + PasswordResetTokenRepository.mark_as_used(token_row) + + @classmethod + def authenticate_with_google(cls, credential: str, google_client_id: str) -> tuple: + """Verifica el token de Google y resuelve la cuenta del usuario. Tres casos posibles: + 1. OAuthAccount ya vinculado => autentica de inmediato, devuelve (user, tokens). + 2. User con el mismo email existe pero sin OAuthAccount => genera un + GoogleLinkToken pendiente y lanza AppError(code='ACCOUNT_LINK_PENDING', + status_code=200) con el link_token en el campo `detail` para que el + controlador lo devuelva al frontend sin emitir sesión. + 3. Ni OAuthAccount ni User existen => crea cuenta nueva y autentica de + inmediato, devuelve (user, tokens). + + Lanza: + - AppError(TOKEN_INVALID, 401) si el token de Google no es válido. + - AppError(EMAIL_NOT_VERIFIED, 401) si Google no marcó el email como verificado. + - AppError(NOT_FOUND, 404) si el OAuthAccount existe pero el User fue eliminado. + - AppError(ACCOUNT_LINK_PENDING, 200) si se requiere confirmación explícita. """ + try: + idinfo = google_id_token.verify_oauth2_token( + credential, + google_requests.Request(), + google_client_id, + ) + except ValueError: + raise AppError( + "El token de Google no es válido.", + code="TOKEN_INVALID", + status_code=401, + ) + + # Solo vinculamos automaticamente con una cuenta existente si Google ya verifico que el usuario controla ese correo; sin esto, alguien podria reclamar la cuenta de otra persona con solo conocer su email. + email_verified = str(idinfo.get("email_verified", "")).lower() == "true" + if not email_verified: + raise AppError( + "El correo de Google no está verificado.", + code="EMAIL_NOT_VERIFIED", + status_code=401, + ) + + google_user_id = idinfo["sub"] + email = idinfo["email"].lower().strip() + first_name = idinfo.get("given_name", "Usuario") + last_name = idinfo.get("family_name", "Google") + + # Caso 1: ya existe un OAuthAccount para este google_user_id. + oauth_account = OAuthAccountRepository.get_by_provider_identity("google", google_user_id) + if oauth_account: + user = UserRepository.get_by_id(oauth_account.user_id) + if not user: + raise AppError( + "La cuenta vinculada ya no existe.", + code="NOT_FOUND", + status_code=404, + ) + tokens = generate_tokens(user_id=user.id, role=user.role) + return user, tokens + + # Caso 2: existe User con ese email, pero sin OAuthAccount de Google. En lugar de vincular de inmediato, emitimos un token de confirmación para que el usuario apruebe explicitamente la vinculacion. + existing_user = UserRepository.get_by_email(email) + if existing_user: + plain_token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(plain_token.encode()).hexdigest() + expires_at = datetime.now(timezone.utc) + timedelta(minutes=15) + + GoogleLinkTokenRepository.create({ + "user_id": existing_user.id, + "token_hash": token_hash, + "google_user_id": google_user_id, + "pending_email": email, + "pending_first_name": first_name, + "pending_last_name": last_name, + "expires_at": expires_at, + }) + + raise AppError( + "Ya existe una cuenta con ese correo. Confirma la vinculación.", + code="ACCOUNT_LINK_PENDING", + status_code=200, + detail={"link_token": plain_token, "email": email}, + ) + + # Caso 3: cuenta completamente nueva, sin User ni OAuthAccount. Sin cambio de comportamiento respecto al flujo previo. + user = UserRepository.create({ + "email": email, + "first_name": first_name, + "last_name": last_name, + "role": "REGISTERED", + "email_verified_at": datetime.now(timezone.utc), + }) + OAuthAccountRepository.create({ + "user_id": user.id, + "provider": "google", + "provider_user_id": google_user_id, + }) + tokens = generate_tokens(user_id=user.id, role=user.role) + return user, tokens + + @classmethod + def confirm_google_link(cls, link_token: str) -> tuple: + """ Valida el token de vinculación pendiente y completa la vinculación. Mismo patrón de validación que _validate_verification_token: verifica existencia, no usado y no expirado. Si pasa, crea el OAuthAccount, marca el token como usado y devuelve (user, tokens) listos para que el controlador emita la sesión. + + Lanza: + - AppError(TOKEN_INVALID, 404) si el token no existe. + - AppError(TOKEN_ALREADY_USED, 409) si ya fue consumido. + - AppError(TOKEN_EXPIRED, 410) si venció la ventana de 15 minutos. + - AppError(NOT_FOUND, 404) si el User asociado fue eliminado. """ + token_hash = hashlib.sha256(link_token.encode()).hexdigest() + token_row = GoogleLinkTokenRepository.get_by_token_hash(token_hash) + + if not token_row: + raise AppError( + "El enlace de vinculación no es válido.", + code="TOKEN_INVALID", + status_code=404, + ) + + if token_row.used_at is not None: + raise AppError( + "Este enlace de vinculación ya fue utilizado.", + code="TOKEN_ALREADY_USED", + status_code=409, + ) + + now = datetime.now(timezone.utc) + expires_aware = ( + token_row.expires_at.replace(tzinfo=timezone.utc) + if token_row.expires_at.tzinfo is None + else token_row.expires_at + ) + if expires_aware < now: + raise AppError( + "El enlace de vinculación expiró. Intenta iniciar sesión con Google de nuevo.", + code="TOKEN_EXPIRED", + status_code=410, + ) + + user = UserRepository.get_by_id(token_row.user_id) + if not user: + raise AppError( + "El usuario asociado a este token ya no existe.", + code="NOT_FOUND", + status_code=404, + ) + + OAuthAccountRepository.create({ + "user_id": user.id, + "provider": "google", + "provider_user_id": token_row.google_user_id, + }) + GoogleLinkTokenRepository.mark_as_used(token_row) + + tokens = generate_tokens(user_id=user.id, role=user.role) + return user, tokens diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py new file mode 100644 index 0000000..bc39bf9 --- /dev/null +++ b/backend/app/services/backup_service.py @@ -0,0 +1,213 @@ +import os +from urllib.parse import urlparse +from sqlalchemy import text +from flask_migrate import upgrade +import subprocess +from datetime import datetime +from flask import current_app +from app.extensions import db +from app.repositories.backup_repository import BackupRepository +from app.utils.errors import AppError +from app.services.storage_service import RemoteStorageService +import logging + +logger = logging.getLogger(__name__) + +class BackupService: + # Encapsula la ejecución de comandos del sistema operativo (pg_dump). Requisito obligatorio de infraestructura y recuperación. + + @classmethod + def _get_connection_params(cls, db_url: str) -> dict: + # Usamos urlparse para manejar correctamente passwords con caracteres especiales que el split manual no puede resolver. + parsed = urlparse(db_url) + password = parsed.password or "" + env = os.environ.copy() + env["PGPASSWORD"] = password + return { + "host": parsed.hostname, + "port": str(parsed.port or 5432), + "user": parsed.username, + "db_name": parsed.path.lstrip("/"), + "env": env, + } + + @classmethod + def execute_database_backup(cls, requested_by: int = None) -> dict: + + db_url = current_app.config.get("SQLALCHEMY_DATABASE_URI", "") + + if not db_url or "postgresql" not in db_url: + raise AppError( + "El servicio de respaldo solo soporta motores PostgreSQL nativos.", + status_code=500, + ) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + filename = f"skillstat_backup_{timestamp}.sql" + + base_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + backup_dir = os.path.join(base_dir, "data", "backups") + os.makedirs(backup_dir, exist_ok=True) + + filepath = os.path.join(backup_dir, filename) + + backup_record = BackupRepository.create({ + "filename": filename, + "storage_url": filepath, + "status": "PENDING", + "user_id": requested_by, + }) + + try: + conn = cls._get_connection_params(db_url) + + command = [ + "pg_dump", + "-h", conn["host"], + "-p", conn["port"], + "-U", conn["user"], + "-F", "c", + "-f", filepath, + conn["db_name"], + ] + + subprocess.run(command, env=conn["env"], capture_output=True, text=True, check=True) + + file_size = os.path.getsize(filepath) + RemoteStorageService.upload_backup(filepath, filename) + BackupRepository.update( + backup_record.id, + {"status": "COMPLETED", "file_size_bytes": file_size}, + ) + + return {"status": "success", "file": filename, "size": file_size} + + except subprocess.CalledProcessError as e: + try: + BackupRepository.update(backup_record.id, {"status": "FAILED"}) + except AppError as update_err: + logger.error( + "No se pudo marcar el backup %s como FAILED tras error de pg_dump: %s", + backup_record.id, update_err.message + ) + raise AppError( + f"Fallo en ejecucion de pg_dump: {e.stderr}", + code="BACKUP_ERROR", + ) + except Exception as e: + try: + BackupRepository.update(backup_record.id, {"status": "FAILED"}) + except AppError as update_err: + logger.error( + "No se pudo marcar el backup %s como FAILED tras error interno: %s", + backup_record.id, update_err.message + ) + raise AppError( + f"Error interno durante respaldo: {str(e)}", + code="BACKUP_ERROR", + ) + + @classmethod + def restore_database_backup(cls, backup_id: int, requested_by: int) -> dict: + backup = BackupRepository.get_by_id(backup_id) + if not backup: + raise AppError("El respaldo solicitado no existe.", code="NOT_FOUND", status_code=404) + if backup.status != "COMPLETED": + raise AppError( + "Solo se pueden restaurar respaldos con estado COMPLETED.", + code="INVALID_BACKUP_STATE", + status_code=422, + ) + if not os.path.exists(backup.storage_url): + logger.warning( + "Archivo de respaldo %s no encontrado localmente, intentando descargar desde R2.", + backup.filename, + ) + # Aseguramos que el directorio destino exista antes de escribir el archivo descargado (en un entorno recien desplegado podria no existir todavia). + os.makedirs(os.path.dirname(backup.storage_url), exist_ok=True) + downloaded = RemoteStorageService.download_backup( + backup.filename, backup.storage_url + ) + if not downloaded: + raise AppError( + "El archivo de respaldo no existe localmente y no se pudo " + "descargar desde el almacenamiento remoto.", + code="BACKUP_FILE_MISSING", + status_code=404, + ) + + # Extraemos TODOS los valores primitivos que necesitamos del objeto backup ANTES de generar el backup de seguridad. Esto es critico porque cualquier acceso a un atributo del ORM despues del commit de execute_database_backup() dispara un lazy-load que abre una transaccion implicita nueva, la cual retiene un lock compartido sobre la tabla backups y causa un deadlock real con pg_restore, que necesita un lock exclusivo sobre esa misma tabla para hacer DROP CONSTRAINT/DROP TABLE. Confirmado con evidencia de pg_stat_activity durante el diagnostico de esta rama. + backup_filepath = backup.storage_url + backup_filename = backup.filename + + # Generamos el respaldo de seguridad ANTES de tocar la base de datos. Si esto falla, abortamos toda la operacion. + safety_backup = cls.execute_database_backup(requested_by=requested_by) + + # Verificacion de defensa en profundidad, confirmamos que el archivo a restaurar sigue siendo distinto al respaldo de seguridad recien generado. + if safety_backup["file"] == backup_filename: + raise AppError( + "Colision de nombre de archivo detectada entre el respaldo " + "a restaurar y el respaldo de seguridad. Restauracion abortada " + "por seguridad.", + code="FILENAME_COLLISION", + status_code=500, + ) + + db_url = current_app.config.get("SQLALCHEMY_DATABASE_URI", "") + conn = cls._get_connection_params(db_url) + + # Cerramos explicitamente la sesion de SQLAlchemy ANTES del reset DDL y de pg_restore. Esto libera cualquier lock que la sesion actual pudiera estar reteniendo sobre las tablas (ej: locks por lazy-loading o transacciones de prueba abiertas) previniendo deadlocks durante el DROP SCHEMA y la recreación. + db.session.remove() + + # Reseteamos el schema public completo antes de restaurar. El enfoque anterior solo emite DROPs para los objetos que están presentes en el dump, lo que causa un error cuando la BD actual tiene tablas con FK hacia objetos del dump que pg_restore intenta recrear (ej: google_link_tokens -> users). El DROP SCHEMA CASCADE elimina todo el grafo de objetos del schema, incluidas las FKs de tablas que el dump no conoce, dejando la BD estéril antes de la restauración. Las extensiones como unaccent se recuperan automáticamente: si el dump las incluye, pg_restore las recrea; si no, la llamada a upgrade() posterior vuelve a aplicar la migración correspondiente que las instala. + with db.engine.execution_options(isolation_level="AUTOCOMMIT").connect() as raw_conn: + raw_conn.execute(text("DROP SCHEMA public CASCADE")) + raw_conn.execute(text("CREATE SCHEMA public")) + raw_conn.execute(text("GRANT ALL ON SCHEMA public TO CURRENT_USER")) + + command = [ + "pg_restore", + "-h", conn["host"], + "-p", conn["port"], + "-U", conn["user"], + "-d", conn["db_name"], + backup_filepath, + ] + + try: + subprocess.run(command, env=conn["env"], capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as e: + raise AppError( + f"Fallo en ejecucion de pg_restore: {e.stderr}", + code="RESTORE_ERROR", + status_code=500, + ) + + # upgrade() usa la configuracion de directorio de migraciones registrada via migrate.init_app(app, db) en _init_extensions(), que apunta a backend/migrations/ (ubicacion default de flask_migrate, sin parametro directory adicional). Se invoca en un bloque separado para distinguir claramente un fallo de esquema de un fallo del propio pg_restore. + try: + upgrade() + except Exception as e: + logger.error( + "Los datos se restauraron correctamente desde %s, pero la " + "sincronizacion automatica del esquema (flask db upgrade) " + "fallo: %s. Se requiere intervencion manual inmediata " + "ejecutando 'flask db upgrade' desde la terminal.", + backup_filename, str(e), + ) + raise AppError( + "El respaldo se restauro correctamente, pero la sincronizacion " + "automatica del esquema de base de datos fallo. Los datos son " + "validos pero el esquema puede estar desincronizado respecto " + "al codigo actual. Ejecute 'flask db upgrade' manualmente de " + "inmediato.", + code="RESTORE_SCHEMA_SYNC_FAILED", + status_code=500, + ) + + return { + "status": "success", + "restored_from": backup_filename, + "safety_backup": safety_backup["file"], + } diff --git a/backend/app/services/city_service.py b/backend/app/services/city_service.py new file mode 100644 index 0000000..1ad9e4e --- /dev/null +++ b/backend/app/services/city_service.py @@ -0,0 +1,37 @@ +from app.repositories.city_repository import CityRepository +from app.clients.nominatim_client import NominatimClient +from app.utils.errors import ConflictError + +class CityService: + # Orquesta la resolucion de ciudades, va desde la busqueda local indexada, geocodificacion externa via Nominatim, y persistencia. Antes esta orquestacion vivia dentro de CityRepository, violando la separacion de capas de nuestra arquitectura de trabajo (Architecture Hexagonal). + + @classmethod + def get_or_create_city(cls, raw_location: str) -> tuple: + if not raw_location: + return None, False + + existing = CityRepository.find_by_normalized_name(raw_location) + if existing: + return existing, False + + geo_data = NominatimClient.geocode_city(raw_location) + if not geo_data: + return None, False + + # Nominatim puede resolver un alias (ej. "Distrito Federal") a un nombre real (ej. "Ciudad de Mexico") que ya exista en BD. + resolved_name = geo_data["name"] + existing_resolved = CityRepository.find_by_normalized_name(resolved_name) + if existing_resolved: + return existing_resolved, False + + try: + new_city = CityRepository.create({ + "name": geo_data["name"], + "state": geo_data["state"], + "lat": geo_data["lat"], + "lon": geo_data["lon"], + }) + return new_city, True + except ConflictError: + # Condicion de carrera: otro proceso concurrente ya inserto esta misma ciudad entre nuestra verificacion y nuestro intento de creacion. La constraint unique=True de City.name disparo el conflicto; recuperamos la fila que el otro proceso ya persistio, en vez de fallar la ingesta. + return CityRepository.find_by_normalized_name(resolved_name), False diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..cf7a715 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,90 @@ +import resend +from flask import current_app +import logging +from app.utils.errors import AppError + +logger = logging.getLogger(__name__) + +class EmailDeliveryError(AppError): + def __init__(self, message: str, status_code: int = 500): + super().__init__(message, code="EMAIL_DELIVERY_ERROR", status_code=status_code) + + +def build_verification_link(token: str) -> str: + """Construye el enlace de verificación de correo a partir del token en texto plano. Punto único de verdad para la URL; cualquier cambio de ruta o dominio se hace aquí""" + return f"{current_app.config['FRONTEND_BASE_URL']}/views/verificar-correo.html?token={token}" + + +def send_verification_email(to_email: str, token: str) -> None: + resend.api_key = current_app.config["RESEND_API_KEY"] + from_email = current_app.config.get("RESEND_FROM_EMAIL", "onboarding@resend.dev") + verification_link = build_verification_link(token) + + html_content = f""" +

Hola,

+

Por favor verifica tu correo electrónico haciendo clic en el siguiente enlace:

+

{verification_link}

+ """ + + try: + response = resend.Emails.send({ + "from": from_email, + "to": to_email, + "subject": "Verifica tu correo en SkillStat", + "html": html_content + }) + logger.info(f"Correo de verificación enviado a {to_email}. ID: {response.get('id')}") + return response + except Exception as e: + logger.error(f"Error al enviar correo de verificación a {to_email}: {str(e)}") + raise EmailDeliveryError(f"No se pudo enviar el correo de verificación: {str(e)}") + + +def build_password_reset_link(token: str) -> str: + """Construye el enlace de restablecimiento de contraseña a partir del token en texto plano. Punto único de verdad para la URL; cualquier cambio de ruta o dominio se hace aquí""" + return f"{current_app.config['FRONTEND_BASE_URL']}/views/restablecer-contrasena.html?token={token}" + + +def send_password_reset_email(to_email: str, token: str) -> None: + resend.api_key = current_app.config["RESEND_API_KEY"] + from_email = current_app.config.get("RESEND_FROM_EMAIL", "onboarding@resend.dev") + reset_link = build_password_reset_link(token) + + html_content = f""" +

Hola,

+

Has solicitado restablecer tu contraseña. Por favor, haz clic en el siguiente enlace para continuar:

+

{reset_link}

+

Si no solicitaste este cambio, puedes ignorar este correo.

+ """ + + try: + response = resend.Emails.send({ + "from": from_email, + "to": to_email, + "subject": "Restablece tu contraseña en SkillStat", + "html": html_content + }) + logger.info(f"Correo de recuperación de contraseña enviado a {to_email}. ID: {response.get('id')}") + return response + except Exception as e: + logger.error(f"Error al enviar correo de recuperación a {to_email}: {str(e)}") + raise EmailDeliveryError(f"No se pudo enviar el correo de recuperación: {str(e)}") + + +def send_alert_email(to_email: str, subject: str, html_content: str) -> None: + # Centralizamos el envío de alertas en Resend para no mantener dos clientes de correo paralelos. + resend.api_key = current_app.config["RESEND_API_KEY"] + from_email = current_app.config.get("RESEND_FROM_EMAIL", "onboarding@resend.dev") + + try: + response = resend.Emails.send({ + "from": from_email, + "to": to_email, + "subject": subject, + "html": html_content + }) + logger.info(f"Correo de alerta enviado a {to_email}. ID: {response.get('id')}") + return response + except Exception as e: + logger.error(f"Error al enviar correo de alerta a {to_email}: {str(e)}") + raise EmailDeliveryError(f"No se pudo enviar el correo de alerta: {str(e)}") diff --git a/backend/app/services/ingestion_service.py b/backend/app/services/ingestion_service.py new file mode 100644 index 0000000..f5a7aa9 --- /dev/null +++ b/backend/app/services/ingestion_service.py @@ -0,0 +1,158 @@ +import logging +import re +import hashlib +import click +from flask import current_app +from app.clients.adzuna_client import AdzunaClient +from app.services.skills_extraction_service import SkillsExtractionService +from app.repositories.job_repository import JobRepository +from app.repositories.skill_repository import SkillRepository +from app.repositories.job_skill_repository import JobSkillRepository +from app.services.city_service import CityService +from app.utils.errors import AppError + +logger = logging.getLogger(__name__) + +# ID de "México Nacional", fila de fallback cuando la ubicación cruda no puede geocodificarse. Usamos una constante en lugar de una query extra por ejecución porque la fila es de solo lectura y su ID es estable +MEXICO_NACIONAL_CITY_ID = 1 + +class IngestionService: + # Orquestador central del flujo de datos. Conecta el proveedor externo (Adzuna), el motor analítico (NLP) y la capa de persistencia (Repositorios). + + @classmethod + def run_ingestion(cls, country: str = "mx", what: str = "software developer", pages: int = 1, verbose: bool = False) -> dict: + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + # Pre-cargamos las habilidades existentes en memoria para evitar consultas SQL (N+1) por cada habilidad encontrada en cada vacante, minimizando latencia de red. + known_skills = {skill.name.lower(): skill.id for skill in SkillRepository.get_all()} + + for page in range(1, pages + 1): + try: + data = AdzunaClient.get_jobs(country=country, page=page, what=what) + results = data.get("results", []) + stats["fetched"] += len(results) + if verbose: + click.echo(f"[Pagina {page}] {len(results)} vacantes recibidas de Adzuna.") + except AppError: + # Detenemos paginación si la API externa falla, preservando lo que ya se haya procesado en iteraciones anteriores. + if verbose: + click.echo(f"[Pagina {page}] Error al contactar Adzuna, deteniendo ingesta.") + break + + for item in results: + cls._process_job(item, known_skills, stats, verbose=verbose) + + return stats + + @classmethod + def _compute_is_remote(cls, title: str, description: str) -> bool: + search_text = f"{title} {description}".lower() + return bool(re.search(r'\bremote\b', search_text) or re.search(r'\bremoto\b', search_text)) + + @classmethod + def _process_job(cls, item: dict, known_skills: dict, stats: dict, verbose: bool = False) -> None: + description = item.get("description", "") + if not description: + stats["errors"] += 1 + return + + # Hashing criptográfico para garantizar la idempotencia de la ingesta y evitar guardar la misma vacante si Adzuna la devuelve en días posteriores. + desc_hash = hashlib.sha256(description.encode("utf-8")).hexdigest() + + # Validamos duplicados ANTES de geocodificar o instanciar objetos, para evitar excepciones de BD y transacciones descartadas + if JobRepository.get_by_hash(desc_hash): + stats["duplicates"] += 1 + return + + title = item.get("title", "Desconocido") + company = item.get("company", {}).get("display_name", "Confidencial") + url = item.get("redirect_url", "") + + # Acotamos la búsqueda de remote/remoto a title y description con límites de palabra (\b) para evitar falsos positivos por nombres de empresa (ej. "RemoteWorks Solutions") o coincidencias parciales. + is_remote = cls._compute_is_remote(title, description) + + salary_min = item.get("salary_min") + salary_max = item.get("salary_max") + + # El campo location.area de Adzuna es una lista [país, estado, ciudad, ...] ordenada de más general a más específico. Tomamos el último elemento porque es siempre la entidad más concreta disponible, que coincide mejor con lo que Nominatim espera + location_area = item.get("location", {}).get("area", []) + raw_location = location_area[-1] if location_area else "" + + city_id, city_label = cls._resolve_city(raw_location, stats, verbose=verbose) + + if verbose: + click.echo(f" Vacante: {title[:60]} | Ubicacion: '{raw_location}' -> {city_label}") + + job_data = { + "title": title, + "company": company, + "description": description, + "url": url, + "description_hash": desc_hash, + "remote": is_remote, + "city_id": city_id, + # Capturamos los rangos salariales cuando Adzuna los incluye. Muchas vacantes no los declaran, por eso permitimos nulos + "salary_min": float(salary_min) if salary_min is not None else None, + "salary_max": float(salary_max) if salary_max is not None else None, + } + + try: + job = JobRepository.create(job_data) + except AppError: + # El repositorio ya hizo rollback y emitió el log; aquí solo contabilizamos el error de ingesta + stats["errors"] += 1 + return + + # Extracción NLP y vinculación relacional + extracted_skills = SkillsExtractionService.extract_skills(description) + for skill_name in extracted_skills: + skill_id = cls._get_or_create_skill(skill_name, known_skills) + if skill_id: + # Inyectamos confidence_score asumiendo certeza determinista del EntityRuler + try: + JobSkillRepository.create({ + "job_id": job.id, + "skill_id": skill_id, + "confidence_score": 0.95 + }) + except AppError as e: + logger.warning( + "No se pudo vincular skill_id=%s a job_id=%s: %s", + skill_id, job.id, e.message + ) + continue + + stats["processed"] += 1 + + @classmethod + def _resolve_city(cls, raw_location: str, stats: dict, verbose: bool = False): + """Resuelve la ubicación cruda de Adzuna a una fila de la tabla cities. Devuelve (city_id, label_para_log). Usa "México Nacional" como fallback cuando la geocodificación falla o la ubicación está vacía, para garantizar que city_id nunca quede nulo""" + city, created = CityService.get_or_create_city(raw_location) if raw_location else (None, False) + + if city: + if created: + stats["cities_created"] += 1 + return city.id, f"Ciudad: {city.name} ({city.state})" + else: + stats["fallback"] += 1 + return MEXICO_NACIONAL_CITY_ID, "fallback → México Nacional" + + @classmethod + def _get_or_create_skill(cls, skill_name: str, known_skills: dict): + # Mantenemos una única fuente centralizada en memoria durante el ciclo para minimizar I/O contra PostgreSQL + skill_key = skill_name.lower() + if skill_key in known_skills: + return known_skills[skill_key] + + new_skill = SkillRepository.create({ + "name": skill_name, + "canonical_name": skill_name.upper(), + # Asignamos la categoría General como fallback para habilidades detectadas por el NLP que aún no tienen clasificación formal + "category_id": 1, + }) + + if new_skill: + known_skills[skill_key] = new_skill.id + return new_skill.id + + return None diff --git a/backend/app/services/market_trends_service.py b/backend/app/services/market_trends_service.py new file mode 100644 index 0000000..56b0a7c --- /dev/null +++ b/backend/app/services/market_trends_service.py @@ -0,0 +1,75 @@ +import pandas as pd +from datetime import datetime, timezone, timedelta +from app.repositories.job_skill_repository import JobSkillRepository +from app.repositories.trend_snapshot_repository import TrendSnapshotRepository +from app.utils.errors import AppError + +class MarketTrendsService: + # Motor de procesamiento de datos analíticos. Aísla las operaciones vectoriales pesadas del resto del sistema para evitar cuellos de botella en el hilo principal de Flask + + @classmethod + def generate_snapshots(cls) -> int: + raw_data = JobSkillRepository.get_active(days=30) + + if not raw_data: + return 0 + + # Incluimos salary_min, salary_max y city_id de la vacante asociada para calcular demanda y salario promedio por habilidad y ciudad en un solo paso + df = pd.DataFrame([{ + "skill_id": item.skill_id, + "job_id": item.job_id, + "confidence": item.confidence_score, + # Usamos city_id=1 (México Nacional) como fallback para vacantes sin geocodificación para no perder esas métricas de la agregación + "city_id": item.job.city_id if item.job and item.job.city_id is not None else 1, + "salary_min": float(item.job.salary_min) if item.job and item.job.salary_min is not None else None, + "salary_max": float(item.job.salary_max) if item.job and item.job.salary_max is not None else None, + } for item in raw_data]) + + # Calculamos el punto medio del rango salarial por vacante. mean(axis=1, skipna=True) toma el unico valor disponible si solo uno de los dos extremos esta presente + df["salary_mid"] = df[["salary_min", "salary_max"]].mean(axis=1, skipna=True) + + # Agrupamos por habilidad Y ciudad para que /geo pueda mostrar distribución geográfica real en lugar de todo colapsado a México Nacional + trends = df.groupby(["skill_id", "city_id"]).agg( + demand_count=("job_id", "size"), + avg_salary=("salary_mid", "mean"), + ).reset_index() + + today = datetime.now(timezone.utc).date() + snapshots_created = 0 + + for _, row in trends.iterrows(): + avg_salary_value = row["avg_salary"] + # pandas representa la ausencia de datos como NaN, que no es serializable ni almacenable como None directamente en SQL + if pd.isna(avg_salary_value): + avg_salary_value = None + else: + avg_salary_value = float(avg_salary_value) + + skill_id = int(row["skill_id"]) + city_id = int(row["city_id"]) + nuevo_demand_count = int(row["demand_count"]) + + target_date = today - timedelta(days=7) + prev_snapshot = TrendSnapshotRepository.get_by_skill_city_date(skill_id, city_id, target_date) + + if not prev_snapshot or not prev_snapshot.demand_count: + growth_rate = None + else: + previo_demand_count = prev_snapshot.demand_count + growth_rate = round(((nuevo_demand_count - previo_demand_count) / previo_demand_count) * 100, 2) + growth_rate = max(min(growth_rate, 999.99), -999.99) + + snapshot_data = { + "skill_id": skill_id, + "city_id": city_id, + "date": today, + "demand_count": nuevo_demand_count, + "avg_salary": avg_salary_value, + "growth_rate": growth_rate, + } + + result = TrendSnapshotRepository.upsert(snapshot_data) + if result: + snapshots_created += 1 + + return snapshots_created diff --git a/backend/app/services/panorama_service.py b/backend/app/services/panorama_service.py new file mode 100644 index 0000000..3c370fd --- /dev/null +++ b/backend/app/services/panorama_service.py @@ -0,0 +1,160 @@ +from app.repositories.skill_repository import SkillRepository +from app.repositories.city_repository import CityRepository +from app.repositories.trend_snapshot_repository import TrendSnapshotRepository + +class PanoramaService: + # Orquesta las consultas necesarias para la vista de comparacion, usando metodos batch para evitar el patron N+1 que antes generaba hasta 15 queries individuales para 5 habilidades. + + @classmethod + def get_compare_data(cls, skill_ids: list[int]) -> dict: + """ + Retorna dict con: + - 'missing_ids': list[int] - ids solicitados que no existen + - 'blocks': list[dict] - un bloque por skill_id válido, en el mismo orden que skill_ids, con la misma forma que el payload que get_compare ya arma hoy (skill_id, skill_name, demand_count, growth_rate, avg_salary, series). Si missing_ids no está vacío, blocks debe ser una lista vacía; el controlador decide si retorna 404, el servicio solo reporta qué falta. """ + skills = SkillRepository.get_by_ids(skill_ids) + skills_by_id = {s.id: s for s in skills} + missing_ids = [sid for sid in skill_ids if sid not in skills_by_id] + + if missing_ids: + return {"missing_ids": missing_ids, "blocks": []} + + latest_snapshots = TrendSnapshotRepository.get_latest_by_skill_ids(skill_ids) + latest_by_skill = {s.skill_id: s for s in latest_snapshots} + + all_snapshots = TrendSnapshotRepository.get_by_skill_ids(skill_ids) + series_by_skill = {} + for snap in all_snapshots: + series_by_skill.setdefault(snap.skill_id, []).append(snap) + + blocks = [] + for sid in skill_ids: + skill = skills_by_id[sid] + latest = latest_by_skill.get(sid) + series = series_by_skill.get(sid, []) + blocks.append({ + "skill_id": skill.id, + "skill_name": skill.name, + "demand_count": latest.demand_count if latest else 0, + "growth_rate": latest.growth_rate if latest else None, + "avg_salary": latest.avg_salary if latest else None, + "series": [ + {"date": s.date, "demand_count": s.demand_count} + for s in series + ], + }) + + return {"missing_ids": [], "blocks": blocks} + + @classmethod + def get_all_skills(cls) -> list: + return SkillRepository.get_all() + + @classmethod + def get_catalogs_data(cls) -> dict: + skills = SkillRepository.get_all() + cities = CityRepository.get_all() + return { + "skills": [{"id": s.id, "name": s.name} for s in skills], + "cities": [{"id": c.id, "name": c.name} for c in cities], + } + + @classmethod + def get_summary_data(cls) -> dict: + data = TrendSnapshotRepository.get_summary_data() + + def build_skill_block(row): + if not row: + return None + snapshot, skill_name = row + return { + "skill_id": snapshot.skill_id, + "name": skill_name, + "demand_count": snapshot.demand_count, + "growth_rate": snapshot.growth_rate, + "avg_salary": snapshot.avg_salary, + } + + return { + "total_jobs": data["total_jobs"], + "total_skills_tracked": data["total_skills_tracked"], + "total_companies": data["total_companies"], + "top_emerging_skill": build_skill_block(data["top_emerging"]), + "top_declining_skill": build_skill_block(data["top_declining"]), + "last_updated": data["latest_date"], + } + + @classmethod + def get_top_skills_data(cls, limit: int) -> list: + snapshots = TrendSnapshotRepository.get_top_skills(limit=limit) + return [ + { + "skill_id": s.skill_id, + "name": s.skill.name if s.skill else None, + "category": s.skill.category.name if s.skill and s.skill.category else None, + "demand_count": s.demand_count, + "growth_rate": s.growth_rate, + "avg_salary": s.avg_salary, + } + for s in snapshots + ] + + @classmethod + def get_trends_data(cls, skill_id: int) -> dict | None: + # Retorna None si el skill no existe; el controlador decide el 404. + skill = SkillRepository.get_by_id(skill_id) + if not skill: + return None + snapshots = TrendSnapshotRepository.get_by_skill_id(skill_id) + return { + "skill_id": skill.id, + "skill_name": skill.name, + "series": [ + {"date": s.date, "demand_count": s.demand_count} + for s in snapshots + ], + } + + @classmethod + def get_geo_data(cls, skill_id: int | None, group_by: str) -> dict | tuple: + """ Retorna ("NOT_FOUND", None) si skill_id fue dado pero no existe. Retorna dict normal en cualquier otro caso. La convención de retorno distinta a get_trends_data y get_salaries_data refleja que aquí skill_id es opcional; sin él, la respuesta es válida. """ + skill = None + if skill_id is not None: + skill = SkillRepository.get_by_id(skill_id) + if not skill: + return ("NOT_FOUND", None) + rows = TrendSnapshotRepository.get_geo_distribution(skill_id=skill_id, group_by=group_by) + distribution = [] + for row in rows: + if group_by == "state": + distribution.append({ + "state": row.state, + "demand_count": row.total_demand, + "is_fallback": row.is_fallback, + }) + else: + distribution.append({ + "city_id": row.city_id, + "city_name": row.city_name, + "state": row.state, + "demand_count": row.total_demand, + }) + return { + "skill_id": skill.id if skill else None, + "skill_name": skill.name if skill else None, + "distribution": distribution, + } + + @classmethod + def get_salaries_data(cls, skill_id: int) -> dict | None: + # Retorna None si el skill no existe; el controlador decide el 404. + skill = SkillRepository.get_by_id(skill_id) + if not skill: + return None + stats = SkillRepository.get_salary_stats(skill_id) + return { + "skill_id": skill.id, + "skill_name": skill.name, + "avg_salary_min": stats.avg_salary_min if stats else None, + "avg_salary_max": stats.avg_salary_max if stats else None, + "sample_size": stats.sample_size if stats else 0, + } diff --git a/backend/app/services/profile_service.py b/backend/app/services/profile_service.py new file mode 100644 index 0000000..4596a34 --- /dev/null +++ b/backend/app/services/profile_service.py @@ -0,0 +1,72 @@ +from datetime import datetime, timezone + +from app.repositories.user_repository import UserRepository +from app.repositories.user_skill_repository import UserSkillRepository +from app.repositories.trend_snapshot_repository import TrendSnapshotRepository +from app.utils.hash import hash_password, verify_password + + +class ProfileService: + + @classmethod + def get_skill_gap(cls, user_id: int) -> dict: + # Cargamos primero las habilidades del usuario, luego el ranking global completo (sin limite), y construimos las dos listas; lo que ya tiene y lo que le falta del top de la industria + user_skill_rows = UserSkillRepository.get_skills_by_user(user_id) + user_skill_ids = {row.UserSkill.skill_id for row in user_skill_rows} + user_skills_by_id = {row.UserSkill.skill_id: row.Skill for row in user_skill_rows} + + # Usamos get_top_skills con un limite alto para obtener el ranking completo disponible + top_snapshots = TrendSnapshotRepository.get_top_skills(limit=50) + + mis_habilidades = [] + brechas = [] + + for rank_index, snapshot in enumerate(top_snapshots, start=1): + skill_entry = { + "skill_id": snapshot.skill_id, + "name": snapshot.skill.name if snapshot.skill else None, + "demand_count": snapshot.demand_count, + "ranking_position": rank_index, + } + if snapshot.skill_id in user_skill_ids: + mis_habilidades.append(skill_entry) + else: + brechas.append(skill_entry) + + return { + "mis_habilidades": mis_habilidades, + "brechas": brechas, + } + + @classmethod + def update_profile(cls, user_id: int, data: dict) -> object: + user = UserRepository.get_by_id(user_id) + if not user: + return None + + # Solo actualizamos los campos que llegan en data; los ausentes quedan intactos. + if data.get("first_name") is not None: + user.first_name = data["first_name"] + if data.get("last_name") is not None: + user.last_name = data["last_name"] + if "intent" in data: + # intent puede llegar explicitamente como None para "borrar" el valor + user.intent = data["intent"] + + return UserRepository.save(user) + + @classmethod + def change_password(cls, user_id: int, current_password: str, new_password: str) -> None: + user = UserRepository.get_by_id(user_id) + + # Rechazamos si la cuenta no tiene contrasena propia (solo-OAuth) antes de intentar bcrypt. + if not user or user.password_hash is None: + raise ValueError("INVALID_CREDENTIALS") + + if not verify_password(current_password, user.password_hash): + raise ValueError("INVALID_CREDENTIALS") + + user.password_hash = hash_password(new_password) + # Actualizamos password_changed_at para que el blocklist callback invalide los tokens anteriores. + user.password_changed_at = datetime.now(timezone.utc) + UserRepository.save(user) diff --git a/backend/app/services/skills_extraction_service.py b/backend/app/services/skills_extraction_service.py new file mode 100644 index 0000000..b94f8ae --- /dev/null +++ b/backend/app/services/skills_extraction_service.py @@ -0,0 +1,65 @@ +import os +import json +import spacy +from app.utils.errors import AppError + +BASE_DIR = os.path.dirname( + os.path.dirname( + os.path.dirname( + os.path.dirname(os.path.abspath(__file__)) + ) + ) +) +DICT_PATH = os.path.join(BASE_DIR, "data", "dictionaries", "skills_esco.jsonl") + +# Carga del modelo NLP a nivel de módulo. +# Esto garantiza que el impacto en CPU/RAM ocurra solo una vez al arrancar la aplicación y no en cada llamada al servicio durante el procesamiento de vacantes. +try: + nlp = spacy.load( + "es_core_news_sm", + disable=["ner", "parser", "tagger", "lemmatizer", "attribute_ruler"], + ) + + if not os.path.exists(DICT_PATH): + raise FileNotFoundError(f"Diccionario no encontrado en: {DICT_PATH}") + + # Forzamos que el EntityRuler opere antes del componente ner y con overwrite_ents=True para que sus matches tengan prioridad absoluta sobre cualquier entidad que otros componentes del pipeline produzcan. + ruler = nlp.add_pipe( + "entity_ruler", + before="ner", + config={"overwrite_ents": True}, + ) + + patterns = [] + with open(DICT_PATH, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + patterns.append(json.loads(line)) + + ruler.add_patterns(patterns) + nlp_error = None + +except Exception as e: + nlp = None + nlp_error = str(e) + + +class SkillsExtractionService: + # Capa de dominio puro. Recibe texto, devuelve entidades de conocimiento. + + @classmethod + def extract_skills(cls, text: str) -> list: + if not nlp: + raise AppError(f"El motor NLP falló en su inicialización: {nlp_error}", code="NLP_INIT_ERROR") + + if not text or not isinstance(text, str): + return [] + + # Procesamos el texto crudo contra las reglas inyectadas + doc = nlp(text) + + # Filtramos entidades etiquetadas como SKILL. Utilizamos un set para erradicar duplicados si una vacante menciona "Python" varias veces. + skills_found = {ent.text for ent in doc.ents if ent.label_ == "SKILL"} + + return list(skills_found) diff --git a/backend/app/services/storage_service.py b/backend/app/services/storage_service.py new file mode 100644 index 0000000..222eabd --- /dev/null +++ b/backend/app/services/storage_service.py @@ -0,0 +1,65 @@ +import logging +import boto3 +from botocore.exceptions import ClientError, EndpointConnectionError +from flask import current_app + +logger = logging.getLogger(__name__) + + +class RemoteStorageService: + # Encapsula la comunicacion con almacenamiento S3-compatible (Cloudflare R2). Es una capa de resiliencia adicional sobre el almacenamiento local ya existente, nunca el mecanismo unico, si R2 no esta configurado o falla, el backup local generado por BackupService sigue siendo valido. + + @classmethod + def _get_client(cls): + endpoint = current_app.config.get("R2_ENDPOINT_URL") + access_key = current_app.config.get("R2_ACCESS_KEY_ID") + secret_key = current_app.config.get("R2_SECRET_ACCESS_KEY") + bucket = current_app.config.get("R2_BUCKET_NAME") + if not all([endpoint, access_key, secret_key, bucket]): + return None + return boto3.client( + "s3", + endpoint_url=endpoint, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + ) + + @classmethod + def is_configured(cls) -> bool: + return cls._get_client() is not None + + @classmethod + def upload_backup(cls, filepath: str, filename: str) -> bool: + client = cls._get_client() + if not client: + logger.warning( + "R2 no esta configurado, se omite la subida remota del respaldo %s.", + filename, + ) + return False + bucket = current_app.config.get("R2_BUCKET_NAME") + try: + client.upload_file(filepath, bucket, filename) + logger.info("Respaldo %s subido correctamente a R2.", filename) + return True + except (ClientError, EndpointConnectionError) as e: + logger.error("Fallo al subir respaldo %s a R2: %s", filename, str(e)) + return False + + @classmethod + def download_backup(cls, filename: str, destination_path: str) -> bool: + client = cls._get_client() + if not client: + logger.warning( + "R2 no esta configurado, no se puede descargar el respaldo %s.", + filename, + ) + return False + bucket = current_app.config.get("R2_BUCKET_NAME") + try: + client.download_file(bucket, filename, destination_path) + logger.info("Respaldo %s descargado correctamente desde R2.", filename) + return True + except (ClientError, EndpointConnectionError) as e: + logger.error("Fallo al descargar respaldo %s desde R2: %s", filename, str(e)) + return False diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/decorators.py b/backend/app/utils/decorators.py new file mode 100644 index 0000000..8ce3d73 --- /dev/null +++ b/backend/app/utils/decorators.py @@ -0,0 +1,47 @@ +import logging +from functools import wraps +from flask_jwt_extended import get_jwt_identity +from app.utils.response import error_response + +logger = logging.getLogger(__name__) + + +def role_required(*allowed_roles): + """Decorador de autorización por rol para rutas de la API. Debe aplicarse siempre después de @jwt_required() en el orden de decoradores, es decir, @jwt_required() va encima y @role_required(...) va debajo. Esto es necesario porque jwt_required debe ejecutarse primero, sin él no existe identidad verificada que este decorador pueda consultar. + + Uso: + @jwt_required() + @role_required('ADMIN') + def mi_ruta(): + ... + """ + def decorator(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + # Importación diferida para evitar ciclo con db en el arranque de la app. + from app.repositories.user_repository import UserRepository + + user_id = get_jwt_identity() + user = UserRepository.get_by_id(int(user_id)) if user_id else None + + if not user: + # Un token puede seguir siendo criptograficamente valido aunque el usuario ya no exista (cuenta eliminada tras emitirse el token). Usamos el mismo code que un token ausente porque en ambos casos el frontend debe reaccionar igual: redirigir a login. + return error_response( + code="UNAUTHORIZED", + message="La sesion no corresponde a un usuario valido.", + status_code=401, + ) + if user.role not in allowed_roles: + logger.warning( + f"Acceso denegado: usuario {user.id} ({user.email}) con rol " + f"{user.role} intentó acceder a un recurso que requiere " + f"{allowed_roles}." + ) + return error_response( + code="FORBIDDEN", + message="No tienes permiso para acceder a este recurso.", + status_code=403, + ) + return fn(*args, **kwargs) + return wrapper + return decorator diff --git a/backend/app/utils/errors.py b/backend/app/utils/errors.py new file mode 100644 index 0000000..e04dbe2 --- /dev/null +++ b/backend/app/utils/errors.py @@ -0,0 +1,25 @@ +class AppError(Exception): + # Excepción base para que la capa de Controladores pueda atrapar cualquier fallo lógico de la capa de Servicios sin acoplarse a librerías HTTP. + def __init__(self, message: str, code: str = "INTERNAL_ERROR", status_code: int = 500, detail: dict = None): + super().__init__(message) + self.message = message + self.code = code + self.status_code = status_code + # Campo opcional para datos estructurados adicionales que el controlador puede necesitar para construir la respuesta (ej: link_token en ACCOUNT_LINK_PENDING). + self.detail = detail + +class ResourceNotFoundError(AppError): + def __init__(self, message: str = "El recurso solicitado no fue encontrado."): + super().__init__(message, code="NOT_FOUND", status_code=404) + +class ValidationError(AppError): + def __init__(self, message: str = "Error de validación de datos."): + super().__init__(message, code="VALIDATION_ERROR", status_code=422) + +class UnauthorizedError(AppError): + def __init__(self, message: str = "No autorizado para realizar esta acción."): + super().__init__(message, code="UNAUTHORIZED", status_code=401) + +class ConflictError(AppError): + def __init__(self, message: str = "Conflicto con el estado actual del recurso."): + super().__init__(message, code="CONFLICT", status_code=409) diff --git a/backend/app/utils/hash.py b/backend/app/utils/hash.py new file mode 100644 index 0000000..0e12b5e --- /dev/null +++ b/backend/app/utils/hash.py @@ -0,0 +1,11 @@ +import bcrypt + +def hash_password(password: str) -> str: + # Usamos bcrypt con gensalt() para proteger contra ataques de rainbow tables + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password.encode("utf-8"), salt) + return hashed.decode("utf-8") + +def verify_password(plain_password: str, hashed_password: str) -> bool: + # La comparación siempre debe hacerse a nivel de bytes para evitar brechas de codificación + return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) diff --git a/backend/app/utils/response.py b/backend/app/utils/response.py new file mode 100644 index 0000000..c6f1f36 --- /dev/null +++ b/backend/app/utils/response.py @@ -0,0 +1,22 @@ +from typing import Any, Dict, Tuple +from flask import jsonify, Response + +def success_response(data: Any = None, meta: Dict = None, status_code: int = 200) -> Tuple[Response, int]: + # Estructuramos una respuesta predecible para que el frontend no tenga que + # adivinar en qué llave viene la información tras cada petición. + response_body = {} + if data is not None: + response_body["data"] = data + if meta is not None: + response_body["meta"] = meta + return jsonify(response_body), status_code + +def error_response(code: str, message: str, status_code: int = 400) -> Tuple[Response, int]: + # Aislamos el formato de error para garantizar que todas las fallas del sistema + # sean procesadas uniformemente por el interceptor global de Axios en el frontend. + return jsonify({ + "error": { + "code": code, + "message": message + } + }), status_code diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py new file mode 100644 index 0000000..e812715 --- /dev/null +++ b/backend/app/utils/security.py @@ -0,0 +1,13 @@ +from flask_jwt_extended import create_access_token + +def generate_tokens(user_id: int, role: str) -> dict: + # Inyectamos el rol directamente en los claims del token JWT para evitar consultas redundantes a la base de datos en las rutas protegidas (ahorro de latencia). No pasamos expires_delta aqui; dejamos que JWT_ACCESS_TOKEN_EXPIRES en config.py sea el único punto de referencia sobre cuanto dura la sesion. + access_token = create_access_token( + identity=str(user_id), + additional_claims={"role": role}, + ) + + return { + "access_token": access_token, + "token_type": "Bearer", + } diff --git a/backend/logging_config.py b/backend/logging_config.py new file mode 100644 index 0000000..3527cc7 --- /dev/null +++ b/backend/logging_config.py @@ -0,0 +1,19 @@ +import logging +import sys +from logging.handlers import RotatingFileHandler + +# Log a archivo para que el checkpoint pueda leer los tokens de reset en modo desarrollo sin depender de que el stderr de Flask llegue al terminal de PowerShell (que lo redirige de forma inconsistente). +handler = RotatingFileHandler("flask_dev.log", maxBytes=1_000_000, backupCount=1, encoding="utf-8") +handler.setLevel(logging.DEBUG) +formatter = logging.Formatter("[%(asctime)s] %(levelname)s in %(name)s: %(message)s") +handler.setFormatter(formatter) + +# Adjuntamos al root logger para capturar logger.info() de cualquier módulo +logging.getLogger().addHandler(handler) +logging.getLogger().setLevel(logging.DEBUG) + +# También a stderr para no perder visibilidad en la consola +stream_handler = logging.StreamHandler(sys.stderr) +stream_handler.setLevel(logging.INFO) +stream_handler.setFormatter(formatter) +logging.getLogger().addHandler(stream_handler) diff --git a/backend/migrations/README b/backend/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/backend/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/backend/migrations/alembic.ini b/backend/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/backend/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/03ff0fc315b6_restore_created_at_not_null_on_users.py b/backend/migrations/versions/03ff0fc315b6_restore_created_at_not_null_on_users.py new file mode 100644 index 0000000..409c91a --- /dev/null +++ b/backend/migrations/versions/03ff0fc315b6_restore_created_at_not_null_on_users.py @@ -0,0 +1,38 @@ +"""restore created_at not null on users + +Revision ID: 03ff0fc315b6 +Revises: 4c1e2a0ac992 +Create Date: 2026-07-12 17:33:10.592951 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '03ff0fc315b6' +down_revision = '4c1e2a0ac992' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(), + nullable=False, + existing_server_default=sa.text('now()')) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('now()')) + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/050a32090a02_add_intent_to_users.py b/backend/migrations/versions/050a32090a02_add_intent_to_users.py new file mode 100644 index 0000000..b19ea4c --- /dev/null +++ b/backend/migrations/versions/050a32090a02_add_intent_to_users.py @@ -0,0 +1,37 @@ +"""add intent to users + +Revision ID: 050a32090a02 +Revises: 83e1f6043b93 +Create Date: 2026-06-30 18:01:01.970181 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '050a32090a02' +down_revision = '83e1f6043b93' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('intent', sa.String(length=20), nullable=True)) + + op.create_check_constraint( + 'chk_users_intent', 'users', + "intent IS NULL OR intent IN ('ESTUDIANTE', 'RECLUTADOR')" + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('chk_users_intent', 'users', type_='check') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('intent') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/146283b709e9_rename_user_alerts_threshold_to_.py b/backend/migrations/versions/146283b709e9_rename_user_alerts_threshold_to_.py new file mode 100644 index 0000000..a3ee5ba --- /dev/null +++ b/backend/migrations/versions/146283b709e9_rename_user_alerts_threshold_to_.py @@ -0,0 +1,24 @@ +"""rename user_alerts.threshold to threshold_value + +Revision ID: 146283b709e9 +Revises: 6d85e79a3808 +Create Date: 2026-07-08 19:07:02.019892 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = '146283b709e9' +down_revision = '6d85e79a3808' +branch_labels = None +depends_on = None + + +def upgrade(): + # Renombramos la columna en vez de drop+add para que la operación sea atómica + # y no destruya datos si la tabla no estuviera vacía + op.alter_column('user_alerts', 'threshold', new_column_name='threshold_value') + + +def downgrade(): + op.alter_column('user_alerts', 'threshold_value', new_column_name='threshold') diff --git a/backend/migrations/versions/186b5a2fe371_add_google_link_tokens_table.py b/backend/migrations/versions/186b5a2fe371_add_google_link_tokens_table.py new file mode 100644 index 0000000..db951ca --- /dev/null +++ b/backend/migrations/versions/186b5a2fe371_add_google_link_tokens_table.py @@ -0,0 +1,56 @@ +"""add_google_link_tokens_table + +Revision ID: 186b5a2fe371 +Revises: e89b2bf6614f +Create Date: 2026-08-01 18:06:04.759709 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '186b5a2fe371' +down_revision = 'e89b2bf6614f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # if_not_exists=True hace que el upgrade sea idempotente: si la tabla + # ya existe (ej. tras un pg_restore que incluia el esquema de esta + # revision), el upgrade no falla con DuplicateTable. + op.create_table( + 'google_link_tokens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('google_user_id', sa.String(length=255), nullable=False), + sa.Column('pending_email', sa.String(length=254), nullable=False), + sa.Column('pending_first_name', sa.String(length=100), nullable=True), + sa.Column('pending_last_name', sa.String(length=100), nullable=True), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('used_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash'), + if_not_exists=True, + ) + # Usamos raw SQL para la creacion del indice porque IF NOT EXISTS no es + # soportado via batch_alter_table en todas las versiones de Alembic. + # Esto garantiza que el upgrade es idempotente cuando el indice ya existe + # (ej. tras un pg_restore que incluia el esquema de esta revision). + op.execute( + "CREATE INDEX IF NOT EXISTS ix_google_link_tokens_token_hash " + "ON google_link_tokens (token_hash)" + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.execute("DROP INDEX IF EXISTS ix_google_link_tokens_token_hash") + op.drop_table('google_link_tokens') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/2dd31174c2d6_remove_guest_from_users_role_constraint.py b/backend/migrations/versions/2dd31174c2d6_remove_guest_from_users_role_constraint.py new file mode 100644 index 0000000..4c76363 --- /dev/null +++ b/backend/migrations/versions/2dd31174c2d6_remove_guest_from_users_role_constraint.py @@ -0,0 +1,26 @@ +"""remove GUEST from users role constraint + +Revision ID: 2dd31174c2d6 +Revises: b048153bea47 +Create Date: 2026-06-28 22:38:18.200252 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2dd31174c2d6' +down_revision = 'b048153bea47' +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_constraint('chk_users_role', 'users', type_='check') + op.create_check_constraint('chk_users_role', 'users', "role IN ('REGISTERED', 'ADMIN')") + + +def downgrade(): + op.drop_constraint('chk_users_role', 'users', type_='check') + op.create_check_constraint('chk_users_role', 'users', "role IN ('GUEST', 'REGISTERED', 'ADMIN')") diff --git a/backend/migrations/versions/33f2045987f9_add_alert_type_and_threshold_percentage_.py b/backend/migrations/versions/33f2045987f9_add_alert_type_and_threshold_percentage_.py new file mode 100644 index 0000000..cf73783 --- /dev/null +++ b/backend/migrations/versions/33f2045987f9_add_alert_type_and_threshold_percentage_.py @@ -0,0 +1,53 @@ +"""add alert_type and threshold_percentage to user_alerts + +Revision ID: 33f2045987f9 +Revises: ee1b7b2d2464 +Create Date: 2026-07-12 22:29:18.591440 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '33f2045987f9' +down_revision = 'ee1b7b2d2464' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_alerts', schema=None) as batch_op: + # server_default='ABSOLUTE' hace backfill automático de las filas existentes, + # que implícitamente son todas de umbral absoluto, sin violar el NOT NULL. + batch_op.add_column(sa.Column('alert_type', sa.String(length=20), nullable=False, server_default='ABSOLUTE')) + batch_op.add_column(sa.Column('threshold_percentage', sa.Numeric(precision=5, scale=2), nullable=True)) + batch_op.alter_column('threshold_value', + existing_type=sa.INTEGER(), + nullable=True) + batch_op.create_check_constraint( + 'chk_alerts_type', + "alert_type IN ('ABSOLUTE', 'TREND')" + ) + batch_op.create_check_constraint( + 'chk_alerts_threshold_matches_type', + "(alert_type = 'ABSOLUTE' AND threshold_value IS NOT NULL) OR " + "(alert_type = 'TREND' AND threshold_percentage IS NOT NULL)" + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_alerts', schema=None) as batch_op: + batch_op.drop_constraint('chk_alerts_threshold_matches_type', type_='check') + batch_op.drop_constraint('chk_alerts_type', type_='check') + batch_op.alter_column('threshold_value', + existing_type=sa.INTEGER(), + nullable=False) + batch_op.drop_column('threshold_percentage') + batch_op.drop_column('alert_type') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/3e1b8af1978c_add_password_reset_tokens_table.py b/backend/migrations/versions/3e1b8af1978c_add_password_reset_tokens_table.py new file mode 100644 index 0000000..8be4e9e --- /dev/null +++ b/backend/migrations/versions/3e1b8af1978c_add_password_reset_tokens_table.py @@ -0,0 +1,44 @@ +"""add password_reset_tokens table + +Revision ID: 3e1b8af1978c +Revises: 7199b46f6883 +Create Date: 2026-06-25 22:08:47.519962 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3e1b8af1978c' +down_revision = '7199b46f6883' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('password_reset_tokens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('used_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash') + ) + with op.batch_alter_table('password_reset_tokens', schema=None) as batch_op: + batch_op.create_index('ix_password_reset_tokens_token_hash', ['token_hash'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('password_reset_tokens', schema=None) as batch_op: + batch_op.drop_index('ix_password_reset_tokens_token_hash') + + op.drop_table('password_reset_tokens') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/401c30988716_seed_base_skill_categories.py b/backend/migrations/versions/401c30988716_seed_base_skill_categories.py new file mode 100644 index 0000000..024ab5c --- /dev/null +++ b/backend/migrations/versions/401c30988716_seed_base_skill_categories.py @@ -0,0 +1,39 @@ +"""seed base skill categories + +Revision ID: 401c30988716 +Revises: ce332628ddba +Create Date: 2026-07-18 11:06:08.837470 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '401c30988716' +down_revision = 'ce332628ddba' +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute(""" + INSERT INTO categories (id, name) VALUES + (1, 'General'), + (2, 'Frontend'), + (3, 'Backend'), + (4, 'DevOps y Cloud'), + (5, 'Datos e IA'), + (6, 'Arquitectura') + ON CONFLICT (id) DO NOTHING; + """) + +def downgrade(): + # Solo eliminamos si no tienen skills vinculados, para no borrar + # categorias con datos reales ya clasificados en un entorno donde + # si se uso esta migracion para crearlas desde cero. + op.execute(""" + DELETE FROM categories + WHERE id IN (1, 2, 3, 4, 5, 6) + AND id NOT IN (SELECT DISTINCT category_id FROM skills WHERE category_id IS NOT NULL); + """) diff --git a/backend/migrations/versions/4c1e2a0ac992_add_is_active_to_users.py b/backend/migrations/versions/4c1e2a0ac992_add_is_active_to_users.py new file mode 100644 index 0000000..5d88576 --- /dev/null +++ b/backend/migrations/versions/4c1e2a0ac992_add_is_active_to_users.py @@ -0,0 +1,40 @@ +"""add is_active to users + +Revision ID: 4c1e2a0ac992 +Revises: 146283b709e9 +Create Date: 2026-07-12 17:24:37.045034 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '4c1e2a0ac992' +down_revision = '146283b709e9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('true'))) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('now()')) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(), + nullable=False, + existing_server_default=sa.text('now()')) + batch_op.drop_column('is_active') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/5fc89dde67bf_add_email_verification_schema.py b/backend/migrations/versions/5fc89dde67bf_add_email_verification_schema.py new file mode 100644 index 0000000..1abf45d --- /dev/null +++ b/backend/migrations/versions/5fc89dde67bf_add_email_verification_schema.py @@ -0,0 +1,52 @@ +"""add email verification schema + +Revision ID: 5fc89dde67bf +Revises: 050a32090a02 +Create Date: 2026-07-04 15:02:54.133260 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5fc89dde67bf' +down_revision = '050a32090a02' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('email_verification_tokens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('used_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash') + ) + with op.batch_alter_table('email_verification_tokens', schema=None) as batch_op: + batch_op.create_index('ix_email_verification_tokens_token_hash', ['token_hash'], unique=False) + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('email_verified_at', sa.DateTime(), nullable=True)) + + op.execute("UPDATE users SET email_verified_at = created_at WHERE email_verified_at IS NULL") + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('email_verified_at') + + with op.batch_alter_table('email_verification_tokens', schema=None) as batch_op: + batch_op.drop_index('ix_email_verification_tokens_token_hash') + + op.drop_table('email_verification_tokens') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/6d85e79a3808_harden_users_created_at_not_null_.py b/backend/migrations/versions/6d85e79a3808_harden_users_created_at_not_null_.py new file mode 100644 index 0000000..de61a94 --- /dev/null +++ b/backend/migrations/versions/6d85e79a3808_harden_users_created_at_not_null_.py @@ -0,0 +1,33 @@ +"""harden users created_at not null constraint + +Revision ID: 6d85e79a3808 +Revises: 5fc89dde67bf +Create Date: 2026-07-04 15:32:14.004434 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6d85e79a3808' +down_revision = '5fc89dde67bf' +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("UPDATE users SET created_at = now() WHERE created_at IS NULL") + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=sa.DateTime(), + nullable=False, + server_default=sa.text('now()')) + + +def downgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=sa.DateTime(), + nullable=True, + server_default=None) diff --git a/backend/migrations/versions/7199b46f6883_add_oauth_accounts_table_and_make_.py b/backend/migrations/versions/7199b46f6883_add_oauth_accounts_table_and_make_.py new file mode 100644 index 0000000..43fe29b --- /dev/null +++ b/backend/migrations/versions/7199b46f6883_add_oauth_accounts_table_and_make_.py @@ -0,0 +1,48 @@ +"""add oauth_accounts table and make password_hash nullable + +Revision ID: 7199b46f6883 +Revises: 8bf9d1117007 +Create Date: 2026-06-23 23:09:15.486806 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7199b46f6883' +down_revision = '8bf9d1117007' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('oauth_accounts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('provider', sa.String(length=20), nullable=False), + sa.Column('provider_user_id', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.CheckConstraint("provider IN ('google', 'github', 'apple')", name='chk_oauth_accounts_provider'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('provider', 'provider_user_id', name='uq_oauth_accounts_provider_identity') + ) + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('password_hash', + existing_type=sa.VARCHAR(length=255), + nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('password_hash', + existing_type=sa.VARCHAR(length=255), + nullable=False) + + op.drop_table('oauth_accounts') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/83e1f6043b93_add_user_skills_table.py b/backend/migrations/versions/83e1f6043b93_add_user_skills_table.py new file mode 100644 index 0000000..e419356 --- /dev/null +++ b/backend/migrations/versions/83e1f6043b93_add_user_skills_table.py @@ -0,0 +1,31 @@ +"""add user_skills table + +Revision ID: 83e1f6043b93 +Revises: 2dd31174c2d6 +Create Date: 2026-06-28 22:38:28.349877 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '83e1f6043b93' +down_revision = '2dd31174c2d6' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('user_skills', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('skill_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['skill_id'], ['skills.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('user_id', 'skill_id') + ) + + +def downgrade(): + op.drop_table('user_skills') diff --git a/backend/migrations/versions/8bf9d1117007_make_trend_snapshot_city_id_nullable.py b/backend/migrations/versions/8bf9d1117007_make_trend_snapshot_city_id_nullable.py new file mode 100644 index 0000000..a14fe8d --- /dev/null +++ b/backend/migrations/versions/8bf9d1117007_make_trend_snapshot_city_id_nullable.py @@ -0,0 +1,36 @@ +"""make trend_snapshot city_id nullable + +Revision ID: 8bf9d1117007 +Revises: c431b5f6ca10 +Create Date: 2026-06-15 22:37:31.589524 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8bf9d1117007' +down_revision = 'c431b5f6ca10' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('trend_snapshots', schema=None) as batch_op: + batch_op.alter_column('city_id', + existing_type=sa.INTEGER(), + nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('trend_snapshots', schema=None) as batch_op: + batch_op.alter_column('city_id', + existing_type=sa.INTEGER(), + nullable=False) + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/9a104cdbdaed_seed_insert_mexico_nacional_fallback_.py b/backend/migrations/versions/9a104cdbdaed_seed_insert_mexico_nacional_fallback_.py new file mode 100644 index 0000000..18a0ceb --- /dev/null +++ b/backend/migrations/versions/9a104cdbdaed_seed_insert_mexico_nacional_fallback_.py @@ -0,0 +1,73 @@ +"""seed: insert Mexico Nacional fallback city (id=1) + +Revision ID: 9a104cdbdaed +Revises: beb2d2367ab1 +Create Date: 2026-07-25 14:54:35.785950 + +Garantiza que la fila id=1 ("México Nacional") exista antes de cualquier +ingesta. IngestionService asume que MEXICO_NACIONAL_CITY_ID=1 siempre +está disponible; sin esta semilla la primera ciudad orgánica toma ese id +por autoincremento, corrompiendo silenciosamente las vacantes sin +geocodificación. + +La sentencia ON CONFLICT DO NOTHING hace la migración idempotente: segura +de re-ejecutar si por alguna razón ya existiera la fila (p.ej., si se +corre flask db upgrade dos veces o si la migración se aplica en un +entorno que ya tenía la fila de forma manual). + +El SELECT setval(...) al final adelanta la secuencia por encima del id=1 +para que la próxima inserción orgánica comience desde id=2 y no intente +reusar ni colisionar con el id reservado. Usamos SELECT GREATEST(2, +MAX(id)+1) para que sea seguro incluso si ya hubiera filas con ids > 1. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9a104cdbdaed' +down_revision = 'beb2d2367ab1' +branch_labels = None +depends_on = None + + +def upgrade(): + # Insertar la fila semilla con id=1 explícito. + # state, lat, lon son nullable → NULL es el valor correcto para un + # fallback genérico que no representa una ciudad geocodificada real. + # ON CONFLICT (id) DO NOTHING garantiza idempotencia. + op.execute( + sa.text( + """ + INSERT INTO cities (id, name, state, country, lat, lon) + VALUES (1, 'México Nacional', NULL, 'MX', NULL, NULL) + ON CONFLICT (id) DO NOTHING + """ + ) + ) + + # Adelantar la secuencia para que la siguiente inserción orgánica + # no intente asignarse el id=1 ya reservado. + # GREATEST(2, MAX(id)+1) es defensivo: cubre el caso en que ya + # hubiera filas con id > 1 antes de correr esta migración. + op.execute( + sa.text( + """ + SELECT setval( + pg_get_serial_sequence('cities', 'id'), + GREATEST(2, (SELECT MAX(id) + 1 FROM cities)) + ) + """ + ) + ) + + +def downgrade(): + # Eliminar únicamente la fila semilla. Usamos la combinación + # id=1 AND name='México Nacional' para no borrar accidentalmente + # una fila distinta si el contexto cambia. + op.execute( + sa.text( + "DELETE FROM cities WHERE id = 1 AND name = 'México Nacional'" + ) + ) diff --git a/backend/migrations/versions/9decfc36853f_initial_database_migration.py b/backend/migrations/versions/9decfc36853f_initial_database_migration.py new file mode 100644 index 0000000..254c1d8 --- /dev/null +++ b/backend/migrations/versions/9decfc36853f_initial_database_migration.py @@ -0,0 +1,132 @@ +"""initial database migration + +Revision ID: 9decfc36853f +Revises: Elias Ochoa +Create Date: 2026-06-09 17:38:16 p.m. + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9decfc36853f' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('categories', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('cities', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('state', sa.String(length=100), nullable=True), + sa.Column('country', sa.String(length=10), server_default='MX', nullable=True), + sa.Column('lat', sa.Numeric(precision=9, scale=6), nullable=True), + sa.Column('lon', sa.Numeric(precision=9, scale=6), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('first_name', sa.String(length=50), nullable=False), + sa.Column('last_name', sa.String(length=50), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.CheckConstraint("role IN ('GUEST', 'REGISTERED', 'ADMIN')", name='chk_users_role'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + op.create_table('backups', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('filename', sa.String(length=255), nullable=False), + sa.Column('storage_url', sa.String(length=500), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False), + sa.CheckConstraint("status IN ('PENDING', 'COMPLETED', 'FAILED')", name='chk_backups_status'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('jobs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('source', sa.String(length=50), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('company', sa.String(length=255), nullable=True), + sa.Column('city_id', sa.Integer(), nullable=True), + sa.Column('salary_min', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('salary_max', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('raw_description', sa.Text(), nullable=False), + sa.Column('description_hash', sa.String(length=64), nullable=False), + sa.Column('processed', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['city_id'], ['cities.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('skills', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('canonical_name', sa.String(length=100), nullable=False), + sa.Column('category_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('canonical_name') + ) + op.create_table('job_skills', + sa.Column('job_id', sa.Integer(), nullable=False), + sa.Column('skill_id', sa.Integer(), nullable=False), + sa.Column('confidence_score', sa.Numeric(precision=4, scale=3), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['job_id'], ['jobs.id'], ), + sa.ForeignKeyConstraint(['skill_id'], ['skills.id'], ), + sa.PrimaryKeyConstraint('job_id', 'skill_id') + ) + op.create_table('trend_snapshots', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('skill_id', sa.Integer(), nullable=False), + sa.Column('city_id', sa.Integer(), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('demand_count', sa.Integer(), nullable=True), + sa.Column('growth_rate', sa.Numeric(precision=6, scale=2), nullable=True), + sa.Column('avg_salary', sa.Numeric(precision=10, scale=2), nullable=True), + sa.ForeignKeyConstraint(['city_id'], ['cities.id'], ), + sa.ForeignKeyConstraint(['skill_id'], ['skills.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('skill_id', 'city_id', 'date', name='uq_trend_snapshot_skill_city_date') + ) + op.create_table('user_alerts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('skill_id', sa.Integer(), nullable=False), + sa.Column('threshold', sa.Integer(), nullable=False), + sa.Column('active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['skill_id'], ['skills.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user_alerts') + op.drop_table('trend_snapshots') + op.drop_table('job_skills') + op.drop_table('skills') + op.drop_table('jobs') + op.drop_table('backups') + op.drop_table('users') + op.drop_table('cities') + op.drop_table('categories') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/a1b2c3d4e5f6_fix_sync_categories_id_seq_after_seed_without_setval.py b/backend/migrations/versions/a1b2c3d4e5f6_fix_sync_categories_id_seq_after_seed_without_setval.py new file mode 100644 index 0000000..fd3c623 --- /dev/null +++ b/backend/migrations/versions/a1b2c3d4e5f6_fix_sync_categories_id_seq_after_seed_without_setval.py @@ -0,0 +1,58 @@ +"""fix: sync categories_id_seq after seed without setval + +Revision ID: a1b2c3d4e5f6 +Revises: 9a104cdbdaed +Create Date: 2026-07-27 00:00:00.000000 + +La migración 401c30988716 sembró las categorías base (ids 1-6) con ids +explícitos usando ON CONFLICT DO NOTHING, pero omitió el ajuste de la +secuencia categories_id_seq. Esto hace que la primera inserción orgánica +de una Category (p.ej. en tests de integración) intente reutilizar el +id=1 ya ocupado, produciendo: + + duplicate key value violates unique constraint "categories_pkey" + +Este patrón es idéntico al que afectó a cities y se corrigió en +9a104cdbdaed. Se aplica la misma solución: SELECT setval() con +GREATEST(MAX(id)+1, 7) para que la secuencia arranque por encima del +id más alto ya sembrado (id=6), sin depender de la condición de la tabla. + +La operación es idempotente: si la secuencia ya fue avanzada (p.ej. en +un entorno que ya tenía inserciones orgánicas de categories), GREATEST +garantiza que no la retrocedemos. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = '9a104cdbdaed' +branch_labels = None +depends_on = None + + +def upgrade(): + # Adelantar la secuencia por encima del id máximo ya sembrado (6), + # para que el próximo INSERT orgánico comience desde id=7 como mínimo. + # GREATEST(MAX(id)+1, 7) es defensivo: si ya existen filas con id > 6, + # usamos ese valor; si la tabla solo tiene las filas sembradas (max=6), + # partimos de 7. + op.execute( + sa.text( + """ + SELECT setval( + pg_get_serial_sequence('categories', 'id'), + GREATEST((SELECT MAX(id) + 1 FROM categories), 7) + ) + """ + ) + ) + + +def downgrade(): + # No hay reversión significativa posible para un ajuste de secuencia: + # retroceder la secuencia podría causar colisiones con filas ya + # insertadas en producción. Se documenta explícitamente como decisión + # de diseño, no como omisión. + pass diff --git a/backend/migrations/versions/b048153bea47_add_password_changed_at_to_users.py b/backend/migrations/versions/b048153bea47_add_password_changed_at_to_users.py new file mode 100644 index 0000000..0e5126b --- /dev/null +++ b/backend/migrations/versions/b048153bea47_add_password_changed_at_to_users.py @@ -0,0 +1,33 @@ +"""add password_changed_at to users + +Revision ID: b048153bea47 +Revises: 3e1b8af1978c +Create Date: 2026-06-25 22:49:16.739540 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b048153bea47' +down_revision = '3e1b8af1978c' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column( + sa.Column( + 'password_changed_at', + sa.DateTime(), + nullable=True, + server_default=sa.func.now(), + ) + ) + + +def downgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('password_changed_at') diff --git a/backend/migrations/versions/beb2d2367ab1_add_file_size_bytes_to_backups.py b/backend/migrations/versions/beb2d2367ab1_add_file_size_bytes_to_backups.py new file mode 100644 index 0000000..4b32f6e --- /dev/null +++ b/backend/migrations/versions/beb2d2367ab1_add_file_size_bytes_to_backups.py @@ -0,0 +1,26 @@ +"""add_file_size_bytes_to_backups + +Revision ID: beb2d2367ab1 +Revises: 401c30988716 +Create Date: 2026-07-19 08:51:27.262260 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'beb2d2367ab1' +down_revision = '401c30988716' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('backups', schema=None) as batch_op: + batch_op.add_column(sa.Column('file_size_bytes', sa.BigInteger(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table('backups', schema=None) as batch_op: + batch_op.drop_column('file_size_bytes') diff --git a/backend/migrations/versions/c431b5f6ca10_add_unique_constraint_to_jobs_.py b/backend/migrations/versions/c431b5f6ca10_add_unique_constraint_to_jobs_.py new file mode 100644 index 0000000..f735184 --- /dev/null +++ b/backend/migrations/versions/c431b5f6ca10_add_unique_constraint_to_jobs_.py @@ -0,0 +1,32 @@ +"""add unique constraint to jobs description_hash + +Revision ID: c431b5f6ca10 +Revises: 9decfc36853f +Create Date: 2026-06-14 18:57:17.222212 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c431b5f6ca10' +down_revision = '9decfc36853f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('jobs', schema=None) as batch_op: + batch_op.create_unique_constraint(None, ['description_hash']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('jobs', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='unique') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/ce332628ddba_tighten_alert_threshold_constraint_to_.py b/backend/migrations/versions/ce332628ddba_tighten_alert_threshold_constraint_to_.py new file mode 100644 index 0000000..bf85f7c --- /dev/null +++ b/backend/migrations/versions/ce332628ddba_tighten_alert_threshold_constraint_to_.py @@ -0,0 +1,47 @@ +"""tighten alert threshold constraint to exclude cross-type values + +Revision ID: ce332628ddba +Revises: 33f2045987f9 +Create Date: 2026-07-17 16:44:51.356054 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'ce332628ddba' +down_revision = '33f2045987f9' +branch_labels = None +depends_on = None + + +def upgrade(): + # Reemplazamos el constraint existente por una version mas estricta que + # exige explicitamente que el campo del OTRO tipo sea NULL, previniendo + # que una fila tenga ambos campos poblados simultaneamente (DT-17). + op.drop_constraint( + 'chk_alerts_threshold_matches_type', + 'user_alerts', + type_='check', + ) + op.create_check_constraint( + 'chk_alerts_threshold_matches_type', + 'user_alerts', + "(alert_type = 'ABSOLUTE' AND threshold_value IS NOT NULL AND threshold_percentage IS NULL) OR " + "(alert_type = 'TREND' AND threshold_percentage IS NOT NULL AND threshold_value IS NULL)", + ) + + +def downgrade(): + # Restaura la version anterior del constraint (sin la restriccion de NULL cruzado). + op.drop_constraint( + 'chk_alerts_threshold_matches_type', + 'user_alerts', + type_='check', + ) + op.create_check_constraint( + 'chk_alerts_threshold_matches_type', + 'user_alerts', + "(alert_type = 'ABSOLUTE' AND threshold_value IS NOT NULL) OR " + "(alert_type = 'TREND' AND threshold_percentage IS NOT NULL)", + ) diff --git a/backend/migrations/versions/e269761308d8_add_remote_column_to_jobs_table.py b/backend/migrations/versions/e269761308d8_add_remote_column_to_jobs_table.py new file mode 100644 index 0000000..03997d4 --- /dev/null +++ b/backend/migrations/versions/e269761308d8_add_remote_column_to_jobs_table.py @@ -0,0 +1,32 @@ +"""add remote column to jobs table + +Revision ID: e269761308d8 +Revises: a1b2c3d4e5f6 +Create Date: 2026-07-30 21:24:09.402072 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e269761308d8' +down_revision = 'a1b2c3d4e5f6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('jobs', schema=None) as batch_op: + batch_op.add_column(sa.Column('remote', sa.Boolean(), server_default=sa.text('false'), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('jobs', schema=None) as batch_op: + batch_op.drop_column('remote') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/e89b2bf6614f_enable_unaccent_extension_and_add_.py b/backend/migrations/versions/e89b2bf6614f_enable_unaccent_extension_and_add_.py new file mode 100644 index 0000000..804bcd0 --- /dev/null +++ b/backend/migrations/versions/e89b2bf6614f_enable_unaccent_extension_and_add_.py @@ -0,0 +1,25 @@ +"""enable unaccent extension and add functional index on cities name + +Revision ID: e89b2bf6614f +Revises: e269761308d8 +Create Date: 2026-07-31 17:18:00.737000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e89b2bf6614f' +down_revision = 'e269761308d8' +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("CREATE EXTENSION IF NOT EXISTS unaccent;") + + +def downgrade(): + op.execute("DROP EXTENSION IF EXISTS unaccent;") + \ No newline at end of file diff --git a/backend/migrations/versions/ee1b7b2d2464_harden_jobs_created_at_not_null.py b/backend/migrations/versions/ee1b7b2d2464_harden_jobs_created_at_not_null.py new file mode 100644 index 0000000..edfb442 --- /dev/null +++ b/backend/migrations/versions/ee1b7b2d2464_harden_jobs_created_at_not_null.py @@ -0,0 +1,36 @@ +"""harden jobs created_at not null + +Revision ID: ee1b7b2d2464 +Revises: 03ff0fc315b6 +Create Date: 2026-07-12 21:57:05.481524 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'ee1b7b2d2464' +down_revision = '03ff0fc315b6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('jobs', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('jobs', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + + # ### end Alembic commands ### diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..08afca8 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,19 @@ +# Incluimos todas las dependencias de producción +-r requirements.txt + +# Pruebas +pytest>=8.0,<9.0 +pytest-flask>=1.3,<2.0 +pytest-cov>=6.0,<7.0 +Faker>=30.0,<31.0 + +# Calidad de código +flake8>=7.0,<8.0 +black>=25.0,<26.0 +isort>=5.13,<6.0 + +# Análisis de seguridad estático +bandit>=1.8,<2.0 + +# Cliente HTTP para pruebas manuales de la API +httpie>=3.2,<4.0 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..5b500ef --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,48 @@ +# Framework principal +Flask>=3.1,<4.0 +Flask-SQLAlchemy>=3.1,<4.0 +Flask-JWT-Extended>=4.7,<5.0 +Flask-CORS>=5.0,<6.0 +Flask-Migrate>=4.0,<5.0 + +# Conexión con PostgreSQL +psycopg2-binary>=2.9,<3.0 + +# Variables de entorno +python-dotenv>=1.0,<2.0 + +# Cifrado de contraseñas +bcrypt>=4.2,<5.0 + +# Validación de datos de entrada +marshmallow>=3.23,<4.0 + +# Programación de tareas automáticas +APScheduler>=3.10,<4.0 + +# Peticiones HTTP a APIs externas +requests>=2.32,<3.0 + +# Procesamiento de lenguaje natural +spacy>=3.8,<4.0 + +# Procesamiento y análisis de datos +pandas>=2.2,<3.0 + +# Cliente S3-compatible para almacenamiento remoto de respaldos (Cloudflare R2) +boto3>=1.35,<2.0 + +# Servidor WSGI para producción +gunicorn>=23.0,<24.0 + +# Verificación de tokens de identidad de Google OAuth +google-auth + +resend==2.32.2 + +# Limitacion de tasa de peticiones +Flask-Limiter>=3.8,<4.0 + +# Testing +pytest>=8.0,<9.0 +pytest-flask>=1.3,<2.0 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..2a8b5b3 --- /dev/null +++ b/backend/run.py @@ -0,0 +1,71 @@ +import os +import click +from dotenv import load_dotenv + +load_dotenv() + +from app import create_app + +app = create_app(os.getenv("FLASK_ENV", "development")) + +if __name__ == "__main__": + port = int(os.getenv("PORT", 5000)) + + # La bandera de depuración se deriva estrictamente del entorno para prevenir la exposición de trazas de ejecución en entornos de producción + debug_mode = os.getenv("FLASK_ENV") == "development" + + app.run(host="0.0.0.0", port=port, debug=debug_mode) + + +@app.cli.command("ingest-jobs") +@click.option("--pages", default=1, show_default=True, help="Número de páginas de Adzuna a consumir (50 vacantes por página).") +@click.option("--what", default="software developer", show_default=True, help="Término de búsqueda enviado a Adzuna.") +@click.option("--country", default="mx", show_default=True, help="Código de país ISO para la búsqueda en Adzuna.") +def ingest_jobs(pages, what, country): + """Dispara la ingesta completa de vacantes desde Adzuna con geocodificación via Nominatim""" + import sys + # Forzar UTF-8 en Windows para evitar UnicodeEncodeError con cp1252 al imprimir acentos o emojis + if sys.stdout.encoding.lower() != 'utf-8': + sys.stdout.reconfigure(encoding='utf-8') + + from app.services.ingestion_service import IngestionService + + click.echo(f"Iniciando ingesta: country={country}, what='{what}', pages={pages}") + click.echo("-" * 60) + + stats = IngestionService.run_ingestion(country=country, what=what, pages=pages, verbose=True) + + click.echo("-" * 60) + click.echo("Resumen de ingesta:") + click.echo(f" Vacantes recibidas de Adzuna : {stats['fetched']}") + click.echo(f" Vacantes guardadas : {stats['processed']}") + click.echo(f" Duplicados (hash repetido) : {stats['duplicates']}") + click.echo(f" Errores reales : {stats['errors']}") + click.echo(f" Ciudades nuevas insertadas : {stats['cities_created']}") + click.echo(f" Fallback a Mexico Nacional : {stats['fallback']}") + + +@app.cli.command("generate-snapshots") +def generate_snapshots_cmd(): + """Recalcula los TrendSnapshots analíticos a partir de los JobSkills clasificados. Debe ejecutarse después de clasificar las vacantes ingeridas""" + import sys + if sys.stdout.encoding.lower() != 'utf-8': + sys.stdout.reconfigure(encoding='utf-8') + + from app.services.market_trends_service import MarketTrendsService + + count = MarketTrendsService.generate_snapshots() + click.echo(f"Snapshots generados: {count}") + + +@app.cli.command("evaluate-alerts") +def evaluate_alerts_cmd(): + """Evalúa todas las alertas activas contra los snapshots más recientes y envía notificaciones por correo a los usuarios cuyo umbral fue superado""" + import sys + if sys.stdout.encoding.lower() != 'utf-8': + sys.stdout.reconfigure(encoding='utf-8') + + from app.services.alerts_service import AlertsService + + sent = AlertsService.evaluate_and_notify() + click.echo(f"Notificaciones enviadas: {sent}") \ No newline at end of file diff --git a/backend/scheduler/__init__.py b/backend/scheduler/__init__.py new file mode 100644 index 0000000..b312ccd --- /dev/null +++ b/backend/scheduler/__init__.py @@ -0,0 +1 @@ +# scheduler/__init__ — SkillStat \ No newline at end of file diff --git a/backend/scheduler/jobs.py b/backend/scheduler/jobs.py new file mode 100644 index 0000000..5248dd5 --- /dev/null +++ b/backend/scheduler/jobs.py @@ -0,0 +1,9 @@ +from app.services.market_trends_service import MarketTrendsService +from app.services.alerts_service import AlertsService + + +def daily_pipeline(app): + # Recibimos la instancia concreta de app en lugar de usar el proxy current_app porque APScheduler ejecuta este job en un hilo separado donde el proxy no tiene contexto activo garantizado. + with app.app_context(): + MarketTrendsService.generate_snapshots() + AlertsService.evaluate_and_notify() \ No newline at end of file diff --git a/backend/scripts/backfill_remote_flag.py b/backend/scripts/backfill_remote_flag.py new file mode 100644 index 0000000..5e9133f --- /dev/null +++ b/backend/scripts/backfill_remote_flag.py @@ -0,0 +1,74 @@ +import os +import click +from dotenv import load_dotenv + +# Asegurar que estamos en el directorio base correcto para cargar .env si es necesario +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +env_path = os.path.join(BASE_DIR, ".env") +if os.path.exists(env_path): + load_dotenv(env_path) + +from app import create_app +from app.extensions import db +from app.models.job import Job +from app.services.ingestion_service import IngestionService + +@click.command() +@click.option('--execute', is_flag=True, help="Ejecutar los cambios en la base de datos (por defecto es dry-run)") +def backfill_remote_flag(execute): + """ + Recalcula el campo 'remote' de todos los jobs existentes usando la nueva lógica de IngestionService. Por defecto corre en modo dry-run (solo reporta). Usa --execute para aplicar los cambios. """ + app = create_app(os.getenv("FLASK_ENV", "development")) + + with app.app_context(): + print(f"Iniciando backfill de flag 'remote' (Modo: {'EXECUTE' if execute else 'DRY-RUN'})") + print("-" * 60) + + jobs = db.session.execute(db.select(Job)).scalars().all() + + total_jobs = len(jobs) + changed_to_true = 0 + changed_to_false = 0 + unchanged = 0 + errors = 0 + + for job in jobs: + try: + # El título o descripción pueden ser None en la BD? Según los modelos y el schema, title y description suelen ser strings, pero por precaución: + title = job.title or "" + description = job.raw_description or "" + + new_remote = IngestionService._compute_is_remote(title, description) + + if new_remote != job.remote: + if new_remote is True: + changed_to_true += 1 + else: + changed_to_false += 1 + + if execute: + job.remote = new_remote + db.session.commit() + print(f"Actualizado Job ID {job.id}: remote -> {new_remote}") + else: + unchanged += 1 + except Exception as e: + errors += 1 + print(f"Error procesando Job ID {job.id}: {e}") + if execute: + db.session.rollback() + + print("-" * 60) + print("Resumen del backfill:") + print(f" Total de jobs analizados : {total_jobs}") + print(f" Cambiaron a True : {changed_to_true}") + print(f" Cambiaron a False : {changed_to_false}") + print(f" Sin cambios : {unchanged}") + print(f" Errores : {errors}") + + if not execute: + print("\nNOTA: Ejecución en modo DRY-RUN. No se guardaron cambios en la base de datos.") + print(" Para aplicar los cambios, ejecuta el script con el flag --execute.") + +if __name__ == '__main__': + backfill_remote_flag() diff --git a/backend/scripts/build_dictionary.py b/backend/scripts/build_dictionary.py new file mode 100644 index 0000000..89691c7 --- /dev/null +++ b/backend/scripts/build_dictionary.py @@ -0,0 +1,46 @@ +import json +import os + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DICT_DIR = os.path.join(BASE_DIR, "data", "dictionaries") +OUTPUT_FILE = os.path.join(DICT_DIR, "skills_esco.jsonl") + +CORE_SKILLS = [ + "Python", "JavaScript", "TypeScript", "Java", "C#", "C++", "Ruby", + "PHP", "Go", "Rust", "Swift", "Kotlin", + "React", "Angular", "Vue.js", "Node.js", "Express", "Django", "Flask", + "FastAPI", "Spring Boot", ".NET", + "SQL", "MySQL", "PostgreSQL", "MongoDB", "SQLite", "NoSQL", "Redis", + "Cassandra", "Elasticsearch", + "AWS", "Azure", "Google Cloud", "GCP", "Docker", "Kubernetes", + "Terraform", "Jenkins", "CI/CD", "Linux", + "Machine Learning", "Data Science", "Artificial Intelligence", "NLP", + "Deep Learning", "TensorFlow", "PyTorch", "Pandas", "NumPy", + "Scikit-learn", + "Git", "GitHub", "GitLab", "Bitbucket", + "Agile", "Scrum", "Jira", "Figma", + "HTML", "CSS", "Sass", "Tailwind", "Bootstrap", + "GraphQL", "REST API", "Microservices", +] + + +def build_dictionary(): + os.makedirs(DICT_DIR, exist_ok=True) + print(f"Construyendo diccionario NLP en: {OUTPUT_FILE}") + + patterns = [] + for skill in CORE_SKILLS: + # Agregamos el patrón original y su variante en minúsculas para que el EntityRuler capture la habilidad sin importar cómo la escriba la bolsa de trabajo en la descripción de la vacante. + patterns.append({"label": "SKILL", "pattern": skill}) + if skill != skill.lower(): + patterns.append({"label": "SKILL", "pattern": skill.lower()}) + + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: + for entry in patterns: + f.write(json.dumps(entry) + "\n") + + print(f"Exito: {len(CORE_SKILLS)} habilidades base → {len(patterns)} patrones exportados.") + + +if __name__ == "__main__": + build_dictionary() diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..a50021c --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +# tests/__init__ — SkillStat \ No newline at end of file diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..88d09a4 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,47 @@ +import os +import pytest +from dotenv import load_dotenv + +# Cargamos el archivo .env primero +load_dotenv() + +os.environ["RESEND_API_KEY"] = "test-resend-key" + +from app import create_app +from app.extensions import db as _db + +@pytest.fixture(scope="session") +def app(): + """ Crea la aplicación Flask configurada para testing """ + app = create_app("testing") + with app.app_context(): + yield app + +import sqlalchemy as sa + +@pytest.fixture(scope="function") +def db_session(app): + """ Crea una sesión de base de datos aislada para cada test. Usa el patrón de savepoints anidados para soportar commits internos """ + connection = _db.engine.connect() + transaction = connection.begin() + + import sqlalchemy as sa + raw_session = sa.orm.Session(bind=connection, join_transaction_mode="create_savepoint") + + """ Guardamos la sesion original para restaurarla al finalizar. Si no la restauramos, db.session queda apuntando a una conexion ya cerrada, lo que rompe cualquier test posterior que use db.session directamente en vez del fixture (como test_backup_restore_schema_sync) """ + original_session = _db.session + _db.session = sa.orm.scoped_session(lambda: raw_session) + + yield _db.session + + _db.session.remove() + transaction.rollback() + connection.close() + _db.session = original_session + + +@pytest.fixture(autouse=True) +def _cleanup_db_session_after_test(app): + # No abrimos un app_context nuevo aqui; la fixture app (scope session) ya mantiene uno activo durante toda la suite. Abrir uno anidado crea un scope distinto y limpia la sesion equivocada, dejando intacta la conexion real que se abrio durante el test. + yield + _db.session.remove() \ No newline at end of file diff --git a/backend/tests/fixtures/sample_jobs.json b/backend/tests/fixtures/sample_jobs.json new file mode 100644 index 0000000..ad47dbb --- /dev/null +++ b/backend/tests/fixtures/sample_jobs.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/backend/tests/fixtures/sample_skills.json b/backend/tests/fixtures/sample_skills.json new file mode 100644 index 0000000..ad47dbb --- /dev/null +++ b/backend/tests/fixtures/sample_skills.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..580d391 --- /dev/null +++ b/backend/tests/integration/__init__.py @@ -0,0 +1 @@ +# integration/__init__ — SkillStat \ No newline at end of file diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py new file mode 100644 index 0000000..b4b9d95 --- /dev/null +++ b/backend/tests/integration/conftest.py @@ -0,0 +1,8 @@ +import pytest + +""" test_backup_restore_schema_sync invoca pg_restore, que destruye y reemplaza la base de datos completa. Esto deja la conexion SQLAlchemy del fixture db_session (scope="session") en un estado invalido (ResourceClosedError) para cualquier test que corra despues en la misma sesion de pytest. Por eso forzamos que ese modulo sea el ultimo en ejecutarse dentro de integration """ +def pytest_collection_modifyitems(items): + LAST_MODULE = "test_backup_restore_schema_sync" + regular = [i for i in items if LAST_MODULE not in i.nodeid] + deferred = [i for i in items if LAST_MODULE in i.nodeid] + items[:] = regular + deferred diff --git a/backend/tests/integration/test_alerts_endpoints.py b/backend/tests/integration/test_alerts_endpoints.py new file mode 100644 index 0000000..cc78892 --- /dev/null +++ b/backend/tests/integration/test_alerts_endpoints.py @@ -0,0 +1,461 @@ +import pytest +from flask_jwt_extended import create_access_token, get_csrf_token + +from app.models.user import User +from app.models.skill import Skill +from app.models.category import Category +from app.models.alert import Alert + + +# Helpers de autenticacion y creacion de entidades. +# Duplicados deliberadamente aqui (principio DAMP): cada archivo de tests es autocontenido. Importar desde test_alerts_service.py crearía acoplamiento entre archivos de tests y violaría el aislamiento que DAMP busca preservar. Si en el futuro la suite crece lo suficiente como para que la duplicacion sea un problema de mantenimiento real, se puede extraer a un modulo tests/helpers.py; esa decision queda pendiente. + +def _mint_token_and_csrf(user_id): + """ Genera un token de acceso real y extrae su CSRF. Debe invocarse dentro del contexto de la aplicacion (app.app_context()) """ + token = create_access_token(identity=str(user_id)) + csrf = get_csrf_token(token) + return token, csrf + + +def _make_category(db_session, name="Programacion"): + cat = Category(name=name) + db_session.add(cat) + db_session.flush() + return cat + + +def _make_skill(db_session, category_id, name="Python"): + skill = Skill(name=name, canonical_name=name.lower(), category_id=category_id) + db_session.add(skill) + db_session.flush() + return skill + + +def _make_user(db_session, email="user@example.com"): + user = User( + email=email, + first_name="Test", + last_name="User", + password_hash="dummy", + role="REGISTERED", + is_active=True, + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +def _make_alert(db_session, user_id, skill_id, alert_type="ABSOLUTE", + threshold_value=50, threshold_percentage=None, active=True): + alert = Alert( + user_id=user_id, + skill_id=skill_id, + alert_type=alert_type, + threshold_value=threshold_value, + threshold_percentage=threshold_percentage, + active=active, + ) + db_session.add(alert) + db_session.commit() + db_session.refresh(alert) + return alert + + +# Tests: POST /api/alerts/ + +def test_create_alert_success(app, db_session, client): + """ POST a /api/alerts/ con payload ABSOLUTE valido. Verificar 201 y que la alerta queda en BD con user_id del token autenticado """ + cat = _make_category(db_session, name="Cat_Create") + skill = _make_skill(db_session, cat.id, name="Skill_Create") + user = _make_user(db_session, email="create_ok@example.com") + # Capturamos IDs como escalares antes del request para evitar DetachedInstanceError: el request cycle del Flask test client expira las instancias ORM de la sesion. + skill_id = skill.id + user_id = user.id + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user_id) + + client.set_cookie("access_token_cookie", token) + payload = {"skill_id": skill_id, "alert_type": "ABSOLUTE", "threshold_value": 100} + response = client.post("/api/alerts/", json=payload, headers={"X-CSRF-TOKEN": csrf}) + + assert response.status_code == 201 + data = response.get_json() + assert data["data"]["skill_id"] == skill_id + assert data["data"]["alert_type"] == "ABSOLUTE" + + # Verificamos en BD que el user_id persisted es el del token, no cualquier valor externo. + alert_id = data["data"]["id"] + persisted = db_session.get(Alert, alert_id) + assert persisted is not None + assert persisted.user_id == user_id + assert persisted.threshold_value == 100 + + +def test_create_alert_rejects_spoofed_user_id_with_422(app, db_session, client): + """ Marshmallow 3 usa Unknown=RAISE por defecto: si el payload incluye un campo no declarado en AlertRequestSchema (como user_id), el schema lo rechazacon ValidationError antes de que el endpoint ejecute ninguna logica. Esto protege contra asignacion cruzada de forma mas robusta aun que sobreescribir el campo post-validacion: el atacante recibe 422 y la alerta nunca llega a crearse. Verificamos ese comportamiento real """ + cat = _make_category(db_session, name="Cat_Spoof") + skill = _make_skill(db_session, cat.id, name="Skill_Spoof") + user = _make_user(db_session, email="spoof@example.com") + skill_id = skill.id + user_id = user.id + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user_id) + + client.set_cookie("access_token_cookie", token) + # Incluimos user_id=99999 en el payload como intento de asignacion cruzada. + payload = { + "skill_id": skill_id, + "alert_type": "ABSOLUTE", + "threshold_value": 50, + "user_id": 99999, + } + response = client.post("/api/alerts/", json=payload, headers={"X-CSRF-TOKEN": csrf}) + + # Marshmallow 3 rechaza el campo desconocido con 422 VALIDATION_ERROR. El endpoint nunca crea la alerta, lo que protege contra asignacion cruzada. + assert response.status_code == 422 + assert response.get_json()["error"]["code"] == "VALIDATION_ERROR" + + + +def test_create_alert_invalid_payload_returns_422(app, db_session, client): + """ POST con payload invalido: ABSOLUTE sin threshold_value. Verificar 422 con code VALIDATION_ERROR, tal como el schema lo define """ + user = _make_user(db_session, email="invalid_payload@example.com") + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user.id) + + client.set_cookie("access_token_cookie", token) + # ABSOLUTE requiere threshold_value, que aqui se omite deliberadamente. + payload = {"skill_id": 1, "alert_type": "ABSOLUTE"} + response = client.post("/api/alerts/", json=payload, headers={"X-CSRF-TOKEN": csrf}) + + assert response.status_code == 422 + assert response.get_json()["error"]["code"] == "VALIDATION_ERROR" + + +# Tests: GET /api/alerts/ + +def test_get_alerts_returns_only_own_alerts(app, db_session, client): + """ Dos usuarios con alertas propias. El primero autentica. GET /api/alerts/ debe retornar SOLO sus alertas, nunca las del segundo usuario """ + cat = _make_category(db_session, name="Cat_Get") + skill = _make_skill(db_session, cat.id, name="Skill_Get") + skill_id = skill.id + + user1 = _make_user(db_session, email="get_user1@example.com") + user2 = _make_user(db_session, email="get_user2@example.com") + user1_id = user1.id + + alert1 = _make_alert(db_session, user1_id, skill_id, threshold_value=10) + alert2 = _make_alert(db_session, user2.id, skill_id, threshold_value=20) + alert1_id = alert1.id + alert2_id = alert2.id + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user1_id) + + client.set_cookie("access_token_cookie", token) + response = client.get("/api/alerts/", headers={"X-CSRF-TOKEN": csrf}) + + assert response.status_code == 200 + returned_ids = {a["id"] for a in response.get_json()["data"]} + assert alert1_id in returned_ids + assert alert2_id not in returned_ids + + +# Tests: DELETE /api/alerts/ + +def test_delete_alert_success(app, db_session, client): + """ DELETE a la alerta propia. Verificar 200 y que la alerta ya no existe en BD tras la operacion """ + cat = _make_category(db_session, name="Cat_Del") + skill = _make_skill(db_session, cat.id, name="Skill_Del") + user = _make_user(db_session, email="delete_ok@example.com") + alert = _make_alert(db_session, user.id, skill.id) + user_id = user.id + alert_id = alert.id + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user_id) + + client.set_cookie("access_token_cookie", token) + response = client.delete( + f"/api/alerts/{alert_id}", headers={"X-CSRF-TOKEN": csrf} + ) + + assert response.status_code == 200 + assert response.get_json()["data"]["deleted"] is True + + # Verificamos en BD que la alerta ya no existe. + db_session.expire_all() + persisted = db_session.get(Alert, alert_id) + assert persisted is None + + +def test_delete_alert_not_owned_returns_404(app, db_session, client): + """ Dos usuarios. El segundo tiene una alerta. El primero intenta DELETE sobre esa alerta. Debe recibir 404 (no 403), y la alerta del segundo debe seguir existiendo en BD """ + cat = _make_category(db_session, name="Cat_NotOwned") + skill = _make_skill(db_session, cat.id, name="Skill_NotOwned") + skill_id = skill.id + + user1 = _make_user(db_session, email="not_owned_u1@example.com") + user2 = _make_user(db_session, email="not_owned_u2@example.com") + user1_id = user1.id + alert_of_user2 = _make_alert(db_session, user2.id, skill_id) + alert_of_user2_id = alert_of_user2.id + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user1_id) + + client.set_cookie("access_token_cookie", token) + response = client.delete( + f"/api/alerts/{alert_of_user2_id}", headers={"X-CSRF-TOKEN": csrf} + ) + + assert response.status_code == 404 + assert response.get_json()["error"]["code"] == "NOT_FOUND" + + # La alerta del usuario 2 debe seguir intacta en BD. + db_session.expire_all() + still_exists = db_session.get(Alert, alert_of_user2_id) + assert still_exists is not None + + +def test_delete_alert_nonexistent_returns_404(app, db_session, client): + """ DELETE a un ID que no existe en absoluto. Debe retornar 404 """ + user = _make_user(db_session, email="del_nonexist@example.com") + user_id = user.id + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user_id) + + client.set_cookie("access_token_cookie", token) + response = client.delete("/api/alerts/999999", headers={"X-CSRF-TOKEN": csrf}) + + assert response.status_code == 404 + assert response.get_json()["error"]["code"] == "NOT_FOUND" + + +# Tests: PATCH /api/alerts//status + +def test_deactivate_alert_success(app, db_session, client): + """ Usuario con una alerta propia, active=True. PATCH con active=False. Verificar 200 y en BD active=False """ + cat = _make_category(db_session, name="Cat_Deactivate") + skill = _make_skill(db_session, cat.id, name="Skill_Deactivate") + user = _make_user(db_session, email="deactivate_ok@example.com") + alert = _make_alert(db_session, user.id, skill.id, active=True) + alert_id = alert.id + user_id = user.id + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user_id) + + client.set_cookie("access_token_cookie", token) + response = client.patch( + f"/api/alerts/{alert_id}/status", + json={"active": False}, + headers={"X-CSRF-TOKEN": csrf} + ) + + assert response.status_code == 200 + assert response.get_json()["data"]["active"] is False + + db_session.expire_all() + persisted = db_session.get(Alert, alert_id) + assert persisted.active is False + + +def test_reactivate_alert_success(app, db_session, client): + """ Usuario con alerta ya desactivada. PATCH con active=True. Verificar 200 y en BD active=True """ + cat = _make_category(db_session, name="Cat_Reactivate") + skill = _make_skill(db_session, cat.id, name="Skill_Reactivate") + user = _make_user(db_session, email="reactivate_ok@example.com") + alert = _make_alert(db_session, user.id, skill.id, active=False) + alert_id = alert.id + user_id = user.id + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user_id) + + client.set_cookie("access_token_cookie", token) + response = client.patch( + f"/api/alerts/{alert_id}/status", + json={"active": True}, + headers={"X-CSRF-TOKEN": csrf} + ) + + assert response.status_code == 200 + assert response.get_json()["data"]["active"] is True + + db_session.expire_all() + persisted = db_session.get(Alert, alert_id) + assert persisted.active is True + + +def test_update_status_missing_active_field_returns_422(app, db_session, client): + """ PATCH con payload vacio {}. Verificar 422 VALIDATION_ERROR """ + cat = _make_category(db_session, name="Cat_MissingActive") + skill = _make_skill(db_session, cat.id, name="Skill_MissingActive") + user = _make_user(db_session, email="missing_active@example.com") + alert = _make_alert(db_session, user.id, skill.id) + alert_id = alert.id + user_id = user.id + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user_id) + + client.set_cookie("access_token_cookie", token) + response = client.patch( + f"/api/alerts/{alert_id}/status", + json={}, + headers={"X-CSRF-TOKEN": csrf} + ) + + assert response.status_code == 422 + assert response.get_json()["error"]["code"] == "VALIDATION_ERROR" + + +def test_update_status_not_owned_returns_404(app, db_session, client): + """ Intento de modificar estado de alerta ajena. Verificar 404 NOT_FOUND y que estado no cambia """ + cat = _make_category(db_session, name="Cat_PatchNotOwned") + skill = _make_skill(db_session, cat.id, name="Skill_PatchNotOwned") + user1 = _make_user(db_session, email="patch_not_owned1@example.com") + user2 = _make_user(db_session, email="patch_not_owned2@example.com") + + # Alerta de user2, activa por defecto + alert2 = _make_alert(db_session, user2.id, skill.id, active=True) + alert2_id = alert2.id + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user1.id) + + client.set_cookie("access_token_cookie", token) + response = client.patch( + f"/api/alerts/{alert2_id}/status", + json={"active": False}, + headers={"X-CSRF-TOKEN": csrf} + ) + + assert response.status_code == 404 + assert response.get_json()["error"]["code"] == "NOT_FOUND" + + db_session.expire_all() + persisted = db_session.get(Alert, alert2_id) + assert persisted.active is True + + +def test_update_status_nonexistent_alert_returns_404(app, db_session, client): + """ PATCH a ID inexistente retorna 404 """ + user = _make_user(db_session, email="patch_nonexist@example.com") + user_id = user.id + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user_id) + + client.set_cookie("access_token_cookie", token) + response = client.patch( + "/api/alerts/999999/status", + json={"active": False}, + headers={"X-CSRF-TOKEN": csrf} + ) + + assert response.status_code == 404 + assert response.get_json()["error"]["code"] == "NOT_FOUND" + + +def _make_snapshot(db_session, skill_id, demand_count=None, growth_rate=None, + snap_date=None, city_id=None): + from app.models.trend_snapshot import TrendSnapshot + from datetime import date + snap = TrendSnapshot( + skill_id=skill_id, + city_id=city_id, + date=snap_date or date(2025, 1, 1), + demand_count=demand_count, + growth_rate=growth_rate, + ) + db_session.add(snap) + db_session.flush() + return snap + + +def test_deactivated_alert_is_excluded_from_evaluation(app, db_session, client, monkeypatch): + """ Confirmar que PATCH /status conecta con la exclusión de AlertsService.evaluate_and_notify() """ + from unittest.mock import MagicMock + from app.services.alerts_service import AlertsService + from datetime import date + from app.models.trend_snapshot import TrendSnapshot + + mock_send = MagicMock() + monkeypatch.setattr("app.services.alerts_service.send_alert_email", mock_send) + + cat = _make_category(db_session, name="Cat_EndToEnd") + skill = _make_skill(db_session, cat.id, name="Skill_EndToEnd") + user = _make_user(db_session, email="end_to_end@example.com") + + # Snapshot que cumple sobradamente el threshold (100 >= 50) + _make_snapshot(db_session, skill.id, demand_count=100, snap_date=date(2025, 1, 2)) + alert = _make_alert(db_session, user.id, skill.id, alert_type="ABSOLUTE", threshold_value=50, active=True) + alert_id = alert.id + user_id = user.id + + # Comprobamos que con active=True la alerta SI se dispara + with app.app_context(): + result_active = AlertsService.evaluate_and_notify() + + assert result_active == 1 + mock_send.assert_called_once() + mock_send.reset_mock() + + # Desactivamos via el endpoint REST + with app.app_context(): + token, csrf = _mint_token_and_csrf(user_id) + + client.set_cookie("access_token_cookie", token) + patch_response = client.patch( + f"/api/alerts/{alert_id}/status", + json={"active": False}, + headers={"X-CSRF-TOKEN": csrf} + ) + assert patch_response.status_code == 200 + + # Comprobamos que al estar desactivada ya NO se dispara + with app.app_context(): + result_inactive = AlertsService.evaluate_and_notify() + + assert result_inactive == 0 + mock_send.assert_not_called() + + +def test_create_alert_rejects_when_active_limit_reached(app, db_session, client): + """ POST a /api/alerts/ cuando el usuario ya tiene 20 alertas activas. Verificar 422 LIMIT_EXCEEDED y que la alerta 21 no se crea """ + from sqlalchemy import select + + cat = _make_category(db_session, name="Cat_Limit") + skill = _make_skill(db_session, cat.id, name="Skill_Limit") + user = _make_user(db_session, email="limit_reached@example.com") + skill_id = skill.id + user_id = user.id + + # Crear 20 alertas activas + for _ in range(20): + _make_alert(db_session, user_id, skill_id) + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user_id) + + client.set_cookie("access_token_cookie", token) + + # Intentar crear la alerta 21 + payload = {"skill_id": skill_id, "alert_type": "ABSOLUTE", "threshold_value": 100} + response = client.post("/api/alerts/", json=payload, headers={"X-CSRF-TOKEN": csrf}) + + assert response.status_code == 422 + assert response.get_json()["error"]["code"] == "LIMIT_EXCEEDED" + assert response.get_json()["error"]["message"] == "Has alcanzado el límite de 20 alertas activas." + + # Verificar que las alertas en BD sigan siendo exactamente 20 + db_session.expire_all() + user_alerts = db_session.execute(select(Alert).filter_by(user_id=user_id)).scalars().all() + assert len(user_alerts) == 20 \ No newline at end of file diff --git a/backend/tests/integration/test_alerts_service.py b/backend/tests/integration/test_alerts_service.py new file mode 100644 index 0000000..1c17583 --- /dev/null +++ b/backend/tests/integration/test_alerts_service.py @@ -0,0 +1,219 @@ +import pytest +from decimal import Decimal +from datetime import date +from unittest.mock import MagicMock + +from app.models.user import User +from app.models.skill import Skill +from app.models.category import Category +from app.models.alert import Alert +from app.models.trend_snapshot import TrendSnapshot +from app.services.alerts_service import AlertsService +from app.utils.errors import AppError + + +""" Helpers de creacion de entidades. +Creamos Category inline en cada test (principio DAMP: cada test es autocontenido). No existe fixture de seed reutilizable en el proyecto para Category, y el modelo solo requiere name unico, por lo que es mas claro y directo crearla inline. Usamos nombres distintos por test para evitar conflictos de unicidad entre tests que corran en la misma transaccion """ + + +def _make_category(db_session, name="Programacion"): + cat = Category(name=name) + db_session.add(cat) + db_session.flush() + return cat + + +def _make_skill(db_session, category_id, name="Python"): + skill = Skill(name=name, canonical_name=name.lower(), category_id=category_id) + db_session.add(skill) + db_session.flush() + return skill + + +def _make_user(db_session, email="user@example.com"): + user = User( + email=email, + first_name="Test", + last_name="User", + password_hash="dummy", + role="REGISTERED", + is_active=True, + ) + db_session.add(user) + db_session.flush() + return user + + +def _make_snapshot(db_session, skill_id, demand_count=None, growth_rate=None, + snap_date=None, city_id=None): + snap = TrendSnapshot( + skill_id=skill_id, + city_id=city_id, + date=snap_date or date(2025, 1, 1), + demand_count=demand_count, + growth_rate=growth_rate, + ) + db_session.add(snap) + db_session.flush() + return snap + + +def _make_alert(db_session, user_id, skill_id, alert_type, threshold_value=None, + threshold_percentage=None, active=True): + alert = Alert( + user_id=user_id, + skill_id=skill_id, + alert_type=alert_type, + threshold_value=threshold_value, + threshold_percentage=threshold_percentage, + active=active, + ) + db_session.add(alert) + db_session.commit() + return alert + + +# Tests + +def test_inactive_alerts_are_never_evaluated(app, db_session, monkeypatch): + """ AlertRepository.get_all() sin filtrar active=True evaluaba alertas que el usuario ya habia desactivado, potencialmente enviando notificaciones no deseadas. Verificamos que una alerta inactiva con un threshold que claramente se cumpliria nunca genera ninguna notificacion """ + mock_send = MagicMock() + monkeypatch.setattr("app.services.alerts_service.send_alert_email", mock_send) + + cat = _make_category(db_session, name="Cat_Inactive") + skill = _make_skill(db_session, cat.id, name="Skill_Inactive") + user = _make_user(db_session, email="inactive_alert@example.com") + _make_snapshot(db_session, skill.id, demand_count=9999, snap_date=date(2025, 1, 1)) + _make_alert(db_session, user.id, skill.id, alert_type="ABSOLUTE", + threshold_value=1, active=False) + + with app.app_context(): + result = AlertsService.evaluate_and_notify() + + assert result == 0 + mock_send.assert_not_called() + + +def test_absolute_alert_triggers_when_threshold_met(app, db_session, monkeypatch): + """ Una alerta ABSOLUTE activa debe dispararse cuando demand_count del snapshot mas reciente supera o iguala threshold_value """ + mock_send = MagicMock() + monkeypatch.setattr("app.services.alerts_service.send_alert_email", mock_send) + + cat = _make_category(db_session, name="Cat_AbsTrue") + skill = _make_skill(db_session, cat.id, name="Skill_AbsTrue") + user = _make_user(db_session, email="abs_trigger@example.com") + _make_snapshot(db_session, skill.id, demand_count=100, snap_date=date(2025, 1, 2)) + _make_alert(db_session, user.id, skill.id, alert_type="ABSOLUTE", threshold_value=50) + + with app.app_context(): + result = AlertsService.evaluate_and_notify() + + assert result == 1 + mock_send.assert_called_once() + call_args = mock_send.call_args[0] + assert call_args[0] == user.email + + +def test_absolute_alert_does_not_trigger_below_threshold(app, db_session, monkeypatch): + """ Una alerta ABSOLUTE no debe dispararse cuando demand_count es menor que + threshold_value """ + mock_send = MagicMock() + monkeypatch.setattr("app.services.alerts_service.send_alert_email", mock_send) + + cat = _make_category(db_session, name="Cat_AbsFalse") + skill = _make_skill(db_session, cat.id, name="Skill_AbsFalse") + user = _make_user(db_session, email="abs_no_trigger@example.com") + _make_snapshot(db_session, skill.id, demand_count=10, snap_date=date(2025, 1, 3)) + _make_alert(db_session, user.id, skill.id, alert_type="ABSOLUTE", threshold_value=50) + + with app.app_context(): + result = AlertsService.evaluate_and_notify() + + assert result == 0 + mock_send.assert_not_called() + + +def test_trend_alert_triggers_when_growth_rate_met(app, db_session, monkeypatch): + """ Una alerta TREND activa debe dispararse cuando growth_rate del snapshot mas reciente supera o iguala threshold_percentage """ + mock_send = MagicMock() + monkeypatch.setattr("app.services.alerts_service.send_alert_email", mock_send) + + cat = _make_category(db_session, name="Cat_TrendTrue") + skill = _make_skill(db_session, cat.id, name="Skill_TrendTrue") + user = _make_user(db_session, email="trend_trigger@example.com") + _make_snapshot(db_session, skill.id, growth_rate=Decimal("20.0"), snap_date=date(2025, 1, 4)) + _make_alert(db_session, user.id, skill.id, alert_type="TREND", + threshold_percentage=Decimal("15.0")) + + with app.app_context(): + result = AlertsService.evaluate_and_notify() + + assert result == 1 + mock_send.assert_called_once() + + +def test_trend_alert_not_evaluated_when_growth_rate_is_null(app, db_session, monkeypatch): + """ Un growth_rate=None en el snapshot significa que no hay suficiente historial para calcular el crecimiento semanal. La logica de produccion trata este caso como 'no evaluar', no como 'umbral no cumplido'. El test confirma que ningun email se envia en esta situacion """ + mock_send = MagicMock() + monkeypatch.setattr("app.services.alerts_service.send_alert_email", mock_send) + + cat = _make_category(db_session, name="Cat_TrendNull") + skill = _make_skill(db_session, cat.id, name="Skill_TrendNull") + user = _make_user(db_session, email="trend_null@example.com") + _make_snapshot(db_session, skill.id, growth_rate=None, snap_date=date(2025, 1, 5)) + _make_alert(db_session, user.id, skill.id, alert_type="TREND", + threshold_percentage=Decimal("15.0")) + + with app.app_context(): + result = AlertsService.evaluate_and_notify() + + assert result == 0 + mock_send.assert_not_called() + + +def test_alert_skipped_when_no_trend_snapshot_exists(app, db_session, monkeypatch): + """ Si no existe ningun TrendSnapshot para el skill de la alerta, evaluate_and_notify debe saltar la alerta silenciosamente y retornar 0 sin lanzar ninguna excepcion """ + mock_send = MagicMock() + monkeypatch.setattr("app.services.alerts_service.send_alert_email", mock_send) + + cat = _make_category(db_session, name="Cat_NoSnap") + skill = _make_skill(db_session, cat.id, name="Skill_NoSnap") + user = _make_user(db_session, email="no_snapshot@example.com") + # No creamos ningun TrendSnapshot para este skill. + _make_alert(db_session, user.id, skill.id, alert_type="ABSOLUTE", threshold_value=50) + + with app.app_context(): + result = AlertsService.evaluate_and_notify() + + assert result == 0 + mock_send.assert_not_called() + + +def test_email_failure_does_not_interrupt_processing(app, db_session, monkeypatch): + """ Si send_alert_email lanza AppError para la primera alerta, el servicio debe capturarla silenciosamente, continuar con la segunda alerta, y retornar 1 (solo la notificacion exitosa cuenta). La excepcion de la primera no interrumpe el ciclo """ + call_count = {"n": 0} + + def send_side_effect(email, subject, html): + call_count["n"] += 1 + if call_count["n"] == 1: + raise AppError("Fallo simulado de email", status_code=500, code="EMAIL_ERROR") + + monkeypatch.setattr("app.services.alerts_service.send_alert_email", send_side_effect) + + cat = _make_category(db_session, name="Cat_EmailFail") + skill1 = _make_skill(db_session, cat.id, name="Skill_EmailFail1") + skill2 = _make_skill(db_session, cat.id, name="Skill_EmailFail2") + user = _make_user(db_session, email="email_fail@example.com") + + # Ambas alertas tienen snapshots que superan el umbral. + _make_snapshot(db_session, skill1.id, demand_count=200, snap_date=date(2025, 1, 6)) + _make_snapshot(db_session, skill2.id, demand_count=200, snap_date=date(2025, 1, 7)) + _make_alert(db_session, user.id, skill1.id, alert_type="ABSOLUTE", threshold_value=50) + _make_alert(db_session, user.id, skill2.id, alert_type="ABSOLUTE", threshold_value=50) + + with app.app_context(): + result = AlertsService.evaluate_and_notify() + + # Solo la segunda notificacion tuvo exito. + assert result == 1 + assert call_count["n"] == 2 diff --git a/backend/tests/integration/test_auth_endpoints.py b/backend/tests/integration/test_auth_endpoints.py new file mode 100644 index 0000000..b0823ab --- /dev/null +++ b/backend/tests/integration/test_auth_endpoints.py @@ -0,0 +1,611 @@ +from datetime import datetime, timezone + +import pytest +from app.models.user import User +from app.utils.hash import hash_password + +# Helpers - duplicados localmente (principio DAMP: cada archivo de tests es autocontenido). Si la suite crece significativamente, extraer a tests/helpers.py queda como decision pendiente. + +_VALID_PASSWORD = "Secure1!" + +def _make_user(db_session, email="user@example.com", password=_VALID_PASSWORD, + verified=True, role="REGISTERED"): + """ Crea un usuario directamente en BD con todos los campos necesarios. Si verified=True setea email_verified_at, de lo contrario lo deja en None """ + user = User( + email=email, + first_name="Test", + last_name="User", + password_hash=hash_password(password) if password else None, + role=role, + is_active=True, + email_verified_at=datetime.now(timezone.utc) if verified else None, + password_changed_at=datetime.now(timezone.utc) if password else None, + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + +def _register_payload(email="new@example.com", password=_VALID_PASSWORD, + first_name="Test", last_name="User"): + return { + "email": email, + "password": password, + "first_name": first_name, + "last_name": last_name, + } + + +# Tests: POST /api/auth/register + +def test_register_success(app, db_session, client, monkeypatch): + """ POST con payload valido. El correo de verificacion se mockea. Verificar 201, usuario en BD con password hasheado, role REGISTERED, password_changed_at no nulo, y email_verified_at nulo (pendiente) """ + mock_calls = [] + + def fake_send(to_email, token): + mock_calls.append((to_email, token)) + + monkeypatch.setattr( + "app.services.email_service.send_verification_email", fake_send + ) + + payload = _register_payload(email="register_ok@example.com") + response = client.post("/api/auth/register", json=payload) + + assert response.status_code == 201 + data = response.get_json() + assert "message" in data["data"] + + # Verificar el usuario en BD + from app.models.user import User as UserModel + from app.extensions import db + persisted = db.session.query(UserModel).filter_by(email="register_ok@example.com").first() + assert persisted is not None + # Contraseña nunca en texto plano + assert persisted.password_hash != _VALID_PASSWORD + # La contraseña esta hasheada con bcrypt (empieza con $2b$) + assert persisted.password_hash.startswith("$2b$") + assert persisted.role == "REGISTERED" + assert persisted.password_changed_at is not None + assert persisted.email_verified_at is None + + # El mock fue invocado exactamente una vez + assert len(mock_calls) == 1 + assert mock_calls[0][0] == "register_ok@example.com" + +def test_register_duplicate_email_returns_409(app, db_session, client, monkeypatch): + """ Crear un usuario existente, luego registrar con el mismo email. Verificar 409 con code CONFLICT """ + monkeypatch.setattr( + "app.services.email_service.send_verification_email", lambda *a: None + ) + + email = "duplicate@example.com" + _make_user(db_session, email=email) + + payload = _register_payload(email=email) + response = client.post("/api/auth/register", json=payload) + + assert response.status_code == 409 + assert response.get_json()["error"]["code"] == "CONFLICT" + +def test_register_invalid_payload_returns_422(app, client): + """ POST con payload incompleto — sin email. Verificar 422 VALIDATION_ERROR. No necesita db_session porque el schema rechaza antes de tocar la BD """ + payload = {"password": _VALID_PASSWORD, "first_name": "Test", "last_name": "User"} + response = client.post("/api/auth/register", json=payload) + + assert response.status_code == 422 + assert response.get_json()["error"]["code"] == "VALIDATION_ERROR" + +def test_register_succeeds_even_if_email_delivery_fails(app, db_session, client, monkeypatch): + """ Mockear send_verification_email para que lance EmailDeliveryError. El registro NO se revierte: la respuesta sigue siendo 201, pero con el mensaje alternativo, y el usuario quedo en BD """ + from app.services.email_service import EmailDeliveryError + + def fake_send_fail(to_email, token): + raise EmailDeliveryError("Servicio de correo caido") + + monkeypatch.setattr( + "app.services.email_service.send_verification_email", fake_send_fail + ) + + email = "delivery_fail@example.com" + payload = _register_payload(email=email) + response = client.post("/api/auth/register", json=payload) + + # El registro no se revierte aunque el correo falle + assert response.status_code == 201 + data = response.get_json() + # El mensaje de respuesta es distinto al caso exitoso + assert "no pudimos enviar el correo" in data["data"]["message"].lower() + + # El usuario si quedo creado en BD + from app.models.user import User as UserModel + from app.extensions import db + persisted = db.session.query(UserModel).filter_by(email=email).first() + assert persisted is not None + +# Tests: POST /api/auth/login + +def test_login_success(app, db_session, client): + """ Usuario verificado con credenciales correctas. Verificar 200 y que la cookie de acceso quedo seteada en la respuesta """ + email = "login_ok@example.com" + password = _VALID_PASSWORD + _make_user(db_session, email=email, password=password, verified=True) + + response = client.post("/api/auth/login", json={"email": email, "password": password}) + + assert response.status_code == 200 + # El token viaja solo en cookie, nunca en el cuerpo + data = response.get_json() + assert "data" in data + + # La cookie de acceso debe haberse seteado en la respuesta. set_access_cookies de flask-jwt-extended escribe 'access_token_cookie'. Werkzeug moderno expone get_cookie(name); retorna None si no existe. + cookie = client.get_cookie("access_token_cookie") + assert cookie is not None, "La cookie 'access_token_cookie' no fue seteada tras un login exitoso" + +def test_login_wrong_password_returns_401(app, db_session, client): + """ Usuario real con password correcto en BD. POST con password incorrecto. Verificar 401 UNAUTHORIZED (mismo code que email inexistente, sin distincion que permita enumeracion de cuentas) """ + email = "wrong_pass@example.com" + _make_user(db_session, email=email, password=_VALID_PASSWORD, verified=True) + + response = client.post( + "/api/auth/login", json={"email": email, "password": "WrongPass999!"} + ) + + assert response.status_code == 401 + assert response.get_json()["error"]["code"] == "UNAUTHORIZED" + +def test_login_nonexistent_email_returns_401(app, client): + """ POST con email que no existe en BD. Verificar 401 UNAUTHORIZED. El code debe ser identico al de password incorrecto; sin distincion para evitar enumeracion de cuentas """ + response = client.post( + "/api/auth/login", + json={"email": "nobody@example.com", "password": _VALID_PASSWORD}, + ) + + assert response.status_code == 401 + assert response.get_json()["error"]["code"] == "UNAUTHORIZED" + +def test_login_unverified_email_returns_403(app, db_session, client): + """ Usuario con credenciales correctas pero email_verified_at=None. Verificar 403 EMAIL_NOT_VERIFIED """ + email = "unverified@example.com" + password = _VALID_PASSWORD + _make_user(db_session, email=email, password=password, verified=False) + + response = client.post("/api/auth/login", json={"email": email, "password": password}) + + assert response.status_code == 403 + assert response.get_json()["error"]["code"] == "EMAIL_NOT_VERIFIED" + +# Tests: POST /api/auth/google + +def test_google_login_creates_new_user(app, db_session, client, monkeypatch): + """ Google verify retorna dict valido. POST a /api/auth/google. Verificar 200, cookie seteada, usuario creado sin password_hash y con OAuthAccount vinculada """ + def fake_verify(*args, **kwargs): + return { + "sub": "google_uid_123", + "email": "newgoogle@example.com", + "email_verified": "true", + "given_name": "Nueva", + "family_name": "Cuenta" + } + monkeypatch.setattr("google.oauth2.id_token.verify_oauth2_token", fake_verify) + + response = client.post("/api/auth/google", json={"credential": "fake_token"}) + assert response.status_code == 200 + + # Cookie seteada + cookie = client.get_cookie("access_token_cookie") + assert cookie is not None + + # Verificar BD + from app.models.user import User as UserModel + from app.models.oauth_account import OAuthAccount + from app.extensions import db + + persisted = db.session.query(UserModel).filter_by(email="newgoogle@example.com").first() + assert persisted is not None + assert persisted.password_hash is None + assert persisted.email_verified_at is not None + assert persisted.first_name == "Nueva" + assert persisted.last_name == "Cuenta" + + oauth_acc = db.session.query(OAuthAccount).filter_by(user_id=persisted.id, provider="google", provider_user_id="google_uid_123").first() + assert oauth_acc is not None + + +def test_google_login_existing_oauth_account(app, db_session, client, monkeypatch): + """ Usuario y OAuthAccount ya existen. Retorna el mismo sub. Verificar 200 y que no se duplica el usuario """ + email = "existing_oauth@example.com" + user = _make_user(db_session, email=email, password=None, verified=True) + + from app.models.oauth_account import OAuthAccount + oauth_acc = OAuthAccount(user_id=user.id, provider="google", provider_user_id="existing_sub_456") + db_session.add(oauth_acc) + db_session.commit() + + original_user_id = user.id + + def fake_verify(*args, **kwargs): + return { + "sub": "existing_sub_456", + "email": email, + "email_verified": "true", + "given_name": "Existing", + "family_name": "User" + } + monkeypatch.setattr("google.oauth2.id_token.verify_oauth2_token", fake_verify) + + response = client.post("/api/auth/google", json={"credential": "fake_token"}) + assert response.status_code == 200 + + from app.models.user import User as UserModel + from app.extensions import db + users = db.session.query(UserModel).filter_by(email=email).all() + assert len(users) == 1 + assert users[0].id == original_user_id + +def test_google_login_account_link_pending_when_email_exists(app, db_session, client, monkeypatch): + """ Usuario registrado normal (con password) hace login con Google (mismo email). Nuevo comportamiento de Ronda 3: debe devolver 200 con code ACCOUNT_LINK_PENDING y un link_token — sin emitir cookie de sesion todavia. No se crea OAuthAccount. """ + email = "link_oauth@example.com" + user = _make_user(db_session, email=email, password=_VALID_PASSWORD, verified=True) + original_user_id = user.id + + def fake_verify(*args, **kwargs): + return { + "sub": "new_sub_789", + "email": email, + "email_verified": "true", + "given_name": "Linked", + "family_name": "User" + } + monkeypatch.setattr("google.oauth2.id_token.verify_oauth2_token", fake_verify) + + response = client.post("/api/auth/google", json={"credential": "fake_token"}) + assert response.status_code == 200 + data = response.get_json()["data"] + assert data["code"] == "ACCOUNT_LINK_PENDING" + assert "link_token" in data + assert data["email"] == email + + # No se emite cookie de sesion todavia + cookie = client.get_cookie("access_token_cookie") + assert cookie is None + + # No se crea OAuthAccount todavia + from app.models.oauth_account import OAuthAccount + from app.extensions import db + oauth_acc = db.session.query(OAuthAccount).filter_by(user_id=original_user_id, provider="google").first() + assert oauth_acc is None + +def test_google_login_rejects_invalid_token(app, client, monkeypatch): + """ Token invalido lanza ValueError desde la libreria de Google. Verificar 401 TOKEN_INVALID """ + def fake_verify_raises(*args, **kwargs): + raise ValueError("Invalid token") + monkeypatch.setattr("google.oauth2.id_token.verify_oauth2_token", fake_verify_raises) + + response = client.post("/api/auth/google", json={"bad_token": "bad_token"}) + assert response.status_code == 422 + +def test_google_login_missing_credential_returns_422(app, client): + """ POST sin campo credential. Verificar 422 VALIDATION_ERROR. """ + response = client.post("/api/auth/google", json={}) + assert response.status_code == 422 + assert response.get_json()["error"]["code"] == "VALIDATION_ERROR" + +def test_google_login_rejects_unverified_email(app, client, monkeypatch): + """ Google dice email_verified='false'. Verificar 401 EMAIL_NOT_VERIFIED """ + def fake_verify_unverified(*args, **kwargs): + return { + "sub": "sub_999", + "email": "unverified@example.com", + "email_verified": "false", + "given_name": "Unverified", + "family_name": "User" + } + monkeypatch.setattr("google.oauth2.id_token.verify_oauth2_token", fake_verify_unverified) + + response = client.post("/api/auth/google", json={"credential": "fake_token"}) + assert response.status_code == 401 + assert response.get_json()["error"]["code"] == "EMAIL_NOT_VERIFIED" + +# Tests: POST /api/auth/google/confirm-link + +def _create_google_link_token(db_session, user_id, token_plain, expired=False, used=False): + from app.models.google_link_token import GoogleLinkToken + token_hash = hashlib.sha256(token_plain.encode("utf-8")).hexdigest() + now = datetime.now(timezone.utc) + expires_at = now - timedelta(minutes=1) if expired else now + timedelta(minutes=15) + used_at = now if used else None + glt = GoogleLinkToken( + user_id=user_id, + token_hash=token_hash, + google_user_id="google_sub_confirm", + pending_email="confirm@example.com", + pending_first_name="Confirm", + pending_last_name="User", + expires_at=expires_at, + used_at=used_at, + ) + db_session.add(glt) + db_session.commit() + db_session.refresh(glt) + return glt + +def test_confirm_google_link_success(app, db_session, client): + """ Token valido: crea el OAuthAccount, marca el token como usado, emite cookie de sesion. """ + user = _make_user(db_session, email="confirm@example.com", password=_VALID_PASSWORD, verified=True) + token_plain = "valid_confirm_token" + glt = _create_google_link_token(db_session, user.id, token_plain) + + response = client.post("/api/auth/google/confirm-link", json={"link_token": token_plain}) + assert response.status_code == 200 + + # Cookie de sesion emitida + cookie = client.get_cookie("access_token_cookie") + assert cookie is not None + + # OAuthAccount creado + from app.models.oauth_account import OAuthAccount + from app.extensions import db + oauth_acc = db.session.query(OAuthAccount).filter_by(user_id=user.id, provider="google", provider_user_id="google_sub_confirm").first() + assert oauth_acc is not None + + # Token marcado como usado + db_session.refresh(glt) + assert glt.used_at is not None + +def test_confirm_google_link_already_used_returns_409(app, db_session, client): + """Token ya consumido: devuelve 409 TOKEN_ALREADY_USED.""" + user = _make_user(db_session, email="confirm_used@example.com", password=_VALID_PASSWORD, verified=True) + token_plain = "used_confirm_token" + _create_google_link_token(db_session, user.id, token_plain, used=True) + + response = client.post("/api/auth/google/confirm-link", json={"link_token": token_plain}) + assert response.status_code == 409 + assert response.get_json()["error"]["code"] == "TOKEN_ALREADY_USED" + +def test_confirm_google_link_expired_returns_410(app, db_session, client): + """ Token expirado: devuelve 410 TOKEN_EXPIRED. """ + user = _make_user(db_session, email="confirm_expired@example.com", password=_VALID_PASSWORD, verified=True) + token_plain = "expired_confirm_token" + _create_google_link_token(db_session, user.id, token_plain, expired=True) + + response = client.post("/api/auth/google/confirm-link", json={"link_token": token_plain}) + assert response.status_code == 410 + assert response.get_json()["error"]["code"] == "TOKEN_EXPIRED" + +def test_confirm_google_link_invalid_token_returns_404(app, client): + """ Token que no existe en BD: devuelve 404 TOKEN_INVALID. """ + response = client.post("/api/auth/google/confirm-link", json={"link_token": "nonexistent_token"}) + assert response.status_code == 404 + assert response.get_json()["error"]["code"] == "TOKEN_INVALID" + +def test_confirm_google_link_missing_token_returns_422(app, client): + """ POST sin campo link_token: devuelve 422 VALIDATION_ERROR. """ + response = client.post("/api/auth/google/confirm-link", json={}) + assert response.status_code == 422 + assert response.get_json()["error"]["code"] == "VALIDATION_ERROR" + +# Helpers y Tests: GET/POST /api/auth/verify-email & POST /api/auth/resend-verification + +import hashlib +from datetime import timedelta +from app.models.email_verification_token import EmailVerificationToken + +def _create_token(db_session, user_id, token_plain, expired=False, used=False): + token_hash = hashlib.sha256(token_plain.encode("utf-8")).hexdigest() + now = datetime.now(timezone.utc) + expires_at = now - timedelta(hours=1) if expired else now + timedelta(hours=24) + used_at = now if used else None + evt = EmailVerificationToken( + user_id=user_id, + token_hash=token_hash, + expires_at=expires_at, + used_at=used_at + ) + db_session.add(evt) + db_session.commit() + return evt + +def test_verify_email_get_valid_token(app, db_session, client): + """ GET con token valido. Verificar 200, valid=true, sin mutaciones. """ + user = _make_user(db_session, verified=False) + token_plain = "valid_token_123" + evt = _create_token(db_session, user.id, token_plain) + + response = client.get(f"/api/auth/verify-email?token={token_plain}") + + assert response.status_code == 200 + data = response.get_json() + assert data["data"]["valid"] is True + + # Comprobar que no hay side-effects + db_session.refresh(evt) + db_session.refresh(user) + assert evt.used_at is None + assert user.email_verified_at is None + +def test_verify_email_get_invalid_token_returns_404(app, client): + """ GET con token inexistente. Verificar 404 TOKEN_INVALID. """ + response = client.get("/api/auth/verify-email?token=does_not_exist") + assert response.status_code == 404 + assert response.get_json()["error"]["code"] == "TOKEN_INVALID" + +def test_verify_email_get_expired_token_returns_410(app, db_session, client): + """ GET con token expirado. Verificar 410 TOKEN_EXPIRED. """ + user = _make_user(db_session, verified=False) + token_plain = "expired_token_123" + _create_token(db_session, user.id, token_plain, expired=True) + + response = client.get(f"/api/auth/verify-email?token={token_plain}") + assert response.status_code == 410 + assert response.get_json()["error"]["code"] == "TOKEN_EXPIRED" + +def test_verify_email_post_marks_verified(app, db_session, client): + """ POST con token valido. Verificar 200 y que el usuario y token se mutan. """ + user = _make_user(db_session, verified=False) + token_plain = "valid_post_token_123" + evt = _create_token(db_session, user.id, token_plain) + + response = client.post("/api/auth/verify-email", json={"token": token_plain}) + + assert response.status_code == 200 + + db_session.refresh(evt) + db_session.refresh(user) + assert evt.used_at is not None + assert user.email_verified_at is not None + +def test_verify_email_post_already_used_returns_409(app, db_session, client): + """ POST con token ya usado. Verificar 409 TOKEN_ALREADY_USED. """ + user = _make_user(db_session, verified=False) + token_plain = "used_token_123" + _create_token(db_session, user.id, token_plain, used=True) + + response = client.post("/api/auth/verify-email", json={"token": token_plain}) + assert response.status_code == 409 + assert response.get_json()["error"]["code"] == "TOKEN_ALREADY_USED" + +def test_resend_verification_unknown_email_returns_generic_200(app, client, monkeypatch): + """ Resend a email no existente. Verificar 200 para evitar enumeracion. """ + mock_calls = [] + monkeypatch.setattr("app.services.email_service.send_verification_email", lambda to, t: mock_calls.append((to, t))) + + response = client.post("/api/auth/resend-verification", json={"email": "nobody@example.com"}) + assert response.status_code == 200 + assert "nuevo enlace" in response.get_json()["data"]["message"].lower() + assert len(mock_calls) == 0 + +def test_resend_verification_already_verified_returns_generic_200(app, db_session, client, monkeypatch): + """ Resend a email ya verificado. Verificar 200 pero sin email real. """ + user = _make_user(db_session, email="already_verified@example.com", verified=True) + + mock_calls = [] + monkeypatch.setattr("app.services.email_service.send_verification_email", lambda to, t: mock_calls.append((to, t))) + + response = client.post("/api/auth/resend-verification", json={"email": user.email}) + assert response.status_code == 200 + + from app.extensions import db + count = db.session.query(EmailVerificationToken).filter_by(user_id=user.id).count() + assert count == 0 + assert len(mock_calls) == 0 + +def test_resend_verification_creates_new_token(app, db_session, client, monkeypatch): + """ Resend a email no verificado. Verificar 200, token creado en BD, y llamada de correo. """ + user = _make_user(db_session, email="needs_resend@example.com", verified=False) + + mock_calls = [] + monkeypatch.setattr("app.services.email_service.send_verification_email", lambda to, t: mock_calls.append((to, t))) + + response = client.post("/api/auth/resend-verification", json={"email": user.email}) + assert response.status_code == 200 + + from app.extensions import db + count = db.session.query(EmailVerificationToken).filter_by(user_id=user.id).count() + assert count == 1 + assert len(mock_calls) == 1 + assert mock_calls[0][0] == user.email + +# Helpers y Tests: GET /api/auth/me, POST /api/auth/forgot-password, POST /api/auth/reset-password + +from app.models.password_reset_token import PasswordResetToken + +def _create_pwd_reset_token(db_session, user_id, token_plain, expired=False, used=False): + token_hash = hashlib.sha256(token_plain.encode("utf-8")).hexdigest() + now = datetime.now(timezone.utc) + expires_at = now - timedelta(hours=1) if expired else now + timedelta(hours=24) + used_at = now if used else None + evt = PasswordResetToken( + user_id=user_id, + token_hash=token_hash, + expires_at=expires_at, + used_at=used_at + ) + db_session.add(evt) + db_session.commit() + return evt + +def test_forgot_password_unknown_email_returns_generic_200(app, client, monkeypatch): + mock_calls = [] + monkeypatch.setattr("app.services.email_service.send_password_reset_email", lambda to, t: mock_calls.append((to, t))) + + response = client.post("/api/auth/forgot-password", json={"email": "nobody@example.com"}) + assert response.status_code == 200 + assert len(mock_calls) == 0 + +def test_forgot_password_known_email_creates_token_and_sends_email(app, db_session, client, monkeypatch): + user = _make_user(db_session, email="known_forgot@example.com") + + mock_calls = [] + monkeypatch.setattr("app.services.email_service.send_password_reset_email", lambda to, t: mock_calls.append((to, t))) + + response = client.post("/api/auth/forgot-password", json={"email": user.email}) + assert response.status_code == 200 + + from app.extensions import db + count = db.session.query(PasswordResetToken).filter_by(user_id=user.id).count() + assert count == 1 + assert len(mock_calls) == 1 + assert mock_calls[0][0] == user.email + +def test_reset_password_success(app, db_session, client): + user = _make_user(db_session, email="reset_ok@example.com", password=_VALID_PASSWORD) + original_hash = user.password_hash + original_changed_at = user.password_changed_at + + token_plain = "valid_pwd_reset_token_123" + prt = _create_pwd_reset_token(db_session, user.id, token_plain) + + response = client.post("/api/auth/reset-password", json={"token": token_plain, "new_password": "NewSecure1!"}) + assert response.status_code == 200 + + db_session.refresh(prt) + db_session.refresh(user) + assert prt.used_at is not None + assert user.password_hash != original_hash + assert user.password_changed_at > original_changed_at + +def test_reset_password_invalidates_old_tokens(app, db_session, client): + user = _make_user(db_session, email="reset_revoke@example.com", password=_VALID_PASSWORD) + user.password_changed_at = datetime.now(timezone.utc) - timedelta(hours=2) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + + from flask_jwt_extended import create_access_token, get_csrf_token, decode_token + import jwt + with app.app_context(): + token = create_access_token(identity=str(user.id)) + + # Retroceder el iat 1 hora para asegurar que es estrictamente menor al nuevo password_changed_at pero mayor al password_changed_at original + token_issued_at = datetime.now(timezone.utc) - timedelta(hours=1) + decoded = decode_token(token) + decoded["iat"] = int(token_issued_at.timestamp()) + token = jwt.encode(decoded, app.config["JWT_SECRET_KEY"], algorithm="HS256") + + csrf = get_csrf_token(token) + + client.set_cookie("access_token_cookie", token) + headers = {"X-CSRF-TOKEN": csrf} + + # Verify token works BEFORE reset + resp_before = client.get("/api/profile/me", headers=headers) + assert resp_before.status_code == 200 + + token_plain = "revoke_reset_token_123" + _create_pwd_reset_token(db_session, user.id, token_plain) + + # Do the reset using endpoint + resp_reset = client.post("/api/auth/reset-password", json={"token": token_plain, "new_password": "NewSecure1!"}) + assert resp_reset.status_code == 200 + + # Verify old token NO LONGER works (TOKEN_REVOKED) + resp_after = client.get("/api/profile/me", headers=headers) + assert resp_after.status_code == 401 + assert resp_after.get_json()["error"]["code"] == "TOKEN_REVOKED" + + +def test_reset_password_invalid_or_expired_token_returns_400(app, db_session, client): + response = client.post("/api/auth/reset-password", json={"token": "does_not_exist", "new_password": "NewSecure1!"}) + assert response.status_code == 400 + assert response.get_json()["error"]["code"] == "INVALID_TOKEN" diff --git a/backend/tests/integration/test_backup_restore_schema_sync.py b/backend/tests/integration/test_backup_restore_schema_sync.py new file mode 100644 index 0000000..b049358 --- /dev/null +++ b/backend/tests/integration/test_backup_restore_schema_sync.py @@ -0,0 +1,192 @@ +""" Test de integracion: auto-sincronizacion de esquema tras restore. + +Advertencia Crítica de Diseño: +Este test NO usa db_session ni depende del aislamiento transaccional para su limpieza. Las operaciones DDL de Alembic (downgrade/upgrade) se ejecutan en conexiones independientes, un rollback de transaccion ORM NO revierte un ALTER TABLE ya aplicado por Alembic. El cleanup es manual y explicito en el bloque finally, con verificacion activa del estado final. + +El test replica el incidente historico verificado manualmente por Elias; Downgrade real de la BD un paso hacia atras (esquema viejo). pg_dump sobre el esquema viejo -> archivo .sql viejo. Upgrade de vuelta al head actual. Insertar registro Backup apuntando al .sql viejo. Llamar restore_database_backup(), que aplica pg_restore + upgrade(). Confirmar que el esquema quedo en head sin intervencion manual. """ +import os +import subprocess +import pytest +from urllib.parse import urlparse +from sqlalchemy import inspect as sa_inspect, text +from alembic.runtime.migration import MigrationContext +from alembic.script import ScriptDirectory +from flask_migrate import upgrade, downgrade +from app.models.backup import Backup +from app.extensions import db +from app.services.backup_service import BackupService + + +def _get_alembic_config(app): + """ Retorna el AlembicConfig registrado via flask_migrate en el contexto activo """ + return app.extensions["migrate"].migrate.get_config() + + +def _get_db_current_revision(engine): + """ Consulta la revision actual de alembic_version en la base de datos """ + with engine.connect() as conn: + ctx = MigrationContext.configure(conn) + return ctx.get_current_revision() + + +def _get_alembic_head(app): + """ Retorna el head actual del ScriptDirectory de Alembic (desde el codigo, no desde la BD) """ + config = _get_alembic_config(app) + script = ScriptDirectory.from_config(config) + heads = script.get_heads() + assert len(heads) == 1, ( + f"Se esperaba exactamente 1 head de Alembic, se encontraron: {heads}. " + "El test asume una cadena lineal de migraciones." + ) + return heads[0] + + +def _build_pg_dump_command(db_url, output_filepath): + """ Construye el comando pg_dump usando el mismo patron que backup_service.py """ + parsed = urlparse(db_url) + user = parsed.username + host = parsed.hostname + port = str(parsed.port or 5432) + db_name = parsed.path.lstrip("/") + return ( + ["pg_dump", "-h", host, "-p", port, "-U", user, "-F", "c", "-f", output_filepath, db_name], + parsed.password or "", + ) + + +def test_restore_triggers_automatic_schema_sync(app): + """ Verifica que restore_database_backup() re-sincroniza el esquema de la BD automaticamente tras un pg_restore que revierte a un esquema viejo, sin requerir intervencion manual + + NOTA: No recibe db_session, gestiona su propia conexion y cleanup DDL """ + backup_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "data", "backups" + ) + + with app.app_context(): + expected_head = _get_alembic_head(app) + db_url = app.config["SQLALCHEMY_DATABASE_URI"] + + files_before = set(os.listdir(backup_dir)) if os.path.isdir(backup_dir) else set() + + inserted_backup_id = None + + try: + downgrade(revision="-1") + + revision_after_downgrade = _get_db_current_revision(db.engine) + assert revision_after_downgrade != expected_head, ( + "El downgrade no produjo ningun cambio -- la BD ya estaba en una " + "revision anterior al head. Verificar el estado de skillstat_test." + ) + + # NO usamos BackupService.execute_database_backup() porque ese metodo inserta un registro Backup via ORM, lo que requiere el esquema actual completo para funcionar correctamente. pg_dump crudo evita esa dependencia. + os.makedirs(backup_dir, exist_ok=True) + old_sql_filename = f"test_schema_sync_old_schema.sql" + old_sql_path = os.path.join(backup_dir, old_sql_filename) + + command, password = _build_pg_dump_command(db_url, old_sql_path) + env = os.environ.copy() + env["PGPASSWORD"] = password + subprocess.run(command, env=env, capture_output=True, text=True, check=True) + + assert os.path.exists(old_sql_path), "pg_dump no genero el archivo .sql esperado" + old_sql_size = os.path.getsize(old_sql_path) + assert old_sql_size > 0, "El archivo .sql generado por pg_dump esta vacio" + + upgrade() + + revision_after_reupgrade = _get_db_current_revision(db.engine) + assert revision_after_reupgrade == expected_head, ( + f"El upgrade de vuelta al head fallo: se esperaba {expected_head}, " + f"se obtuvo {revision_after_reupgrade}." + ) + + # Insertamos el registro de backup evadiendo BackupRepository para no acoplar el test a la logica de filtrado de columnas del repositorio. + backup_record = Backup( + filename=old_sql_filename, + storage_url=old_sql_path, + status="COMPLETED", + user_id=None, + file_size_bytes=old_sql_size, + ) + db.session.add(backup_record) + db.session.commit() + db.session.refresh(backup_record) + inserted_backup_id = backup_record.id + + result = BackupService.restore_database_backup( + backup_id=inserted_backup_id, + requested_by=None, + ) + + assert result["status"] == "success", ( + f"restore_database_backup() no retorno status='success': {result}" + ) + + # Si restore_database_backup() resincronizo el esquema correctamente, este assert debe pasar sin que nosotros llamemos upgrade() aqui, es la prueba real de que el mecanismo automatico funciono. + revision_after_restore = _get_db_current_revision(db.engine) + assert revision_after_restore == expected_head, ( + f"El esquema NO fue resincronizado automaticamente por restore_database_backup(). " + f"Se esperaba {expected_head}, se obtuvo {revision_after_restore}. " + "La llamada automatica a upgrade() dentro del servicio no funciono." + ) + + # Validamos que los cambios del head actual de Alembic estan realmente en la base de datos usando el inspector de SQLAlchemy en vez de cadenas de texto fijas. + inspector = sa_inspect(db.engine) + columns = {col["name"] for col in inspector.get_columns("backups")} + assert "file_size_bytes" in columns, ( + f"La columna 'file_size_bytes' no existe en la tabla 'backups'. " + f"Columnas actuales: {columns}. " + "El esquema no fue correctamente resincronizado al head." + ) + + # Verificamos que la extension unaccent sigue presente tras el ciclo completo. El DROP SCHEMA CASCADE del nuevo mecanismo de restore no afecta las extensiones porque estas viven en pg_catalog, no en el schema public; pero el dump de un esquema viejo (previo a la migracion que instalo unaccent) tampoco la incluye. La garantia real de que unaccent sobrevive es que upgrade() re-aplica la migracion de unaccent. Este assert lo verifica explicitamente. + with db.engine.connect() as check_conn: + result = check_conn.execute( + text("SELECT extname FROM pg_extension WHERE extname = 'unaccent'") + ) + row = result.fetchone() + assert row is not None, ( + "La extension 'unaccent' no esta presente en pg_extension tras el " + "ciclo completo de restore + upgrade(). El mecanismo automatico de " + "resincronizacion de esquema no reinstalo la extension correctamente." + ) + + finally: + # Intentamos resincronizar el esquema aunque el test ya haya fallado, para no dejar skillstat_test en un estado inconsistente para los siguientes tests. + try: + upgrade() + except Exception as cleanup_upgrade_err: + # No silenciamos este error, lo relanzamos para que el test falle con un mensaje explicito sobre el estado del entorno. + raise AssertionError( + f"CLEANUP FALLIDO: No se pudo dejar skillstat_test en el head " + f"'{expected_head}' tras el test. Se requiere intervencion manual: " + f"ejecutar 'flask db upgrade' desde la terminal. " + f"Error: {cleanup_upgrade_err}" + ) from cleanup_upgrade_err + + final_revision = _get_db_current_revision(db.engine) + if final_revision != expected_head: + raise AssertionError( + f"CLEANUP FALLIDO: skillstat_test quedo en revision '{final_revision}' " + f"en vez del head esperado '{expected_head}'. " + "Se requiere intervencion manual: ejecutar 'flask db upgrade'." + ) + + if inserted_backup_id is not None: + try: + record = db.session.get(Backup, inserted_backup_id) + if record: + db.session.delete(record) + db.session.commit() + except Exception: + # El registro es de test., si ya fue limpiado por rollback, ignoramos el fallo. + db.session.rollback() + + if os.path.isdir(backup_dir): + files_after = set(os.listdir(backup_dir)) + for new_file in files_after - files_before: + filepath = os.path.join(backup_dir, new_file) + if os.path.exists(filepath): + os.remove(filepath) diff --git a/backend/tests/integration/test_backup_service.py b/backend/tests/integration/test_backup_service.py new file mode 100644 index 0000000..176f832 --- /dev/null +++ b/backend/tests/integration/test_backup_service.py @@ -0,0 +1,228 @@ +import os +import subprocess +import pytest +from sqlalchemy import select +from app.models.backup import Backup +from app.extensions import db +from app.services.backup_service import BackupService +from app.utils.errors import AppError + + +def test_execute_database_backup_happy_path(app, db_session): + """ Happy path de BackupService.execute_database_backup(), su ejecucion real de pg_dump contra skillstat_test, sin ningun mock. Verifica el dict retornado, el registro en BD y el archivo fisico en disco """ + filepath_to_cleanup = None + + try: + result = BackupService.execute_database_backup(requested_by=None) + + # Aserciones sobre el dict retornado + assert result["status"] == "success" + assert result["file"].startswith("skillstat_backup_") + assert result["file"].endswith(".sql") + assert result["size"] > 0 + + filename = result["file"] + + # Consulta directa al registro en BD (sin metodo nuevo en Repository) + record = db_session.execute( + select(Backup).where(Backup.filename == filename) + ).scalar_one_or_none() + + assert record is not None, f"No se encontro registro en BD para filename={filename}" + assert record.status == "COMPLETED" + assert record.user_id is None + assert record.file_size_bytes is not None + assert record.file_size_bytes > 0 + + # Verificacion del archivo fisico en disco + filepath_to_cleanup = record.storage_url + assert os.path.exists(filepath_to_cleanup), ( + f"El archivo fisico no existe en disco: {filepath_to_cleanup}" + ) + + # file_size_bytes en BD debe coincidir exactamente con el tamaño real del archivo + real_size = os.path.getsize(filepath_to_cleanup) + assert record.file_size_bytes == real_size, ( + f"file_size_bytes en BD ({record.file_size_bytes}) " + f"!= tamaño real en disco ({real_size})" + ) + + finally: + # El archivo .sql vive fuera de la transaccion de Postgres, db_session rollback no lo elimina. Limpieza manual obligatoria. + if filepath_to_cleanup and os.path.exists(filepath_to_cleanup): + os.remove(filepath_to_cleanup) + + +def test_execute_database_backup_pg_dump_failure(app, db_session, monkeypatch): + """ Ruta de fallo CalledProcessError, subprocess.run lanza CalledProcessError simulando un fallo real de pg_dump. Verifica que execute_database_backup() relanza AppError con code BACKUP_ERROR y que el registro en BD queda en status FAILED. No se genera ningun archivo fisico porque pg_dump nunca se ejecuta realmente """ + # Herramienta de mock, monkeypatch (fixture integrada de pytest, sin dependencia adicional). pytest-mock no esta instalado en el proyecto (no figura en 'requirements.txt'). + + def fake_subprocess_run(*args, **kwargs): + raise subprocess.CalledProcessError( + returncode=1, + cmd=["pg_dump"], + stderr="mensaje de error simulado de pg_dump" + ) + + monkeypatch.setattr("subprocess.run", fake_subprocess_run) + + with pytest.raises(AppError) as exc_info: + BackupService.execute_database_backup(requested_by=None) + + # Verifica el code del AppError relanzado + assert exc_info.value.code == "BACKUP_ERROR" + + # Consulta directa al registro: debe existir (se creo ANTES del mock con status PENDING) y su estado final debe ser FAILED (actualizado por el except CalledProcessError). NOTA: el filename es impredecible, pero solo puede haber un registro con status FAILED creado en esta transaccion aislada, consultamos el mas reciente por created_at. + record = db_session.execute( + select(Backup).where(Backup.status == "FAILED").order_by(Backup.created_at.desc()) + ).scalars().first() + + assert record is not None, "No se encontro ningun registro con status FAILED en BD" + assert record.status == "FAILED" + assert record.user_id is None + + +def test_execute_database_backup_generic_exception(app, db_session, monkeypatch): + """ Ruta de fallo Exception generica: subprocess.run lanza OSError simulando un fallo interno no previsto (no CalledProcessError). Verifica que execute_database_backup() relanza AppError con code BACKUP_ERROR (mismo code que el caso CalledProcessError; confirmado en el codigo de produccion: ambos bloques except usan code='BACKUP_ERROR' sin distincion) y que el registro en BD queda en status FAILED """ + + def fake_subprocess_run_oserror(*args, **kwargs): + raise OSError("fallo interno simulado: binario no disponible") + + monkeypatch.setattr("subprocess.run", fake_subprocess_run_oserror) + + with pytest.raises(AppError) as exc_info: + BackupService.execute_database_backup(requested_by=None) + + # Ambos bloques except (CalledProcessError y Exception generico) usan code="BACKUP_ERROR". No hay distincion de code entre los dos casos en el codigo de produccion actual. + assert exc_info.value.code == "BACKUP_ERROR" + + record = db_session.execute( + select(Backup).where(Backup.status == "FAILED").order_by(Backup.created_at.desc()) + ).scalars().first() + + assert record is not None, "No se encontro ningun registro con status FAILED en BD" + assert record.status == "FAILED" + assert record.user_id is None + + +# Tests de restore_database_backup() + +def test_restore_database_backup_not_found(app, db_session): + """ Caso (a): backup_id inexistente. restore_database_backup() debe lanzar AppError con code NOT_FOUND y status_code 404 """ + with pytest.raises(AppError) as exc_info: + BackupService.restore_database_backup(backup_id=999999, requested_by=None) + + assert exc_info.value.code == "NOT_FOUND" + assert exc_info.value.status_code == 404 + + +def test_restore_database_backup_invalid_state(app, db_session): + """ Caso (b): backup con status PENDING (no COMPLETED). restore_database_backup() debe lanzar AppError con code INVALID_BACKUP_STATE y status_code 422 """ + # Creamos un registro real en BD con status PENDING via insercion directa con db_session. No usamos BackupRepository.create() para evitar acoplarnos a su logica de filtrado. + pending_backup = Backup( + filename="test_pending_backup.sql", + storage_url="/ruta/falsa/test_pending_backup.sql", + status="PENDING", + user_id=None, + ) + db_session.add(pending_backup) + db_session.commit() + + # Refrescamos para obtener el id asignado por la BD + db_session.refresh(pending_backup) + backup_id = pending_backup.id + + with pytest.raises(AppError) as exc_info: + BackupService.restore_database_backup(backup_id=backup_id, requested_by=None) + + assert exc_info.value.code == "INVALID_BACKUP_STATE" + assert exc_info.value.status_code == 422 + + +def test_restore_database_backup_file_missing_no_r2(app, db_session): + """ Caso (c): backup con status COMPLETED pero archivo local inexistente y R2 no configurado. RemoteStorageService.download_backup() retorna False de forma natural en el entorno de testing (R2 no configurado, degradacion suave ya verificada en rama anterior). No se mockea nada. Verifica AppError con code BACKUP_FILE_MISSING y status_code 404 """ + # Ruta que definitivamente no existe en disco + fake_path = "C:/ruta/absolutamente/inexistente/fake_backup_12345.sql" + + completed_backup = Backup( + filename="fake_backup_12345.sql", + storage_url=fake_path, + status="COMPLETED", + user_id=None, + file_size_bytes=1024, + ) + db_session.add(completed_backup) + db_session.commit() + + db_session.refresh(completed_backup) + backup_id = completed_backup.id + + with pytest.raises(AppError) as exc_info: + BackupService.restore_database_backup(backup_id=backup_id, requested_by=None) + + assert exc_info.value.code == "BACKUP_FILE_MISSING" + assert exc_info.value.status_code == 404 + + +def test_restore_database_backup_pg_restore_failure(app, db_session, monkeypatch): + """ Caso (d): pg_dump real (para tener un archivo local legitimo) + pg_restore mockeado con CalledProcessError. El mock es CONDICIONAL: si el primer elemento del comando es 'pg_dump', ejecuta el subprocess.run original; si es 'pg_restore', lanza CalledProcessError simulado. Verifica AppError con code RESTORE_ERROR y status_code 500. El archivo .sql generado por pg_dump se limpia en el finally """ + # Referencia al subprocess.run original, capturada ANTES del monkeypatch + _original_subprocess_run = subprocess.run + + # Snapshot del directorio de backups ANTES del test para identificar archivos nuevos al limpiar. restore_database_backup() genera DOS archivos fisicos (backup del test + backup de seguridad interno), y el backup de seguridad interno no es visible desde una conexion externa porque vive dentro de la transaccion db_session que luego hace rollback. + backup_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "data", "backups" + ) + files_before = set(os.listdir(backup_dir)) if os.path.isdir(backup_dir) else set() + + def conditional_subprocess_run(command, *args, **kwargs): + if command[0] == "pg_dump": + # pg_dump debe ejecutarse de verdad para generar un archivo local legitimo + return _original_subprocess_run(command, *args, **kwargs) + elif command[0] == "pg_restore": + raise subprocess.CalledProcessError( + returncode=1, + cmd=command, + stderr="mensaje de error simulado de pg_restore" + ) + # Cualquier otro comando: ejecutar normalmente (fallback defensivo) + return _original_subprocess_run(command, *args, **kwargs) + + monkeypatch.setattr("subprocess.run", conditional_subprocess_run) + + try: + # Primero generamos un backup real con pg_dump para tener un archivo local legitimo que restore_database_backup() pueda encontrar en disco (os.path.exists == True). + real_backup_result = BackupService.execute_database_backup(requested_by=None) + + # Recuperamos el registro real del backup recien generado para obtener su id + real_backup_record = db_session.execute( + select(Backup).where(Backup.filename == real_backup_result["file"]) + ).scalar_one_or_none() + + assert real_backup_record is not None, "No se encontro el registro del backup real en BD" + backup_id = real_backup_record.id + + # Ahora llamamos a restore con ese backup_id. pg_dump se ejecutara de nuevo (backup de seguridad interno) via el mock condicional, y pg_restore fallara con CalledProcessError simulado. Ademas, mockeamos la conexion DDL cruda para evitar que DROP SCHEMA CASCADE haga deadlock con la transaccion de prueba activa en db_session. + from unittest.mock import MagicMock + mock_conn = MagicMock() + mock_conn.__enter__.return_value = mock_conn + mock_execution_options = MagicMock() + mock_execution_options.connect.return_value = mock_conn + monkeypatch.setattr("app.services.backup_service.db.engine.execution_options", lambda **kwargs: mock_execution_options) + + with pytest.raises(AppError) as exc_info: + BackupService.restore_database_backup(backup_id=backup_id, requested_by=None) + + assert exc_info.value.code == "RESTORE_ERROR" + assert exc_info.value.status_code == 500 + + finally: + # Eliminamos todos los archivos .sql nuevos generados durante el test (el backup del test + el backup de seguridad interno de restore), comparando contra el snapshot del directorio tomado antes de empezar. + if os.path.isdir(backup_dir): + files_after = set(os.listdir(backup_dir)) + for new_file in files_after - files_before: + filepath = os.path.join(backup_dir, new_file) + if os.path.exists(filepath): + os.remove(filepath) + diff --git a/backend/tests/integration/test_city_repository.py b/backend/tests/integration/test_city_repository.py new file mode 100644 index 0000000..9f90e50 --- /dev/null +++ b/backend/tests/integration/test_city_repository.py @@ -0,0 +1,22 @@ +import pytest +from unittest.mock import MagicMock + +from app.models.city import City +from app.repositories.city_repository import CityRepository + +def _make_city(db_session, name="Ciudad Test", state="Estado Test", lat=19.4326, lon=-99.1332): + city = City(name=name, state=state, lat=lat, lon=lon, country="MX") + db_session.add(city) + db_session.commit() + return city + +def test_get_by_name_returns_city_if_exists(app, db_session): + """ get_by_name retorna la ciudad correcta cuando existe, y None cuando no. """ + _make_city(db_session, name="Cancún") + + with app.app_context(): + city_found = CityRepository.get_by_name("Cancún") + city_missing = CityRepository.get_by_name("Mérida") + assert city_found is not None + assert city_found.name == "Cancún" + assert city_missing is None diff --git a/backend/tests/integration/test_city_repository_unaccent.py b/backend/tests/integration/test_city_repository_unaccent.py new file mode 100644 index 0000000..6d3a908 --- /dev/null +++ b/backend/tests/integration/test_city_repository_unaccent.py @@ -0,0 +1,61 @@ +import pytest +from sqlalchemy import event +from app.extensions import db as _db +from app.models.city import City +from app.repositories.city_repository import CityRepository + +@pytest.fixture +def query_counter(app): + counts = {"n": 0} + def on_execute(conn, cursor, statement, parameters, context, executemany): + counts["n"] += 1 + event.listen(_db.engine, "before_cursor_execute", on_execute) + yield counts + event.remove(_db.engine, "before_cursor_execute", on_execute) + +def _make_city(db_session, name="Guadalajara"): + city = City(name=name, state="State", country="MX") + db_session.add(city) + db_session.flush() + return city + +def test_find_by_normalized_name_matches_exact(app, db_session): + _make_city(db_session, name="Guadalajara") + + with app.app_context(): + city = CityRepository.find_by_normalized_name("Guadalajara") + assert city is not None + assert city.name == "Guadalajara" + +def test_find_by_normalized_name_matches_with_accents_and_case(app, db_session): + _make_city(db_session, name="Querétaro") + + with app.app_context(): + city = CityRepository.find_by_normalized_name(" queretaro ") + assert city is not None + assert city.name == "Querétaro" + +def test_find_by_normalized_name_returns_none_when_not_found(app, db_session): + _make_city(db_session, name="Monterrey") + + with app.app_context(): + city = CityRepository.find_by_normalized_name("Cancun") + assert city is None + +def test_find_by_normalized_name_returns_none_for_empty_string(app, db_session): + with app.app_context(): + city = CityRepository.find_by_normalized_name("") + assert city is None + + city_none = CityRepository.find_by_normalized_name(None) + assert city_none is None + +def test_find_by_normalized_name_uses_single_query(app, db_session, query_counter): + _make_city(db_session, name="Mérida") + + with app.app_context(): + query_counter["n"] = 0 + city = CityRepository.find_by_normalized_name("merida") + assert city is not None + assert city.name == "Mérida" + assert query_counter["n"] == 1 diff --git a/backend/tests/integration/test_city_service.py b/backend/tests/integration/test_city_service.py new file mode 100644 index 0000000..f51a832 --- /dev/null +++ b/backend/tests/integration/test_city_service.py @@ -0,0 +1,116 @@ +import pytest +from unittest.mock import MagicMock + +from app.models.city import City +from app.services.city_service import CityService +from app.repositories.city_repository import CityRepository +from app.utils.errors import ConflictError + +def _make_city(db_session, name="Ciudad Test", state="Estado Test", lat=19.4326, lon=-99.1332): + city = City(name=name, state=state, lat=lat, lon=lon, country="MX") + db_session.add(city) + db_session.commit() + return city + +def test_get_or_create_city_returns_existing_exact_match(app, db_session): + """ get_or_create_city retorna una ciudad existente sin crear una nueva cuando el nombre coincide exactamente. """ + _make_city(db_session, name="Guadalajara") + + with app.app_context(): + city, created = CityService.get_or_create_city("Guadalajara") + assert not created + assert city is not None + assert city.name == "Guadalajara" + +def test_get_or_create_city_returns_existing_after_normalization(app, db_session): + """ get_or_create_city retorna una ciudad existente cuando el nombre coincide tras normalización (acentos, mayúsculas/minúsculas). """ + _make_city(db_session, name="Querétaro") + + with app.app_context(): + city, created = CityService.get_or_create_city(" queretaro ") + assert not created + assert city is not None + assert city.name == "Querétaro" + +def test_get_or_create_city_creates_new_when_valid_data(app, db_session, monkeypatch): + """ get_or_create_city crea una ciudad nueva cuando no existe y Nominatim (mockeado) retorna datos válidos, devolviendo (city, True). """ + mock_geocode = MagicMock(return_value={ + "name": "Monterrey", + "state": "Nuevo León", + "lat": 25.6866, + "lon": -100.3161 + }) + monkeypatch.setattr("app.services.city_service.NominatimClient.geocode_city", mock_geocode) + + with app.app_context(): + city, created = CityService.get_or_create_city("Monterrey") + assert created + assert city is not None + assert city.name == "Monterrey" + assert city.state == "Nuevo León" + mock_geocode.assert_called_once_with("Monterrey") + +def test_get_or_create_city_returns_none_when_not_found(app, db_session, monkeypatch): + """ get_or_create_city retorna (None, False) cuando Nominatim (mockeado) no encuentra resultado. """ + mock_geocode = MagicMock(return_value=None) + monkeypatch.setattr("app.services.city_service.NominatimClient.geocode_city", mock_geocode) + + with app.app_context(): + city, created = CityService.get_or_create_city("Ciudad Inexistente 123") + assert not created + assert city is None + mock_geocode.assert_called_once_with("Ciudad Inexistente 123") + +def test_get_or_create_city_detects_resolved_name_already_exists(app, db_session, monkeypatch): + """ get_or_create_city detecta que el nombre resuelto por Nominatim ya existe en base de datos aunque el nombre original consultado no coincidiera (caso de alias, ej. "Distrito Federal" resolviendo a "Ciudad de México" ya existente) y retorna esa ciudad sin duplicar. """ + _make_city(db_session, name="Ciudad de México") + + mock_geocode = MagicMock(return_value={ + "name": "Ciudad de México", + "state": "Ciudad de México", + "lat": 19.4326, + "lon": -99.1332 + }) + monkeypatch.setattr("app.services.city_service.NominatimClient.geocode_city", mock_geocode) + + with app.app_context(): + city, created = CityService.get_or_create_city("Distrito Federal") + assert not created + assert city is not None + assert city.name == "Ciudad de México" + mock_geocode.assert_called_once_with("Distrito Federal") + +def test_get_or_create_city_returns_none_for_empty_raw_location(app, db_session): + """ get_or_create_city retorna (None, False) cuando raw_location está vacío o es None. """ + with app.app_context(): + city1, created1 = CityService.get_or_create_city("") + city2, created2 = CityService.get_or_create_city(None) + assert city1 is None + assert not created1 + assert city2 is None + assert not created2 + +def test_get_or_create_city_recovers_from_concurrent_creation_conflict(app, db_session, monkeypatch): + """ get_or_create_city recupera la ciudad vía find_by_normalized_name si ocurre un ConflictError al intentar insertarla por una colisión en concurrencia con otro proceso. """ + mock_geocode = MagicMock(return_value={ + "name": "Puebla", + "state": "Puebla", + "lat": 19.0414, + "lon": -98.2063 + }) + monkeypatch.setattr("app.services.city_service.NominatimClient.geocode_city", mock_geocode) + + def mock_create(*args, **kwargs): + # Cuando intenta crearla, simulamos que otro proceso ya la guardo e insertamos directo a BD, + # y luego lanzamos ConflictError para simular el fallo de integridad del proceso actual. + _make_city(db_session, name="Puebla", state="Puebla") + raise ConflictError("Conflicto concurrencia test") + + monkeypatch.setattr("app.services.city_service.CityRepository.create", mock_create) + + with app.app_context(): + city, created = CityService.get_or_create_city("Puebla") + assert not created + assert city is not None + assert city.name == "Puebla" + mock_geocode.assert_called_once_with("Puebla") diff --git a/backend/tests/integration/test_ingestion_service.py b/backend/tests/integration/test_ingestion_service.py new file mode 100644 index 0000000..376211a --- /dev/null +++ b/backend/tests/integration/test_ingestion_service.py @@ -0,0 +1,210 @@ +import pytest +from unittest.mock import MagicMock + +from app.services.ingestion_service import IngestionService, MEXICO_NACIONAL_CITY_ID +from app.utils.errors import AppError + +def test_run_ingestion_stops_on_apperror_and_preserves_stats(monkeypatch): + """ run_ingestion detiene la paginación cuando AdzunaClient.get_jobs lanza AppError, preservando las estadísticas acumuladas hasta ese punto. """ + mock_get_jobs = MagicMock() + # Primera página devuelve 2 jobs. Segunda página lanza AppError. + mock_get_jobs.side_effect = [ + {"results": [{"description": "Job 1"}, {"description": "Job 2"}]}, + AppError("API Error") + ] + monkeypatch.setattr("app.services.ingestion_service.AdzunaClient.get_jobs", mock_get_jobs) + + mock_get_all_skills = MagicMock(return_value=[]) + monkeypatch.setattr("app.services.ingestion_service.SkillRepository.get_all", mock_get_all_skills) + + mock_process_job = MagicMock() + monkeypatch.setattr("app.services.ingestion_service.IngestionService._process_job", mock_process_job) + + stats = IngestionService.run_ingestion(pages=3) + + # Se intentaron 2 páginas antes de detenerse + assert mock_get_jobs.call_count == 2 + # El loop interno procesó los 2 jobs de la página 1 antes de detenerse + assert mock_process_job.call_count == 2 + assert stats["fetched"] == 2 + +def test_process_job_increments_duplicates_when_hash_exists(monkeypatch): + """ _process_job incrementa stats['duplicates'] y no llama a JobRepository.create cuando JobRepository.get_by_hash ya encuentra un hash existente. """ + mock_get_by_hash = MagicMock(return_value={"id": 1}) # Any truthy value implies it exists + monkeypatch.setattr("app.services.ingestion_service.JobRepository.get_by_hash", mock_get_by_hash) + + mock_create_job = MagicMock() + monkeypatch.setattr("app.services.ingestion_service.JobRepository.create", mock_create_job) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + item = {"description": "This is a duplicate job"} + IngestionService._process_job(item, {}, stats) + + assert stats["duplicates"] == 1 + mock_get_by_hash.assert_called_once() + mock_create_job.assert_not_called() + +def test_process_job_increments_errors_on_empty_description(monkeypatch): + """ _process_job incrementa stats['errors'] cuando la descripción del job está vacía, sin llegar a calcular el hash ni tocar el repositorio. """ + mock_get_by_hash = MagicMock() + monkeypatch.setattr("app.services.ingestion_service.JobRepository.get_by_hash", mock_get_by_hash) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + # Missing description + IngestionService._process_job({}, {}, stats) + + assert stats["errors"] == 1 + mock_get_by_hash.assert_not_called() + +def test_process_job_extracts_last_area_as_raw_location(monkeypatch): + """ _process_job extrae correctamente el último elemento de location.area como raw_location antes de resolver la ciudad. """ + monkeypatch.setattr("app.services.ingestion_service.JobRepository.get_by_hash", MagicMock(return_value=None)) + + mock_job = MagicMock() + mock_job.id = 99 + monkeypatch.setattr("app.services.ingestion_service.JobRepository.create", MagicMock(return_value=mock_job)) + monkeypatch.setattr("app.services.ingestion_service.SkillsExtractionService.extract_skills", MagicMock(return_value=[])) + + mock_resolve_city = MagicMock(return_value=(1, "Mock City")) + monkeypatch.setattr("app.services.ingestion_service.IngestionService._resolve_city", mock_resolve_city) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + item = { + "description": "Job desc", + "location": { + "area": ["Country", "State", "CitySpecific"] + } + } + + IngestionService._process_job(item, {}, stats) + + mock_resolve_city.assert_called_once_with("CitySpecific", stats, verbose=False) + +def test_resolve_city_returns_fallback(monkeypatch): + """ _resolve_city retorna MEXICO_NACIONAL_CITY_ID y registra stats['fallback'] cuando CityRepository.get_or_create_city retorna (None, False). """ + monkeypatch.setattr("app.services.ingestion_service.CityService.get_or_create_city", MagicMock(return_value=(None, False))) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + city_id, label = IngestionService._resolve_city("UnknownPlace", stats) + + assert city_id == MEXICO_NACIONAL_CITY_ID + assert stats["fallback"] == 1 + assert stats["cities_created"] == 0 + +def test_resolve_city_returns_id_and_increments_created_when_new(monkeypatch): + """ _resolve_city retorna el id real y registra stats['cities_created'] cuando CityRepository.get_or_create_city retorna una ciudad nueva (created=True). """ + mock_city = MagicMock() + mock_city.id = 42 + mock_city.name = "TestCity" + mock_city.state = "TestState" + + monkeypatch.setattr("app.services.ingestion_service.CityService.get_or_create_city", MagicMock(return_value=(mock_city, True))) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + city_id, label = IngestionService._resolve_city("TestCity", stats) + + assert city_id == 42 + assert stats["cities_created"] == 1 + assert stats["fallback"] == 0 + +def test_get_or_create_skill_reuses_id_case_insensitive(monkeypatch): + """ _get_or_create_skill reutiliza el id ya presente en known_skills sin llamar a SkillRepository.create cuando el skill ya existe en el diccionario en memoria (verificar case-insensitive). """ + mock_create_skill = MagicMock() + monkeypatch.setattr("app.services.ingestion_service.SkillRepository.create", mock_create_skill) + + known_skills = {"python": 100} + + # Testing case insensitivity: the dictionary key is "python", we pass "PyThOn" + skill_id = IngestionService._get_or_create_skill("PyThOn", known_skills) + + assert skill_id == 100 + mock_create_skill.assert_not_called() + +def test_is_remote_ignores_company_name_containing_remote(monkeypatch): + """ is_remote solo examina title y description. Un job cuya empresa se llama "RemoteWorks Solutions" pero cuyo título y descripción son presenciales NO debe marcarse como remoto. Verifica el comportamiento correcto tras la corrección del bug. """ + monkeypatch.setattr("app.services.ingestion_service.JobRepository.get_by_hash", MagicMock(return_value=None)) + + mock_job = MagicMock() + mock_job.id = 99 + mock_create_job = MagicMock(return_value=mock_job) + monkeypatch.setattr("app.services.ingestion_service.JobRepository.create", mock_create_job) + monkeypatch.setattr("app.services.ingestion_service.SkillsExtractionService.extract_skills", MagicMock(return_value=[])) + monkeypatch.setattr("app.services.ingestion_service.CityService.get_or_create_city", MagicMock(return_value=(None, False))) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + # Empresa con "Remote" en el nombre, pero título y descripción completamente presenciales. + item = { + "title": "Backend Developer (On-site)", + "description": "We need an on-site backend developer to work in our office in Monterrey.", + "company": { + "display_name": "RemoteWorks Solutions" + } + } + + IngestionService._process_job(item, {}, stats) + + job_data_passed = mock_create_job.call_args[0][0] + + # El nombre de la empresa NO debe influir en la detección de modalidad. + assert job_data_passed["remote"] is False + + +def test_is_remote_detects_genuine_remote_in_description(monkeypatch): + """ is_remote detecta correctamente 'remoto' como palabra completa dentro de la descripción. Caso positivo genuino: la modalidad remota sí está mencionada en el texto de la vacante. """ + monkeypatch.setattr("app.services.ingestion_service.JobRepository.get_by_hash", MagicMock(return_value=None)) + + mock_job = MagicMock() + mock_job.id = 100 + mock_create_job = MagicMock(return_value=mock_job) + monkeypatch.setattr("app.services.ingestion_service.JobRepository.create", mock_create_job) + monkeypatch.setattr("app.services.ingestion_service.SkillsExtractionService.extract_skills", MagicMock(return_value=[])) + monkeypatch.setattr("app.services.ingestion_service.CityService.get_or_create_city", MagicMock(return_value=(None, False))) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + item = { + "title": "Desarrollador Backend", + "description": "Puesto 100% remoto, no requiere presencia en oficina. Trabajo desde casa.", + "company": {"display_name": "TechCorp"} + } + + IngestionService._process_job(item, {}, stats) + + job_data_passed = mock_create_job.call_args[0][0] + + # La palabra 'remoto' en la descripción debe marcar el job como remoto. + assert job_data_passed["remote"] is True + + +def test_is_remote_does_not_match_partial_word_containing_remoto(monkeypatch): + """ is_remote usa \\b (límite de palabra), por lo que una cadena que contenga 'remoto' como subcadena de otra palabra (ej. 'remotorizado') NO debe disparar un falso positivo. """ + monkeypatch.setattr("app.services.ingestion_service.JobRepository.get_by_hash", MagicMock(return_value=None)) + + mock_job = MagicMock() + mock_job.id = 101 + mock_create_job = MagicMock(return_value=mock_job) + monkeypatch.setattr("app.services.ingestion_service.JobRepository.create", mock_create_job) + monkeypatch.setattr("app.services.ingestion_service.SkillsExtractionService.extract_skills", MagicMock(return_value=[])) + monkeypatch.setattr("app.services.ingestion_service.CityService.get_or_create_city", MagicMock(return_value=(None, False))) + + stats = {"fetched": 0, "processed": 0, "duplicates": 0, "errors": 0, "cities_created": 0, "fallback": 0} + + # 'remotorizado' contiene la subcadena 'remoto' pero no es la palabra 'remoto'. + item = { + "title": "Tecnico Electrico", + "description": "Se requiere conocimiento en sistemas remotorizado y control de motores.", + "company": {"display_name": "ElectroCorp"} + } + + IngestionService._process_job(item, {}, stats) + + job_data_passed = mock_create_job.call_args[0][0] + + # 'remotorizado' no debe confundirse con la palabra 'remoto'. + assert job_data_passed["remote"] is False diff --git a/backend/tests/integration/test_job_model_remote_column.py b/backend/tests/integration/test_job_model_remote_column.py new file mode 100644 index 0000000..8a0f261 --- /dev/null +++ b/backend/tests/integration/test_job_model_remote_column.py @@ -0,0 +1,39 @@ +import pytest +from app.models.job import Job + +def test_job_remote_defaults_to_false_when_not_specified(app, db_session): + """ crea un Job sin especificar remote, lo persiste, lo recupera con una query nueva y afirma que remote es False """ + job = Job( + source="Test Source", + title="Test Job Title", + raw_description="This is a test job description", + description_hash="hash1234567890abcdef" + ) + db_session.add(job) + db_session.commit() + + db_session.expire_all() + + with app.app_context(): + fetched_job = db_session.get(Job, job.id) + assert fetched_job is not None + assert fetched_job.remote is False + +def test_job_remote_persists_explicit_true_value(app, db_session): + """ crea un Job con remote=True explícito, lo persiste, lo recupera igual que el test anterior, y afirma que remote es True """ + job = Job( + source="Test Source 2", + title="Test Job Title 2", + raw_description="This is another test job description", + description_hash="hash0987654321fedcba", + remote=True + ) + db_session.add(job) + db_session.commit() + + db_session.expire_all() + + with app.app_context(): + fetched_job = db_session.get(Job, job.id) + assert fetched_job is not None + assert fetched_job.remote is True diff --git a/backend/tests/integration/test_jwt_revocation.py b/backend/tests/integration/test_jwt_revocation.py new file mode 100644 index 0000000..b9eb03f --- /dev/null +++ b/backend/tests/integration/test_jwt_revocation.py @@ -0,0 +1,147 @@ +import pytest +import jwt +from datetime import datetime, timezone, timedelta +from flask_jwt_extended import create_access_token, get_csrf_token, decode_token +from app.models.user import User + +def _mint_token_and_csrf(user_id, custom_iat=None, secret_key=None): + """ Genera un token de acceso real y extrae su CSRF. + Si custom_iat se proporciona, modifica manualmente el claim 'iat' decodificando y re-encodeando con el secret_key de la app, ya que flask_jwt_extended no expone un parametro directo para fijar 'iat'.Debe ser invocado dentro del contexto de la aplicacion (app.app_context()) """ + token = create_access_token(identity=str(user_id)) + + if custom_iat is not None and secret_key is not None: + decoded = decode_token(token) + decoded["iat"] = int(custom_iat.timestamp()) + token = jwt.encode(decoded, secret_key, algorithm="HS256") + + csrf = get_csrf_token(token) + return token, csrf + +def test_token_revoked_immediately_after_deactivation(app, db_session, client): + """ Verifica que un token emitido para un usuario activo deja de funcionar inmediatamente despues de que ese usuario es desactivado (soft-delete), debido a que check_if_token_revoked intercepta y revoca la sesion """ + # Creamos un usuario real en la base de datos (activo por defecto) + user = User( + email="test_revocation@example.com", + first_name="Test", + last_name="Revocation", + password_hash="dummy_hash_para_test", + role="REGISTERED", + is_active=True + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + + # Generamos el token y CSRF + with app.app_context(): + token, csrf = _mint_token_and_csrf(user.id) + + """ Configuramos el test client con la cookie y el header, los valores por defecto de flask-jwt-extended en el proyecto son: + a. Cookie name: access_token_cookie + b. CSRF Header: X-CSRF-TOKEN """ + client.set_cookie("access_token_cookie", token) + headers = {"X-CSRF-TOKEN": csrf} + + # Hacemos un GET a /api/auth/me y verificar que responde 200 (usuario activo) + response_active = client.get("/api/profile/me", headers=headers) + assert response_active.status_code == 200, "El token deberia funcionar para un usuario activo." + + # Modificamos el usuario, is_active = False y guardar + user.is_active = False + db_session.add(user) + db_session.commit() + + # Aplicamos OTRO GET a /api/auth/me con el MISMO client y header, sin generar token nuevo. El client ya tiene la cookie guardada desde el set_cookie anterior. + response_revoked = client.get("/api/profile/me", headers=headers) + assert response_revoked.status_code == 401, "El token debio ser rechazado porque el usuario esta inactivo." + + data = response_revoked.get_json() + assert data["error"]["code"] == "TOKEN_REVOKED", "El error debio ser especificamente TOKEN_REVOKED segun el handler revoked_token_loader." + +def test_token_revoked_after_password_change(app, db_session, client): + """ Verifica que un token emitido ANTES de que el usuario cambie su contrasena es revocado automaticamente """ + # Creamos un usuario cuyo password_changed_at fue hace 1 hora + past_password_change = datetime.now(timezone.utc) - timedelta(hours=1) + user = User( + email="test_pwd_revoked@example.com", + first_name="Test", + last_name="PwdRevoked", + password_hash="dummy_hash_para_test", + role="REGISTERED", + is_active=True, + password_changed_at=past_password_change + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + + # Generamos un token emitido hace 2 horas (ANTERIOR al cambio de contrasena) + token_issued_at = datetime.now(timezone.utc) - timedelta(hours=2) + with app.app_context(): + secret_key = app.config["JWT_SECRET_KEY"] + token, csrf = _mint_token_and_csrf(user.id, custom_iat=token_issued_at, secret_key=secret_key) + + client.set_cookie("access_token_cookie", token) + headers = {"X-CSRF-TOKEN": csrf} + + # El GET debe ser 401 con TOKEN_REVOKED + response = client.get("/api/profile/me", headers=headers) + assert response.status_code == 401, "El token viejo debio ser rechazado tras el cambio de contrasena." + assert response.get_json()["error"]["code"] == "TOKEN_REVOKED" + +def test_token_valid_after_password_change_if_issued_later(app, db_session, client): + """ Verifica que un token emitido DESPUES de un cambio de contrasena es valido y no se revoca erronamente """ + # Creamos un usuario cuyo password_changed_at fue hace 2 horas + past_password_change = datetime.now(timezone.utc) - timedelta(hours=2) + user = User( + email="test_pwd_valid@example.com", + first_name="Test", + last_name="PwdValid", + password_hash="dummy_hash_para_test", + role="REGISTERED", + is_active=True, + password_changed_at=past_password_change + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + + # Generamos un token emitido hace 1 hora (POSTERIOR al cambio de contrasena) + token_issued_at = datetime.now(timezone.utc) - timedelta(hours=1) + with app.app_context(): + secret_key = app.config["JWT_SECRET_KEY"] + token, csrf = _mint_token_and_csrf(user.id, custom_iat=token_issued_at, secret_key=secret_key) + + client.set_cookie("access_token_cookie", token) + headers = {"X-CSRF-TOKEN": csrf} + + # El GET debe ser 200 (token valido) + response = client.get("/api/profile/me", headers=headers) + assert response.status_code == 200, "El token nuevo debio ser aceptado." + +def test_oauth_only_user_never_revoked_by_password_change(app, db_session, client): + """ Verifica que un usuario exclusivamente OAuth (password_changed_at=None) nunca tiene sus tokens revocados por este mecanismo, sin importar el IAT """ + # Creamos un usuario sin contrasena propia (simulando OAuth puro) + user = User( + email="test_oauth_only@example.com", + first_name="Test", + last_name="OAuth", + password_hash=None, + password_changed_at=None, + role="REGISTERED", + is_active=True + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + + # Generamos un token normal (iat actual) + with app.app_context(): + token, csrf = _mint_token_and_csrf(user.id) + + client.set_cookie("access_token_cookie", token) + headers = {"X-CSRF-TOKEN": csrf} + + # El GET debe ser 200 (token valido) + response = client.get("/api/profile/me", headers=headers) + assert response.status_code == 200, "El token del usuario OAuth debio ser aceptado." diff --git a/backend/tests/integration/test_last_admin_protection.py b/backend/tests/integration/test_last_admin_protection.py new file mode 100644 index 0000000..b5ca00a --- /dev/null +++ b/backend/tests/integration/test_last_admin_protection.py @@ -0,0 +1,134 @@ +import pytest +from flask_jwt_extended import create_access_token, get_csrf_token +from app.models.user import User + +def _mint_token_and_csrf(user_id): + """ Genera un token de acceso real y extrae su CSRF. Duplicado deliberadamente aqui (principio DAMP) para mantener el archivo autocontenido sin afectar los tests previos ni crear acoplamiento artificial """ + token = create_access_token(identity=str(user_id)) + csrf = get_csrf_token(token) + return token, csrf + +def test_last_admin_cannot_demote_self(app, db_session, client): + """ Verifica que el unico administrador activo no puede quitarse el rol de ADMIN """ + user = User( + email="only_admin_role@example.com", + first_name="Test", + last_name="Admin", + password_hash="dummy", + role="ADMIN", + is_active=True + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user.id) + + client.set_cookie("access_token_cookie", token) + headers = {"X-CSRF-TOKEN": csrf} + + response = client.patch(f"/api/admin/users/{user.id}/role", json={"role": "REGISTERED"}, headers=headers) + + assert response.status_code == 403 + assert response.get_json()["error"]["code"] == "LAST_ADMIN_PROTECTED" + + updated_user = db_session.get(User, user.id) + assert updated_user.role == "ADMIN", "La operacion no debio mutar el rol del usuario." + +def test_last_admin_cannot_deactivate_self(app, db_session, client): + """ Verifica que el unico administrador activo no puede desactivar su propia cuenta """ + user = User( + email="only_admin_status@example.com", + first_name="Test", + last_name="Admin", + password_hash="dummy", + role="ADMIN", + is_active=True + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user.id) + + client.set_cookie("access_token_cookie", token) + headers = {"X-CSRF-TOKEN": csrf} + + response = client.patch(f"/api/admin/users/{user.id}/status", json={"is_active": False}, headers=headers) + + assert response.status_code == 403 + assert response.get_json()["error"]["code"] == "LAST_ADMIN_PROTECTED" + + updated_user = db_session.get(User, user.id) + assert updated_user.is_active is True, "La operacion no debio mutar el status del usuario." + +def test_admin_can_demote_self_if_another_admin_active(app, db_session, client): + """ Verifica que un administrador si puede quitarse el rol si existe al menos otro administrador activo en el sistema """ + user1 = User( + email="admin1_self_demote@example.com", + first_name="Admin", + last_name="One", + password_hash="dummy", + role="ADMIN", + is_active=True + ) + user2 = User( + email="admin2_active@example.com", + first_name="Admin", + last_name="Two", + password_hash="dummy", + role="ADMIN", + is_active=True + ) + db_session.add_all([user1, user2]) + db_session.commit() + db_session.refresh(user1) + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user1.id) + + client.set_cookie("access_token_cookie", token) + headers = {"X-CSRF-TOKEN": csrf} + + response = client.patch(f"/api/admin/users/{user1.id}/role", json={"role": "REGISTERED"}, headers=headers) + + assert response.status_code == 200 + updated_user = db_session.get(User, user1.id) + assert updated_user.role == "REGISTERED", "El rol debio cambiar a REGISTERED." + +def test_admin_can_demote_another_admin_even_if_last_active(app, db_session, client): + """ Verifica y documenta una asimetria del diseño actual, la proteccion de ultimo admin solo aplica para la auto-modificacion. Si el actor modifica a un tercero, la proteccion no aplica, incluso si eso dejara tecnicamente a un unico admin (o cero admins) si no hay mas activos. En este test, el actor degrada al target y la operacion procede (200 OK) """ + user_actor = User( + email="admin_actor@example.com", + first_name="Actor", + last_name="Admin", + password_hash="dummy", + role="ADMIN", + is_active=True + ) + user_target = User( + email="admin_target@example.com", + first_name="Target", + last_name="Admin", + password_hash="dummy", + role="ADMIN", + is_active=True + ) + db_session.add_all([user_actor, user_target]) + db_session.commit() + db_session.refresh(user_actor) + db_session.refresh(user_target) + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user_actor.id) + + client.set_cookie("access_token_cookie", token) + headers = {"X-CSRF-TOKEN": csrf} + + response = client.patch(f"/api/admin/users/{user_target.id}/role", json={"role": "REGISTERED"}, headers=headers) + + assert response.status_code == 200 + updated_user_target = db_session.get(User, user_target.id) + assert updated_user_target.role == "REGISTERED", "El rol del target debio cambiar a REGISTERED sin importar el limite de admins." diff --git a/backend/tests/integration/test_market_trends_service.py b/backend/tests/integration/test_market_trends_service.py new file mode 100644 index 0000000..a302f0a --- /dev/null +++ b/backend/tests/integration/test_market_trends_service.py @@ -0,0 +1,257 @@ +import pytest +from datetime import datetime, timezone, timedelta +from decimal import Decimal + +from app.models.user import User +from app.models.city import City +from app.models.category import Category +from app.models.skill import Skill +from app.models.job import Job +from app.models.job_skill import JobSkill +from app.models.trend_snapshot import TrendSnapshot +from app.repositories.trend_snapshot_repository import TrendSnapshotRepository +from app.services.market_trends_service import MarketTrendsService +from app.extensions import db as _db + + +# Helpers de creacion de entidades. +# Duplicados deliberadamente aqui (principio DAMP): cada archivo de tests es autocontenido. No se importa desde otros archivos de tests para preservar aislamiento y legibilidad individual. + +def _make_city(db_session, city_id, name="Mexico Nacional", state="Nacional"): + """ Crea una City con un ID especifico usando INSERT directo para poder + controlar el id=1 que necesita el fallback de generate_snapshots(). Usa + INSERT con id explicito en lugar de add() para garantizar el id exacto. + + Si ya existe una City con ese id (p.ej. la fila sembrada por la migración + 9a104cdbdaed para México Nacional), la retorna directamente sin intentar + re-insertarla. Esto hace la función idempotente frente al esquema base + que ya contiene id=1 tras flask db upgrade. """ + existing = db_session.get(City, city_id) + if existing: + return existing + city = City(id=city_id, name=name, state=state) + db_session.add(city) + db_session.flush() + return city + + +def _make_category(db_session, name="Programacion"): + cat = Category(name=name) + db_session.add(cat) + db_session.flush() + return cat + + +def _make_skill(db_session, category_id, name="Python"): + skill = Skill(name=name, canonical_name=name.lower(), category_id=category_id) + db_session.add(skill) + db_session.flush() + return skill + + +def _make_job(db_session, city_id, description_hash=None, salary_min=None, salary_max=None): + """ Crea un Job con created_at reciente (ahora mismo en UTC) para que JobSkillRepository.get_active(days=30) lo incluya siempre """ + import uuid + hash_val = description_hash or str(uuid.uuid4()) + job = Job( + source="test", + title="Test Job", + company="Test Co", + city_id=city_id, + salary_min=salary_min, + salary_max=salary_max, + raw_description="raw", + description_hash=hash_val, + processed=True, + created_at=datetime.now(timezone.utc), + ) + db_session.add(job) + db_session.flush() + return job + + +def _make_job_skill(db_session, job_id, skill_id, confidence_score=0.9): + js = JobSkill( + job_id=job_id, + skill_id=skill_id, + confidence_score=confidence_score, + ) + db_session.add(js) + db_session.flush() + return js + + +def _make_prev_snapshot(db_session, skill_id, city_id, demand_count, days_ago=7): + """ Inserta un TrendSnapshot en la fecha exacta que usa generate_snapshots() como referencia de crecimiento: hoy - 7 dias """ + target_date = datetime.now(timezone.utc).date() - timedelta(days=days_ago) + snap = TrendSnapshot( + skill_id=skill_id, + city_id=city_id, + date=target_date, + demand_count=demand_count, + growth_rate=None, + avg_salary=None, + ) + db_session.add(snap) + db_session.commit() + return snap + + +def _get_today_snapshot(db_session, skill_id, city_id): + """ Recupera el snapshot generado para hoy para la combinacion skill+city """ + today = datetime.now(timezone.utc).date() + return db_session.execute( + _db.select(TrendSnapshot).filter_by( + skill_id=skill_id, city_id=city_id, date=today + ) + ).scalar_one_or_none() + + +# Tests: MarketTrendsService.generate_snapshots() - escenarios de growth_rate + +def test_growth_rate_none_when_no_previous_snapshot(app, db_session): + """ Si no existe TrendSnapshot previo en (hoy - 7 dias) para la combinacion skill_id+city_id, growth_rate debe ser None. No hay division, no hay base de comparacion """ + city = _make_city(db_session, city_id=1) + cat = _make_category(db_session, name="Cat_NoPrev") + skill = _make_skill(db_session, cat.id, name="Skill_NoPrev") + skill_id = skill.id + city_id = city.id + + # Creamos un Job+JobSkill reciente - sin snapshot previo en hoy-7dias + job = _make_job(db_session, city_id=city_id) + _make_job_skill(db_session, job.id, skill_id) + + # Sin snapshot previo en hoy-7dias para esta combinacion - confirmamos + target_date = datetime.now(timezone.utc).date() - timedelta(days=7) + prev = db_session.execute( + _db.select(TrendSnapshot).filter_by( + skill_id=skill_id, city_id=city_id, date=target_date + ) + ).scalar_one_or_none() + assert prev is None, "Prerequisito: no debe existir snapshot previo para este test" + + with app.app_context(): + MarketTrendsService.generate_snapshots() + + snapshot = _get_today_snapshot(db_session, skill_id, city_id) + assert snapshot is not None, "generate_snapshots() debe haber creado el snapshot de hoy" + assert snapshot.growth_rate is None, ( + f"Sin snapshot previo, growth_rate debe ser None, pero fue {snapshot.growth_rate}" + ) + + +def test_growth_rate_none_when_previous_demand_count_is_zero(app, db_session): + """ Si el snapshot previo existe pero su demand_count es 0, growth_rate debe ser None para evitar division por cero. La condicion en el codigo es: 'if not prev_snapshot or not prev_snapshot.demand_count' """ + city = _make_city(db_session, city_id=1) + cat = _make_category(db_session, name="Cat_ZeroPrev") + skill = _make_skill(db_session, cat.id, name="Skill_ZeroPrev") + skill_id = skill.id + city_id = city.id + + # Snapshot previo con demand_count=0 — activa la guarda de division por cero + _make_prev_snapshot(db_session, skill_id, city_id, demand_count=0) + + job = _make_job(db_session, city_id=city_id) + _make_job_skill(db_session, job.id, skill_id) + + with app.app_context(): + MarketTrendsService.generate_snapshots() + + snapshot = _get_today_snapshot(db_session, skill_id, city_id) + assert snapshot is not None + assert snapshot.growth_rate is None, ( + f"Con demand_count previo=0, growth_rate debe ser None, pero fue {snapshot.growth_rate}" + ) + + +def test_growth_rate_calculated_within_normal_range(app, db_session): + """ Calculo normal de growth_rate dentro del rango sin cap. Setup: previo demand_count=10, hoy demand_count=15. Formula: ((15 - 10) / 10) * 100 = 50.0 Valor exacto esperado: Decimal('50.00') (almacenado como Numeric(6,2)) """ + city = _make_city(db_session, city_id=1) + cat = _make_category(db_session, name="Cat_Normal") + skill = _make_skill(db_session, cat.id, name="Skill_Normal") + skill_id = skill.id + city_id = city.id + + # Snapshot previo con demand_count=10 + _make_prev_snapshot(db_session, skill_id, city_id, demand_count=10) + + # 15 Jobs+JobSkills recientes - demand_count de hoy sera 15 + for i in range(15): + job = _make_job(db_session, city_id=city_id) + _make_job_skill(db_session, job.id, skill_id) + + with app.app_context(): + MarketTrendsService.generate_snapshots() + + snapshot = _get_today_snapshot(db_session, skill_id, city_id) + assert snapshot is not None + assert snapshot.demand_count == 15 + + # Valor exacto: round(((15 - 10) / 10) * 100, 2) = 50.0 => Numeric(6,2) => Decimal('50.00') + expected = Decimal("50.00") + assert snapshot.growth_rate == expected, ( + f"growth_rate esperado {expected}, obtenido {snapshot.growth_rate}" + ) + + +def test_growth_rate_capped_at_positive_999_99(app, db_session): + """ El cap positivo se aplica cuando el crecimiento calculado excede 999.99. Setup: previo demand_count=1, hoy demand_count=50. Formula sin cap: ((50 - 1) / 1) * 100 = 4900.0 → excede 999.99. Resultado esperado tras cap: Decimal('999.99'). """ + city = _make_city(db_session, city_id=1) + cat = _make_category(db_session, name="Cat_CapPos") + skill = _make_skill(db_session, cat.id, name="Skill_CapPos") + skill_id = skill.id + city_id = city.id + + # Snapshot previo con demand_count=1 (base minima para crecimiento explosivo) + _make_prev_snapshot(db_session, skill_id, city_id, demand_count=1) + + # 50 Jobs recientes - da growth_rate=(49/1)*100=4900 antes del cap + for i in range(50): + job = _make_job(db_session, city_id=city_id) + _make_job_skill(db_session, job.id, skill_id) + + with app.app_context(): + MarketTrendsService.generate_snapshots() + + snapshot = _get_today_snapshot(db_session, skill_id, city_id) + assert snapshot is not None + assert snapshot.demand_count == 50 + + # Sin cap: 4900.0, con cap: 999.99 + expected = Decimal("999.99") + assert snapshot.growth_rate == expected, ( + f"Cap positivo esperado {expected}, obtenido {snapshot.growth_rate}" + ) + + +def test_growth_rate_negative_extreme_real_case(app, db_session): + """ El cap negativo de -999.99 es MATEMATICAMENTE INALCANZABLE con datos reales: demand_count es siempre >= 0 (es un conteo de vacantes), y el peor caso posible es nuevo=0 Jobs activos, pero si no hay Jobs activos, generate_snapshots() retorna 0 sin crear ningun snapshot (la guarda 'if not raw_data: return 0'). Por lo tanto, el demand_count mas bajo fisicamente posible en un snapshot generado es 1, lo que da como caida maxima ((1 - previo) / previo) * 100. Con previo=100 y nuevo=1: + ((1 - 100) / 100) * 100 = -99.0%, muy lejos del -999.99% del cap. + + Este test verifica el caso mas extremo REAL: previo=100, nuevo=1, -99.00%. El cap negativo queda sin cobertura real posible y se documenta aqui explicitamente como decision de diseno auditada, no como omision. """ + city = _make_city(db_session, city_id=1) + cat = _make_category(db_session, name="Cat_CapNeg") + skill = _make_skill(db_session, cat.id, name="Skill_CapNeg") + skill_id = skill.id + city_id = city.id + + # Snapshot previo con demand_count=100 (base alta para caida dramatica) + _make_prev_snapshot(db_session, skill_id, city_id, demand_count=100) + + # Solo 1 Job activo - da la caida mas pronunciada fisicamente posible + job = _make_job(db_session, city_id=city_id) + _make_job_skill(db_session, job.id, skill_id) + + with app.app_context(): + MarketTrendsService.generate_snapshots() + + snapshot = _get_today_snapshot(db_session, skill_id, city_id) + assert snapshot is not None + assert snapshot.demand_count == 1 + + # Formula exacta: round(((1 - 100) / 100) * 100, 2) = -99.0 + # Numeric(6,2) almacena como Decimal('-99.00') + expected = Decimal("-99.00") + assert snapshot.growth_rate == expected, ( + f"Caida extrema real esperada {expected}, obtenida {snapshot.growth_rate}" + ) diff --git a/backend/tests/integration/test_nominatim_client.py b/backend/tests/integration/test_nominatim_client.py new file mode 100644 index 0000000..a5ee230 --- /dev/null +++ b/backend/tests/integration/test_nominatim_client.py @@ -0,0 +1,184 @@ +import pytest +from unittest.mock import MagicMock +from requests.exceptions import RequestException + +from app.clients.nominatim_client import NominatimClient + +@pytest.fixture +def mock_sleep(monkeypatch): + mock = MagicMock() + monkeypatch.setattr("app.clients.nominatim_client.time.sleep", mock) + return mock + +def test_geocode_city_returns_valid_data(monkeypatch, mock_sleep): + """ geocode_city retorna un dict con name/state/lat/lon cuando la respuesta de Nominatim es válida y tiene un tipo de lugar en VALID_TYPES. """ + mock_response = MagicMock() + mock_response.json.return_value = [{ + "type": "city", + "lat": "19.4326", + "lon": "-99.1332", + "name": "Ciudad de México", + "address": { + "city": "Ciudad de México", + "state": "Ciudad de México" + } + }] + mock_get = MagicMock(return_value=mock_response) + monkeypatch.setattr("app.clients.nominatim_client.requests.get", mock_get) + + result = NominatimClient.geocode_city("Ciudad de México") + + assert result == { + "name": "Ciudad de México", + "state": "Ciudad de México", + "lat": 19.4326, + "lon": -99.1332 + } + mock_sleep.assert_called_once_with(1.1) + +def test_geocode_city_returns_none_when_empty_response(monkeypatch, mock_sleep): + """ geocode_city retorna None cuando la respuesta de Nominatim viene vacía (lista vacía). """ + mock_response = MagicMock() + mock_response.json.return_value = [] + monkeypatch.setattr("app.clients.nominatim_client.requests.get", MagicMock(return_value=mock_response)) + + result = NominatimClient.geocode_city("Ciudad Fantasma") + assert result is None + +def test_geocode_city_returns_none_when_missing_state(monkeypatch, mock_sleep): + """ geocode_city retorna None cuando el resultado no tiene un state en address (caso de dato insuficiente). """ + mock_response = MagicMock() + mock_response.json.return_value = [{ + "type": "city", + "lat": "19.4326", + "lon": "-99.1332", + "name": "Ciudad de México", + "address": { + "city": "Ciudad de México" + # No state + } + }] + monkeypatch.setattr("app.clients.nominatim_client.requests.get", MagicMock(return_value=mock_response)) + + result = NominatimClient.geocode_city("Ciudad sin estado") + assert result is None + +def test_geocode_city_returns_none_when_invalid_type(monkeypatch, mock_sleep): + """ geocode_city retorna None cuando el tipo de lugar (type/class/addresstype) no está en VALID_TYPES. """ + mock_response = MagicMock() + mock_response.json.return_value = [{ + "type": "residential", # Invalid type + "lat": "19.4326", + "lon": "-99.1332", + "name": "Colonia X", + "address": { + "city": "Ciudad", + "state": "Estado" + } + }] + monkeypatch.setattr("app.clients.nominatim_client.requests.get", MagicMock(return_value=mock_response)) + + result = NominatimClient.geocode_city("Colonia") + assert result is None + +def test_geocode_city_applies_disambiguation(monkeypatch, mock_sleep): + """ geocode_city aplica la desambiguación del diccionario QUERY_DISAMBIGUATION correctamente. """ + mock_response = MagicMock() + mock_response.json.return_value = [{ + "type": "city", + "lat": "19.4326", + "lon": "-99.1332", + "name": "Ciudad de México", + "address": { + "city": "Ciudad de México", + "state": "Ciudad de México" + } + }] + mock_get = MagicMock(return_value=mock_response) + monkeypatch.setattr("app.clients.nominatim_client.requests.get", mock_get) + + result = NominatimClient.geocode_city("cdmx") + + assert result is not None + called_params = mock_get.call_args[1]["params"] + assert called_params["q"] == "Ciudad de Mexico" + +def test_geocode_city_normalizes_and_applies_disambiguation(monkeypatch, mock_sleep): + """ geocode_city normaliza el query eliminando acentos y puntos antes de buscar en el diccionario de desambiguación. """ + mock_response = MagicMock() + mock_response.json.return_value = [{ + "type": "city", + "lat": "19.4326", + "lon": "-99.1332", + "name": "Ciudad de México", + "address": { + "city": "Ciudad de México", + "state": "Ciudad de México" + } + }] + mock_get = MagicMock(return_value=mock_response) + monkeypatch.setattr("app.clients.nominatim_client.requests.get", mock_get) + + result = NominatimClient.geocode_city(" méxico d.f. ") + + assert result is not None + called_params = mock_get.call_args[1]["params"] + assert called_params["q"] == "Ciudad de Mexico" + +def test_geocode_city_returns_none_on_request_exception(monkeypatch, mock_sleep): + """ geocode_city retorna None cuando requests.get lanza una excepción de tipo RequestException. """ + mock_get = MagicMock(side_effect=RequestException("Timeout!")) + monkeypatch.setattr("app.clients.nominatim_client.requests.get", mock_get) + + result = NominatimClient.geocode_city("Cualquier cosa") + assert result is None + +def test_geocode_city_returns_none_on_value_error(monkeypatch, mock_sleep): + """ geocode_city retorna None cuando la respuesta no es JSON válido (ValueError al parsear). """ + mock_response = MagicMock() + mock_response.json.side_effect = ValueError("Invalid JSON") + monkeypatch.setattr("app.clients.nominatim_client.requests.get", MagicMock(return_value=mock_response)) + + result = NominatimClient.geocode_city("Cualquier cosa") + assert result is None + +def test_geocode_city_uses_name_fallback_priority(monkeypatch, mock_sleep): + """ Verifica que geocode_city usa como name el primer campo disponible en el orden: city -> town -> village -> municipality -> name """ + mock_response = MagicMock() + mock_response.json.return_value = [{ + "type": "city", + "lat": "19.4326", + "lon": "-99.1332", + "name": "Result Name", + "address": { + "state": "Estado", + "municipality": "Muni", + "village": "Aldea", + "town": "Pueblo", + "city": "Ciudad Principal" + } + }] + monkeypatch.setattr("app.clients.nominatim_client.requests.get", MagicMock(return_value=mock_response)) + + result = NominatimClient.geocode_city("Test Priority") + assert result["name"] == "Ciudad Principal" + + # Caso sin city, debe usar town + mock_response.json.return_value[0]["address"].pop("city") + result = NominatimClient.geocode_city("Test Priority") + assert result["name"] == "Pueblo" + + # Caso sin town, debe usar village + mock_response.json.return_value[0]["address"].pop("town") + result = NominatimClient.geocode_city("Test Priority") + assert result["name"] == "Aldea" + + # Caso sin village, debe usar municipality + mock_response.json.return_value[0]["address"].pop("village") + result = NominatimClient.geocode_city("Test Priority") + assert result["name"] == "Muni" + + # Caso sin municipality, debe usar name de result + mock_response.json.return_value[0]["address"].pop("municipality") + result = NominatimClient.geocode_city("Test Priority") + assert result["name"] == "Result Name" diff --git a/backend/tests/integration/test_panorama_endpoints.py b/backend/tests/integration/test_panorama_endpoints.py new file mode 100644 index 0000000..b012ec3 --- /dev/null +++ b/backend/tests/integration/test_panorama_endpoints.py @@ -0,0 +1,313 @@ +import uuid +import pytest +from sqlalchemy import event +from datetime import datetime, timezone, timedelta + +from app.models.city import City +from app.models.category import Category +from app.models.skill import Skill +from app.models.job import Job +from app.models.job_skill import JobSkill +from app.models.trend_snapshot import TrendSnapshot +from app.extensions import db as _db + +@pytest.fixture +def query_counter(app): + counts = {"n": 0} + def on_execute(conn, cursor, statement, parameters, context, executemany): + counts["n"] += 1 + event.listen(_db.engine, "before_cursor_execute", on_execute) + yield counts + event.remove(_db.engine, "before_cursor_execute", on_execute) + +# Helpers de creacion de entidades. +# Duplicados deliberadamente aqui (principio DAMP): cada archivo de tests es autocontenido. No se importan desde otros archivos de tests. + +def _make_city(db_session, city_id=2, name="Ciudad Test", state="Estado Test"): + """ Crea una City con id explicito. Usamos city_id!=1 por defecto en este archivo para evitar colision con el fallback de generate_snapshots() que usa city_id=1. Los endpoints de panorama no invocan ese fallback, pero es buena practica no depender de ese ID. """ + city = City(id=city_id, name=name, state=state) + db_session.add(city) + db_session.flush() + return city + +def _make_category(db_session, name="Programacion"): + cat = Category(name=name) + db_session.add(cat) + db_session.flush() + return cat + +def _make_skill(db_session, category_id, name="Python"): + skill = Skill(name=name, canonical_name=name.lower(), category_id=category_id) + db_session.add(skill) + db_session.flush() + return skill + +def _make_job(db_session, city_id, salary_min=None, salary_max=None): + job = Job( + source="test", + title="Test Job", + company="Test Co", + city_id=city_id, + salary_min=salary_min, + salary_max=salary_max, + raw_description="raw", + description_hash=str(uuid.uuid4()), + processed=True, + created_at=datetime.now(timezone.utc), + ) + db_session.add(job) + db_session.flush() + return job + +def _make_job_skill(db_session, job_id, skill_id, confidence_score=0.9): + js = JobSkill( + job_id=job_id, + skill_id=skill_id, + confidence_score=confidence_score, + ) + db_session.add(js) + db_session.flush() + return js + +def _make_snapshot(db_session, skill_id, city_id, demand_count=10, + days_ago=0, growth_rate=None, avg_salary=None): + """ Inserta un TrendSnapshot directamente (sin pasar por generate_snapshots). days_ago=0 => fecha de hoy; days_ago=7 => hace 7 dias. """ + snap_date = datetime.now(timezone.utc).date() - timedelta(days=days_ago) + snap = TrendSnapshot( + skill_id=skill_id, + city_id=city_id, + date=snap_date, + demand_count=demand_count, + growth_rate=growth_rate, + avg_salary=avg_salary, + ) + db_session.add(snap) + db_session.commit() + return snap + +def _setup_compare_skills(db_session, num_skills=2, base_id=40): + city = _make_city(db_session, city_id=base_id, name=f"City_Compare_{base_id}") + cat = _make_category(db_session, name=f"Cat_Compare_{base_id}") + skill_ids = [] + for i in range(num_skills): + skill = _make_skill(db_session, cat.id, name=f"Skill_Compare_{base_id}_{i}") + _make_snapshot(db_session, skill.id, city.id, demand_count=(i+1)*5) + skill_ids.append(skill.id) + return skill_ids + +# GET /api/panorama/skills + +def test_get_skills_returns_all(app, db_session, client): + """ Crear dos skills reales. Verificar que ambas aparecen en la respuesta con 200. Se verifica por id, no por conteo exacto, porque otros tests dentro de la misma sesion pueden haber insertado skills adicionales.""" + cat = _make_category(db_session, name="Cat_Skills") + skill_a = _make_skill(db_session, cat.id, name="SkillA_GetAll") + skill_b = _make_skill(db_session, cat.id, name="SkillB_GetAll") + skill_a_id = skill_a.id + skill_b_id = skill_b.id + + response = client.get("/api/panorama/skills") + + assert response.status_code == 200 + returned_ids = {s["id"] for s in response.get_json()["data"]} + assert skill_a_id in returned_ids + assert skill_b_id in returned_ids + + +# GET /api/panorama/catalogs + +def test_get_catalogs_returns_skills_and_cities(app, db_session, client): + """ Crear una city y un skill reales. Verificar 200 y que la respuesta contiene ambas listas con los campos id+name esperados. """ + city = _make_city(db_session, city_id=10, name="City_Catalogs") + cat = _make_category(db_session, name="Cat_Catalogs") + skill = _make_skill(db_session, cat.id, name="Skill_Catalogs") + city_id = city.id + skill_id = skill.id + + response = client.get("/api/panorama/catalogs") + + assert response.status_code == 200 + data = response.get_json()["data"] + + assert "skills" in data + assert "cities" in data + + returned_skill_ids = {s["id"] for s in data["skills"]} + returned_city_ids = {c["id"] for c in data["cities"]} + + assert skill_id in returned_skill_ids + assert city_id in returned_city_ids + + any_skill = next(s for s in data["skills"] if s["id"] == skill_id) + assert "name" in any_skill + any_city = next(c for c in data["cities"] if c["id"] == city_id) + assert "name" in any_city + +# GET /api/panorama/summary + +def test_get_summary_returns_kpis(app, db_session, client): + """ Crear datos minimos (job, skill, snapshot) y verificar que summary retorna 200 con los campos KPI estructurales esperados. No se verifican valores exactos de agregacion porque otros tests de la sesion pueden haber insertado datos adicionales — verificamos estructura y >= minimos. """ + city = _make_city(db_session, city_id=20, name="City_Summary") + cat = _make_category(db_session, name="Cat_Summary") + skill = _make_skill(db_session, cat.id, name="Skill_Summary") + _make_job(db_session, city_id=city.id) + _make_snapshot(db_session, skill.id, city.id, demand_count=5) + + response = client.get("/api/panorama/summary") + + assert response.status_code == 200 + data = response.get_json()["data"] + + assert "total_jobs" in data + assert "total_skills_tracked" in data + assert "total_companies" in data + assert data["total_jobs"] >= 1 + assert data["total_skills_tracked"] >= 1 + +# GET /api/panorama/skills/top + +def test_get_skills_top_respects_limit_bounds(app, client): + """ Verificar que limit se acota segun max(1, min(limit, 50)) del codigo. No se necesitan 50 snapshots reales; verificamos que el endpoint responde 200 para ambos extremos y devuelve una lista valida. """ + # limit=0 => max(1, min(0, 50)) = 1 - no debe fallar + resp_zero = client.get("/api/panorama/skills/top?limit=0") + assert resp_zero.status_code == 200 + assert isinstance(resp_zero.get_json()["data"], list) + + # limit=1000 => max(1, min(1000, 50)) = 50 - no debe fallar + resp_over = client.get("/api/panorama/skills/top?limit=1000") + assert resp_over.status_code == 200 + assert isinstance(resp_over.get_json()["data"], list) + +# GET /api/panorama/trends + +def test_get_trends_requires_skill_id(app, client): + """ GET sin parametro skill_id debe retornar 422 VALIDATION_ERROR. """ + response = client.get("/api/panorama/trends") + + assert response.status_code == 422 + assert response.get_json()["error"]["code"] == "VALIDATION_ERROR" + +def test_get_trends_returns_404_for_nonexistent_skill(app, client): + """ GET con skill_id inexistente debe retornar 404 NOT_FOUND. """ + response = client.get("/api/panorama/trends?skill_id=999999") + + assert response.status_code == 404 + assert response.get_json()["error"]["code"] == "NOT_FOUND" + +def test_get_trends_returns_series_for_valid_skill(app, db_session, client): + """ Crear skill con un trend_snapshot. Verificar 200 y que la serie temporal incluye la fecha del snapshot insertado. """ + city = _make_city(db_session, city_id=30, name="City_Trends") + cat = _make_category(db_session, name="Cat_Trends") + skill = _make_skill(db_session, cat.id, name="Skill_Trends") + snap = _make_snapshot(db_session, skill.id, city.id, demand_count=7) + skill_id = skill.id + snap_date = snap.date + + response = client.get(f"/api/panorama/trends?skill_id={skill_id}") + + assert response.status_code == 200 + data = response.get_json()["data"] + assert data["skill_id"] == skill_id + assert "series" in data + + series_dates = [s["date"] for s in data["series"]] + assert str(snap_date) in series_dates + +# GET /api/panorama/geo + +def test_get_geo_rejects_invalid_group_by(app, client): + """ group_by distinto de 'city' o 'state' debe retornar 422. """ + response = client.get("/api/panorama/geo?group_by=invalid") + + assert response.status_code == 422 + assert response.get_json()["error"]["code"] == "VALIDATION_ERROR" + +def test_get_geo_returns_404_for_nonexistent_skill(app, client): + """ skill_id inexistente debe retornar 404 NOT_FOUND. """ + response = client.get("/api/panorama/geo?skill_id=999999") + + assert response.status_code == 404 + assert response.get_json()["error"]["code"] == "NOT_FOUND" + +def test_get_geo_returns_200_without_skill_filter(app, client): + """ Sin skill_id el endpoint debe retornar 200 con distribucion global (puede estar vacia si no hay snapshots, pero no debe fallar). """ + response = client.get("/api/panorama/geo") + + assert response.status_code == 200 + data = response.get_json()["data"] + assert "distribution" in data + assert isinstance(data["distribution"], list) + +# GET /api/panorama/salaries + +def test_get_salaries_requires_skill_id(app, client): + """ GET sin skill_id debe retornar 422 VALIDATION_ERROR. """ + response = client.get("/api/panorama/salaries") + + assert response.status_code == 422 + assert response.get_json()["error"]["code"] == "VALIDATION_ERROR" + +def test_get_salaries_returns_404_for_nonexistent_skill(app, client): + """ skill_id inexistente debe retornar 404 NOT_FOUND. """ + response = client.get("/api/panorama/salaries?skill_id=999999") + + assert response.status_code == 404 + assert response.get_json()["error"]["code"] == "NOT_FOUND" + +# GET /api/panorama/compare + +def test_get_compare_requires_between_2_and_5_skills(app, db_session, client): + """ Probar con 1 skill (menos de 2) y con 6 skills (mas de 5). Ambos deben retornar 422 VALIDATION_ERROR. """ + cat = _make_category(db_session, name="Cat_Compare_Bounds") + skills = [_make_skill(db_session, cat.id, name=f"Skill_Bound_{i}") for i in range(6)] + ids = [s.id for s in skills] + + # Un solo skill - menor al minimo de 2 + resp_one = client.get(f"/api/panorama/compare?skill_ids={ids[0]}") + assert resp_one.status_code == 422 + assert resp_one.get_json()["error"]["code"] == "VALIDATION_ERROR" + + # Seis skills - excede el maximo de 5 + ids_str = ",".join(str(i) for i in ids) + resp_six = client.get(f"/api/panorama/compare?skill_ids={ids_str}") + assert resp_six.status_code == 422 + assert resp_six.get_json()["error"]["code"] == "VALIDATION_ERROR" + +def test_get_compare_returns_404_when_any_skill_missing(app, db_session, client): + """ Un skill real + un id inexistente debe retornar 404 NOT_FOUND. """ + cat = _make_category(db_session, name="Cat_Compare_404") + skill = _make_skill(db_session, cat.id, name="Skill_Compare_404") + skill_id = skill.id + + response = client.get(f"/api/panorama/compare?skill_ids={skill_id},999999") + + assert response.status_code == 404 + assert response.get_json()["error"]["code"] == "NOT_FOUND" + +def test_get_compare_success_with_valid_skills(app, db_session, client): + """ Crear dos skills con al menos un snapshot cada uno. Verificar 200 y que la respuesta incluye un bloque por cada skill solicitado. """ + skill_ids = _setup_compare_skills(db_session, num_skills=2, base_id=40) + id_a, id_b = skill_ids + + response = client.get(f"/api/panorama/compare?skill_ids={id_a},{id_b}") + + assert response.status_code == 200 + data = response.get_json()["data"] + assert "skills" in data + + returned_skill_ids = {block["skill_id"] for block in data["skills"]} + assert id_a in returned_skill_ids + assert id_b in returned_skill_ids + +def test_get_compare_query_count_baseline_before_optimization( + client, db_session, query_counter +): + """ Test de caracterización: documenta el número EXACTO de queries que get_compare ejecuta hoy con 5 skills. """ + skill_ids = _setup_compare_skills(db_session, num_skills=5, base_id=50) + ids_str = ",".join(str(i) for i in skill_ids) + + query_counter["n"] = 0 + response = client.get(f"/api/panorama/compare?skill_ids={ids_str}") + + assert response.status_code == 200 + + assert query_counter["n"] == 3 diff --git a/backend/tests/integration/test_role_required.py b/backend/tests/integration/test_role_required.py new file mode 100644 index 0000000..69fb057 --- /dev/null +++ b/backend/tests/integration/test_role_required.py @@ -0,0 +1,89 @@ +import pytest +from flask_jwt_extended import create_access_token, get_csrf_token +from app.models.user import User + +def _mint_token_and_csrf(user_id): + """ Genera un token de acceso real y extrae su CSRF. Duplicado deliberadamente aqui (principio DAMP) para mantener el archivo autocontenido sin afectar los tests de revocacion previos ni crear acoplamiento artificial con ellos """ + token = create_access_token(identity=str(user_id)) + csrf = get_csrf_token(token) + return token, csrf + +def test_admin_endpoint_rejects_missing_token(client): + """ Hacer un GET a /api/admin/users SIN ninguna cookie ni header. Verificar 401 con code == "UNAUTHORIZED" """ + response = client.get("/api/admin/users") + assert response.status_code == 401 + assert response.get_json()["error"]["code"] == "UNAUTHORIZED" + +def test_admin_endpoint_rejects_insufficient_role(app, db_session, client): + """ Crear un usuario real con role="REGISTERED". Hacer GET a /api/admin/users con ese token. Verificar 403 con code == "FORBIDDEN" """ + user = User( + email="registered@example.com", + first_name="Test", + last_name="Registered", + password_hash="dummy", + role="REGISTERED", + is_active=True + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user.id) + + client.set_cookie("access_token_cookie", token) + response = client.get("/api/admin/users", headers={"X-CSRF-TOKEN": csrf}) + + assert response.status_code == 403 + assert response.get_json()["error"]["code"] == "FORBIDDEN" + +def test_admin_endpoint_allows_sufficient_role(app, db_session, client): + """ Crear un usuario real con role="ADMIN". Hacer GET a /api/admin/users. Verificar 200 """ + user = User( + email="admin@example.com", + first_name="Test", + last_name="Admin", + password_hash="dummy", + role="ADMIN", + is_active=True + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user.id) + + client.set_cookie("access_token_cookie", token) + response = client.get("/api/admin/users", headers={"X-CSRF-TOKEN": csrf}) + + assert response.status_code == 200 + +def test_admin_endpoint_rejects_token_for_deleted_user(app, db_session, client): + """ Verifica que un token emitido para un usuario que luego fue eliminado de la base de datos recibe 401 UNAUTHORIZED. El token sigue siendo criptograficamente valido, pero ya no corresponde a ninguna identidad existente, por lo que el frontend debe reaccionar igual que ante un token ausente: redirigir a login """ + user = User( + email="deleted@example.com", + first_name="Test", + last_name="Deleted", + password_hash="dummy", + role="ADMIN", + is_active=True + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + user_id = user.id + + with app.app_context(): + token, csrf = _mint_token_and_csrf(user_id) + + client.set_cookie("access_token_cookie", token) + headers = {"X-CSRF-TOKEN": csrf} + + db_session.delete(user) + db_session.commit() + + response = client.get("/api/admin/users", headers=headers) + + assert response.status_code == 401 + assert response.get_json()["error"]["code"] == "UNAUTHORIZED" diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 0000000..68c1802 --- /dev/null +++ b/backend/tests/unit/__init__.py @@ -0,0 +1 @@ +# unit/__init__ — SkillStat \ No newline at end of file diff --git a/backend/tests/unit/test_alert_schema.py b/backend/tests/unit/test_alert_schema.py new file mode 100644 index 0000000..34b0996 --- /dev/null +++ b/backend/tests/unit/test_alert_schema.py @@ -0,0 +1,134 @@ +import pytest +from marshmallow import ValidationError +from app.schemas.alert_schema import AlertRequestSchema + + +def test_absolute_alert_valid_payload(): + schema = AlertRequestSchema() + payload = { + "skill_id": 1, + "alert_type": "ABSOLUTE", + "threshold_value": 100 + } + result = schema.load(payload) + assert result["skill_id"] == 1 + assert result["alert_type"] == "ABSOLUTE" + assert result["threshold_value"] == 100 + assert "threshold_percentage" not in result + + +def test_absolute_alert_missing_threshold_value(): + schema = AlertRequestSchema() + payload = { + "skill_id": 1, + "alert_type": "ABSOLUTE" + } + with pytest.raises(ValidationError) as exc: + schema.load(payload) + assert "threshold_value" in exc.value.messages + + +def test_absolute_alert_rejects_threshold_percentage(): + schema = AlertRequestSchema() + payload = { + "skill_id": 1, + "alert_type": "ABSOLUTE", + "threshold_value": 100, + "threshold_percentage": 10.5 + } + with pytest.raises(ValidationError) as exc: + schema.load(payload) + assert "threshold_percentage" in exc.value.messages + + +def test_trend_alert_valid_payload(): + schema = AlertRequestSchema() + payload = { + "skill_id": 1, + "alert_type": "TREND", + "threshold_percentage": 15.5 + } + result = schema.load(payload) + assert result["skill_id"] == 1 + assert result["alert_type"] == "TREND" + assert "threshold_value" not in result + # It might cast to Decimal, so we just check it's present and correct + assert float(result["threshold_percentage"]) == 15.5 + + +def test_trend_alert_missing_threshold_percentage(): + schema = AlertRequestSchema() + payload = { + "skill_id": 1, + "alert_type": "TREND" + } + with pytest.raises(ValidationError) as exc: + schema.load(payload) + assert "threshold_percentage" in exc.value.messages + + +def test_trend_alert_rejects_threshold_value(): + schema = AlertRequestSchema() + payload = { + "skill_id": 1, + "alert_type": "TREND", + "threshold_percentage": 15.5, + "threshold_value": 100 + } + with pytest.raises(ValidationError) as exc: + schema.load(payload) + assert "threshold_value" in exc.value.messages + + +def test_invalid_alert_type_rejected(): + schema = AlertRequestSchema() + payload = { + "skill_id": 1, + "alert_type": "INVALID", + "threshold_value": 100 + } + with pytest.raises(ValidationError) as exc: + schema.load(payload) + assert "alert_type" in exc.value.messages + + +def test_threshold_value_must_be_positive(): + schema = AlertRequestSchema() + payload_zero = { + "skill_id": 1, + "alert_type": "ABSOLUTE", + "threshold_value": 0 + } + with pytest.raises(ValidationError) as exc: + schema.load(payload_zero) + assert "threshold_value" in exc.value.messages + + payload_negative = { + "skill_id": 1, + "alert_type": "ABSOLUTE", + "threshold_value": -5 + } + with pytest.raises(ValidationError) as exc: + schema.load(payload_negative) + assert "threshold_value" in exc.value.messages + + +def test_threshold_percentage_must_be_positive(): + schema = AlertRequestSchema() + payload_zero = { + "skill_id": 1, + "alert_type": "TREND", + "threshold_percentage": 0 + } + with pytest.raises(ValidationError) as exc: + schema.load(payload_zero) + assert "threshold_percentage" in exc.value.messages + + payload_negative = { + "skill_id": 1, + "alert_type": "TREND", + "threshold_percentage": -5.5 + } + with pytest.raises(ValidationError) as exc: + schema.load(payload_negative) + assert "threshold_percentage" in exc.value.messages diff --git a/backend/tests/unit/test_alerts_service.py b/backend/tests/unit/test_alerts_service.py new file mode 100644 index 0000000..a42bc26 --- /dev/null +++ b/backend/tests/unit/test_alerts_service.py @@ -0,0 +1 @@ +# test_alerts_service — SkillStat \ No newline at end of file diff --git a/backend/tests/unit/test_db_session_isolation.py b/backend/tests/unit/test_db_session_isolation.py new file mode 100644 index 0000000..052f077 --- /dev/null +++ b/backend/tests/unit/test_db_session_isolation.py @@ -0,0 +1,25 @@ +import pytest +from sqlalchemy import text +from app.models.category import Category +from app.extensions import db + +def test_a_write_visible_within_transaction(db_session): + """ Test A: Escribe un registro y valida que existe DENTRO de la transacción del test. Este test se ejecuta primero por orden alfabético """ + new_category = Category(id=9999, name="TestIsolationCategory123") + db_session.add(new_category) + db_session.commit() + + # Confirma que es visible dentro de esta misma sesión + cat = db_session.query(Category).filter_by(name="TestIsolationCategory123").first() + assert cat is not None + assert cat.name == "TestIsolationCategory123" + +def test_b_rollback_after_test_completes(app): + """ Test B: Abre una conexión limpia y separada SIN el fixture db_session. Comprueba que la tabla sigue limpia y el registro del test A nunca se guardó definitivamente tras el teardown del test A """ + with db.engine.connect() as conn: + result = conn.execute( + text("SELECT * FROM categories WHERE name = 'TestIsolationCategory123'") + ).fetchone() + + # Debe ser None porque la transacción de db_session ya hizo rollback al terminar test_a_write_visible_within_transaction + assert result is None diff --git a/backend/tests/unit/test_environment_sanity.py b/backend/tests/unit/test_environment_sanity.py new file mode 100644 index 0000000..3b16c7a --- /dev/null +++ b/backend/tests/unit/test_environment_sanity.py @@ -0,0 +1,3 @@ +def test_environment_sanity(app): + """ Test trivial para validar que la fixture de la aplicación arranca correctamente y está usando la configuración de testing """ + assert app.config["TESTING"] is True diff --git a/backend/tests/unit/test_market_trends.py b/backend/tests/unit/test_market_trends.py new file mode 100644 index 0000000..aa295a0 --- /dev/null +++ b/backend/tests/unit/test_market_trends.py @@ -0,0 +1 @@ +# test_market_trends — SkillStat \ No newline at end of file diff --git a/backend/tests/unit/test_repository_batch_methods.py b/backend/tests/unit/test_repository_batch_methods.py new file mode 100644 index 0000000..68cf736 --- /dev/null +++ b/backend/tests/unit/test_repository_batch_methods.py @@ -0,0 +1,103 @@ +import pytest +from datetime import datetime, timezone, timedelta + +from app.models.city import City +from app.models.category import Category +from app.models.skill import Skill +from app.models.trend_snapshot import TrendSnapshot +from app.repositories.skill_repository import SkillRepository +from app.repositories.trend_snapshot_repository import TrendSnapshotRepository + +# Helpers +def _make_city(db_session, city_id=2, name="City"): + city = City(id=city_id, name=name, state="State", country="MX") + db_session.add(city) + db_session.flush() + return city + +def _make_category(db_session, name="Category"): + cat = Category(name=name) + db_session.add(cat) + db_session.flush() + return cat + +def _make_skill(db_session, category_id, name="Skill"): + skill = Skill(name=name, canonical_name=name.upper(), category_id=category_id) + db_session.add(skill) + db_session.flush() + return skill + +def _make_snapshot(db_session, skill_id, city_id, demand_count=10, days_ago=0): + snap_date = datetime.now(timezone.utc).date() - timedelta(days=days_ago) + snap = TrendSnapshot( + skill_id=skill_id, + city_id=city_id, + date=snap_date, + demand_count=demand_count, + growth_rate=None, + avg_salary=None, + ) + db_session.add(snap) + db_session.commit() + return snap + +def test_skill_get_by_ids_returns_matching_skills_only(app, db_session): + cat = _make_category(db_session) + s1 = _make_skill(db_session, cat.id, name="S1") + s2 = _make_skill(db_session, cat.id, name="S2") + s3 = _make_skill(db_session, cat.id, name="S3") + + with app.app_context(): + skills = SkillRepository.get_by_ids([s1.id, s3.id]) + assert len(skills) == 2 + ids = {s.id for s in skills} + assert s1.id in ids + assert s3.id in ids + assert s2.id not in ids + +def test_trend_snapshot_get_by_skill_ids_returns_all_snapshots_for_given_skills(app, db_session): + cat = _make_category(db_session) + city = _make_city(db_session) + s1 = _make_skill(db_session, cat.id, name="SnapS1") + s2 = _make_skill(db_session, cat.id, name="SnapS2") + + _make_snapshot(db_session, s1.id, city.id, days_ago=1) + _make_snapshot(db_session, s1.id, city.id, days_ago=2) + _make_snapshot(db_session, s2.id, city.id, days_ago=3) + _make_snapshot(db_session, s2.id, city.id, days_ago=4) + + with app.app_context(): + snaps = TrendSnapshotRepository.get_by_skill_ids([s1.id, s2.id]) + assert len(snaps) == 4 + +def test_trend_snapshot_get_latest_by_skill_ids_returns_one_per_skill(app, db_session): + cat = _make_category(db_session) + city = _make_city(db_session, city_id=99) + s1 = _make_skill(db_session, cat.id, name="LatestS1") + s2 = _make_skill(db_session, cat.id, name="LatestS2") + + # 3 snapshots per skill, days_ago ensures different dates (1 is most recent) + s1_snap1 = _make_snapshot(db_session, s1.id, city.id, days_ago=1) + s1_snap2 = _make_snapshot(db_session, s1.id, city.id, days_ago=2) + s1_snap3 = _make_snapshot(db_session, s1.id, city.id, days_ago=3) + + s2_snap1 = _make_snapshot(db_session, s2.id, city.id, days_ago=5) + s2_snap2 = _make_snapshot(db_session, s2.id, city.id, days_ago=10) + s2_snap3 = _make_snapshot(db_session, s2.id, city.id, days_ago=15) + + with app.app_context(): + snaps = TrendSnapshotRepository.get_latest_by_skill_ids([s1.id, s2.id]) + assert len(snaps) == 2 + + # Verify exactly one snapshot per skill id and it's the most recent one + s1_returned = next(s for s in snaps if s.skill_id == s1.id) + s2_returned = next(s for s in snaps if s.skill_id == s2.id) + + assert s1_returned.date == s1_snap1.date + assert s2_returned.date == s2_snap1.date + +def test_trend_snapshot_get_latest_by_skill_ids_empty_list_returns_empty(app, db_session): + with app.app_context(): + snaps = TrendSnapshotRepository.get_latest_by_skill_ids([]) + assert isinstance(snaps, list) + assert len(snaps) == 0 diff --git a/backend/tests/unit/test_skills_extraction.py b/backend/tests/unit/test_skills_extraction.py new file mode 100644 index 0000000..682650f --- /dev/null +++ b/backend/tests/unit/test_skills_extraction.py @@ -0,0 +1 @@ +# test_skills_extraction — SkillStat \ No newline at end of file diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..33bfc34 --- /dev/null +++ b/data/README.md @@ -0,0 +1,23 @@ +# data/ + +Aqui viven los activos de datos que usa el modulo de procesamiento +de lenguaje natural. No es codigo de la aplicacion: son los archivos +que alimentan al extractor de habilidades. + +## Lo que hay aqui + +- `dictionaries/skills_esco.jsonl` - habilidades tecnicas de la + taxonomia ESCO (base de datos oficial de la Union Europea). Se + descarga y procesa con el script `scripts/build_dictionary.py`. +- `dictionaries/skills_custom.jsonl` - habilidades que el equipo + agrega manualmente cuando la taxonomia base no las incluye. +- `dictionaries/skill_aliases.json` - mapeo de abreviaciones y + variantes al nombre canonico. Por ejemplo: `"JS": "JavaScript"`. +- `samples/vacantes_sample.json` - vacantes de prueba para + desarrollar y probar el extractor sin consumir la API real. + +## Como agregar una habilidad nueva + +Si encontramos una habilidad que el sistema no detecta, la agregamos +en `skills_custom.jsonl` siguiendo el mismo formato que el resto +del archivo. Si es una abreviacion, la agregamos en `skill_aliases.json`. diff --git a/data/dictionaries/skill_aliases.json b/data/dictionaries/skill_aliases.json new file mode 100644 index 0000000..22fdca1 --- /dev/null +++ b/data/dictionaries/skill_aliases.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/data/dictionaries/skills_custom.jsonl b/data/dictionaries/skills_custom.jsonl new file mode 100644 index 0000000..ad47dbb --- /dev/null +++ b/data/dictionaries/skills_custom.jsonl @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/data/dictionaries/skills_esco.jsonl b/data/dictionaries/skills_esco.jsonl new file mode 100644 index 0000000..49bbdbb --- /dev/null +++ b/data/dictionaries/skills_esco.jsonl @@ -0,0 +1,67 @@ +{"label": "SKILL", "pattern": [{"LOWER": "python"}]} +{"label": "SKILL", "pattern": [{"LOWER": "javascript"}]} +{"label": "SKILL", "pattern": [{"LOWER": "typescript"}]} +{"label": "SKILL", "pattern": [{"LOWER": "java"}]} +{"label": "SKILL", "pattern": [{"LOWER": "c#"}]} +{"label": "SKILL", "pattern": [{"LOWER": "c++"}]} +{"label": "SKILL", "pattern": [{"LOWER": "ruby"}]} +{"label": "SKILL", "pattern": [{"LOWER": "php"}]} +{"label": "SKILL", "pattern": [{"LOWER": "go"}]} +{"label": "SKILL", "pattern": [{"LOWER": "rust"}]} +{"label": "SKILL", "pattern": [{"LOWER": "swift"}]} +{"label": "SKILL", "pattern": [{"LOWER": "kotlin"}]} +{"label": "SKILL", "pattern": [{"LOWER": "react"}]} +{"label": "SKILL", "pattern": [{"LOWER": "angular"}]} +{"label": "SKILL", "pattern": [{"LOWER": "vue.js"}]} +{"label": "SKILL", "pattern": [{"LOWER": "node.js"}]} +{"label": "SKILL", "pattern": [{"LOWER": "express"}]} +{"label": "SKILL", "pattern": [{"LOWER": "django"}]} +{"label": "SKILL", "pattern": [{"LOWER": "flask"}]} +{"label": "SKILL", "pattern": [{"LOWER": "fastapi"}]} +{"label": "SKILL", "pattern": [{"LOWER": "spring"}, {"LOWER": "boot"}]} +{"label": "SKILL", "pattern": [{"LOWER": ".net"}]} +{"label": "SKILL", "pattern": [{"LOWER": "sql"}]} +{"label": "SKILL", "pattern": [{"LOWER": "mysql"}]} +{"label": "SKILL", "pattern": [{"LOWER": "postgresql"}]} +{"label": "SKILL", "pattern": [{"LOWER": "mongodb"}]} +{"label": "SKILL", "pattern": [{"LOWER": "sqlite"}]} +{"label": "SKILL", "pattern": [{"LOWER": "nosql"}]} +{"label": "SKILL", "pattern": [{"LOWER": "redis"}]} +{"label": "SKILL", "pattern": [{"LOWER": "cassandra"}]} +{"label": "SKILL", "pattern": [{"LOWER": "elasticsearch"}]} +{"label": "SKILL", "pattern": [{"LOWER": "aws"}]} +{"label": "SKILL", "pattern": [{"LOWER": "azure"}]} +{"label": "SKILL", "pattern": [{"LOWER": "google"}, {"LOWER": "cloud"}]} +{"label": "SKILL", "pattern": [{"LOWER": "gcp"}]} +{"label": "SKILL", "pattern": [{"LOWER": "docker"}]} +{"label": "SKILL", "pattern": [{"LOWER": "kubernetes"}]} +{"label": "SKILL", "pattern": [{"LOWER": "terraform"}]} +{"label": "SKILL", "pattern": [{"LOWER": "jenkins"}]} +{"label": "SKILL", "pattern": [{"LOWER": "ci/cd"}]} +{"label": "SKILL", "pattern": [{"LOWER": "linux"}]} +{"label": "SKILL", "pattern": [{"LOWER": "machine"}, {"LOWER": "learning"}]} +{"label": "SKILL", "pattern": [{"LOWER": "data"}, {"LOWER": "science"}]} +{"label": "SKILL", "pattern": [{"LOWER": "artificial"}, {"LOWER": "intelligence"}]} +{"label": "SKILL", "pattern": [{"LOWER": "nlp"}]} +{"label": "SKILL", "pattern": [{"LOWER": "deep"}, {"LOWER": "learning"}]} +{"label": "SKILL", "pattern": [{"LOWER": "tensorflow"}]} +{"label": "SKILL", "pattern": [{"LOWER": "pytorch"}]} +{"label": "SKILL", "pattern": [{"LOWER": "pandas"}]} +{"label": "SKILL", "pattern": [{"LOWER": "numpy"}]} +{"label": "SKILL", "pattern": [{"LOWER": "scikit-learn"}]} +{"label": "SKILL", "pattern": [{"LOWER": "git"}]} +{"label": "SKILL", "pattern": [{"LOWER": "github"}]} +{"label": "SKILL", "pattern": [{"LOWER": "gitlab"}]} +{"label": "SKILL", "pattern": [{"LOWER": "bitbucket"}]} +{"label": "SKILL", "pattern": [{"LOWER": "agile"}]} +{"label": "SKILL", "pattern": [{"LOWER": "scrum"}]} +{"label": "SKILL", "pattern": [{"LOWER": "jira"}]} +{"label": "SKILL", "pattern": [{"LOWER": "figma"}]} +{"label": "SKILL", "pattern": [{"LOWER": "html"}]} +{"label": "SKILL", "pattern": [{"LOWER": "css"}]} +{"label": "SKILL", "pattern": [{"LOWER": "sass"}]} +{"label": "SKILL", "pattern": [{"LOWER": "tailwind"}]} +{"label": "SKILL", "pattern": [{"LOWER": "bootstrap"}]} +{"label": "SKILL", "pattern": [{"LOWER": "graphql"}]} +{"label": "SKILL", "pattern": [{"LOWER": "rest"}, {"LOWER": "api"}]} +{"label": "SKILL", "pattern": [{"LOWER": "microservices"}]} diff --git a/data/samples/vacantes_sample.json b/data/samples/vacantes_sample.json new file mode 100644 index 0000000..ad47dbb --- /dev/null +++ b/data/samples/vacantes_sample.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/docs/ARQUITECTURA.md b/docs/ARQUITECTURA.md new file mode 100644 index 0000000..1a4c315 --- /dev/null +++ b/docs/ARQUITECTURA.md @@ -0,0 +1,25 @@ +# Arquitectura del Proyecto: SkillStat + +Este documento define la estructura técnica oficial y las decisiones arquitectónicas de SkillStat. Cualquier desviación de este documento requiere un Architecture Decision Record (ADR) previo. + +## 1. Visión General y Stack Tecnológico + +SkillStat utiliza una arquitectura de Monorepo, aislando el frontend del backend. + +- **Backend:** Python 3.13.x, Flask 3.x. +- **Base de Datos:** PostgreSQL 15. +- **ORM & Migraciones:** SQLAlchemy + Flask-Migrate (Alembic). +- **Frontend:** HTML, CSS, JavaScript Vanilla, Tailwind CSS. + +## 2. Arquitectura Orientada a Servicios (SOA) + +El backend está estrictamente separado en 4 capas para garantizar escalabilidad y evitar código espagueti: + +1. **Capa de Presentación (Frontend):** Interfaces de usuario. Consume exclusivamente nuestra API REST. +2. **Capa de Procesos (Controladores):** Implementada mediante Flask Blueprints por dominio (`auth_bp`, `panorama_bp`, `alerts_bp`, `admin_bp`). Orquesta peticiones HTTP. +3. **Capa de Servicios (Lógica de Negocio):** Lógica pura. Contiene integraciones con APIs externas (Adzuna, Nominatim, SendGrid) y el procesamiento NLP. No sabe que Flask existe. +4. **Capa de Recursos (Repositorios y Datos):** Único punto de contacto con la base de datos. Implementa el patrón Repositorio (BaseRepository y repositorios específicos) aislando las consultas SQL. + +## 3. Integridad de Datos (PostgreSQL) + +El esquema relacional cuenta con 8 tablas base normalizadas estrictamente en la 3FN y BCNF. Toda operación de escritura en los repositorios está encapsulada en bloques `try/except` con `db.session.rollback()` para garantizar la integridad transaccional. diff --git a/docs/GUIA_ENTORNO.md b/docs/GUIA_ENTORNO.md new file mode 100644 index 0000000..7c60ef8 --- /dev/null +++ b/docs/GUIA_ENTORNO.md @@ -0,0 +1,168 @@ +# Configuracion del entorno local + +Esta guia explica como preparar la maquina para trabajar en SkillStat desde cero. La seguimos la primera vez que clonamos el repositorio y cada vez que alguien nuevo se integra al equipo. + +## Lo que necesitamos tener instalado + +- **Python 3.13.x** - lo descargamos desde https://www.python.org/downloads/ + Verificamos la instalacion con: `python --version` +- **Git** - lo descargamos desde https://git-scm.com/ + Verificamos con: `git --version` +- **PostgreSQL 15** - lo descargamos desde https://www.postgresql.org/download/ + Durante la instalacion en Windows, el instalador nos deja definir la contraseña del usuario `postgres` y el puerto (dejamos el 5432 por defecto salvo que ya tengamos algo corriendo ahi). Verificamos con: + `psql --version` + Es importante que durante la instalacion se incluyan las "Command Line Tools" (vienen marcadas por defecto). De ahi salen `pg_dump` y `pg_restore`, que el proyecto usa para generar y restaurar respaldos de la base de datos. Si en algun punto un comando `pg_dump` o `pg_restore` no se reconoce en la terminal, lo mas probable es que la carpeta `bin` de la instalacion de PostgreSQL no este en el PATH del sistema. +- Un editor de codigo. Recomendamos Visual Studio Code. + +## Pasos para configurar el proyecto + +**1. Clonamos el repositorio** + +```bash +git clone https://github.com/Ochoa-Stack/SkillStat.git +cd SkillStat +``` + +**2. Entramos a la carpeta del backend y creamos el entorno virtual** + +El entorno virtual aísla las dependencias del proyecto para que no interfieran con otros proyectos en la misma maquina. + +```bash +cd backend +# En Mac y Linux +python3 -m venv .venv +# En Windows +python -m venv .venv +``` + +**3. Activamos el entorno virtual** + +Esto lo hacemos cada vez que abrimos una nueva terminal para trabajar en el proyecto. Todos los comandos de `pip`, `flask` y `python` de esta guia asumen que el entorno virtual ya esta activo. + +```bash +# En Mac y Linux +source .venv/bin/activate +# En Windows (PowerShell) +.venv\Scripts\Activate.ps1 +# En Windows (CMD) +.venv\Scripts\activate.bat +``` + +Cuando el entorno esta activo vemos `(.venv)` al inicio de la linea en la terminal. + +**4. Instalamos las dependencias** + +```bash +pip install -r requirements.txt +pip install -r requirements-dev.txt +``` + +**5. Creamos la base de datos local** + +Con PostgreSQL ya instalado y corriendo, creamos una base de datos vacia donde va a vivir el esquema del proyecto. Podemos hacerlo desde `psql` o desde pgAdmin4 si lo tenemos instalado: + +```sql +CREATE DATABASE skillstat_dev; +``` + +El nombre `skillstat_dev` es el que usa el proyecto por convencion; si le ponemos otro nombre, hay que recordar ajustarlo tambien en `DATABASE_URL` en el paso 6. + +**6. Configuramos las variables de entorno** + +Copiamos el archivo de ejemplo y lo llenamos con los valores reales: + +```bash +# En Mac y Linux +cp .env.example .env +# En Windows +copy .env.example .env +``` + +Abrimos `.env` y completamos cada variable. Nunca subimos el archivo `.env` al repositorio; solo `.env.example` vive en Git, sin valores reales. Guia rapida de que es cada cosa: + +| Variable | Que es | De donde sale | +| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `SECRET_KEY` | Clave interna de Flask para firmar sesiones | La inventamos nosotros, cualquier cadena larga y aleatoria | +| `DATABASE_URL` | Cadena de conexion a PostgreSQL | La armamos con el usuario/password que definimos al instalar PostgreSQL y el nombre de la base de datos del paso 5 | +| `JWT_SECRET_KEY` | Clave para firmar los tokens de sesion de la API | La inventamos nosotros, distinta a `SECRET_KEY` | +| `JWT_ACCESS_TOKEN_EXPIRES` | Cuanto dura la sesion antes de expirar, en segundos | Ya viene con un valor razonable (7200 = 2 horas), no hace falta tocarlo | +| `JWT_COOKIE_SECURE` | Si las cookies de sesion exigen HTTPS | En local se deja en `false`; en produccion se pone en `true` | +| `GOOGLE_CLIENT_ID` | Id de cliente para el boton "Iniciar sesion con Google" | Ya viene precargado en `.env.example`, es compartido por el equipo, no hace falta generarlo de nuevo | +| `ADZUNA_APP_ID` / `ADZUNA_APP_KEY` | Credenciales de la API de empleos que alimenta el proyecto | Se piden en https://developer.adzuna.com/, pedirlas al equipo si ya existen unas compartidas | +| `RESEND_API_KEY` | Credencial del servicio que envia los correos (verificacion de cuenta, recuperacion de contraseña, alertas) | Se genera en https://resend.com/, pedirla al equipo si ya existe una compartida | +| `BACKUP_STORAGE_URL` / `BACKUP_STORAGE_KEY` | Reservadas para almacenamiento externo de respaldos | Todavia no estan conectadas a ningun proveedor; se puede dejar el valor de ejemplo tal cual por ahora, no bloquea el arranque del proyecto | +| `SCHEDULER_ENABLED` | Si el pipeline diario de ingesta y calculo de tendencias corre automaticamente | Se deja en `false` en desarrollo para no gastar la cuota de la API de Adzuna mientras programamos | +| `CORS_ORIGINS` / `FRONTEND_BASE_URL` | De donde puede llamar el frontend a la API, y donde vive el frontend | Ya vienen con los valores correctos para Live Server en local, no hace falta tocarlos salvo que sirvamos el frontend desde otro puerto | + +**7. Aplicamos las migraciones de base de datos** + +Este paso crea todas las tablas dentro de `skillstat_dev`. Sin esto, el servidor arranca pero cualquier peticion que toque la base de datos va a fallar. + +```bash +flask db upgrade +``` + +Si en algun punto el comando se queja de no encontrar la aplicacion, confirmamos que estamos parados en la carpeta `backend/` y que el entorno virtual esta activo. + +**8. Corremos el servidor de desarrollo** + +```bash +python run.py +``` + +Si todo esta bien veremos un mensaje indicando que Flask esta corriendo, generalmente en http://localhost:5000 + +**9. Servimos el frontend** + +El frontend es HTML/CSS/JS plano, no necesita build. Lo mas simple es usar la extension Live Server de VS Code sobre la carpeta `frontend/`, apuntando al puerto 5500 (que ya es el que espera `CORS_ORIGINS` y `FRONTEND_BASE_URL` en el `.env` de ejemplo). + +## Cuando algo no funciona + +- Si `python` no se reconoce como comando, probamos con `python3`. +- Si el entorno virtual no se activa en Windows, es posible que + necesitemos ejecutar primero: + `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` +- Si hay errores al instalar dependencias, verificamos que el entorno virtual este activado antes de correr `pip install`. +- Si `flask db upgrade` falla con un error de conexion, revisamos que PostgreSQL este corriendo y que `DATABASE_URL` tenga el usuario, contraseña, puerto y nombre de base de datos correctos. +- Si `pg_dump` o `pg_restore` no se reconocen como comando (solo necesario para trabajar con respaldos desde el panel de Admin), hay que agregar la carpeta `bin` de la instalacion de PostgreSQL al PATH del sistema. +- Para cualquier otro problema lo comentamos en el canal del equipo antes de intentar soluciones por cuenta propia. + +## Comandos operativos de referencia + +Esta seccion no es parte del levantamiento inicial del proyecto; es una referencia rapida para tareas que se hacen ya con el entorno funcionando. + +**Generar un respaldo manual de la base de datos** + +Se puede hacer desde el panel de Admin en el navegador (`/admin/respaldos.html`, requiere una cuenta con rol ADMIN), o directamente contra la API: + +```bash +curl -X POST http://localhost:5000/api/admin/backup -H "Authorization: Bearer TU_TOKEN" +``` + +**Restaurar un respaldo** + +Solo desde el panel de Admin, nunca directamente por API sin el flujo de confirmacion, la restauracion reemplaza todos los datos actuales de la base de datos. El panel exige escribir el nombre exacto del archivo antes de permitir confirmar, y genera automaticamente un respaldo de seguridad justo antes de ejecutar la restauracion. + +**Poblar datos de ejemplo para desarrollo** + +Después de aplicar las migraciones, si se quiere trabajar con algunos skills de ejemplo sin depender de la ingesta real de Adzuna (que consume cuota de API): + +```bash +python scripts/seed_db.py +``` + +Esto es opcional dado que las categorías base ya se insertan automáticamente con `flask db upgrade`, este script solo agrega algunos skills de ejemplo para tener datos con los que probar el frontend sin ingesta real. + +**Correr la ingesta de vacantes manualmente** + +Con `SCHEDULER_ENABLED=false` (el valor por defecto en desarrollo), la ingesta no corre sola. Para dispararla manualmente y probar el pipeline: + +```bash +curl -X POST http://localhost:5000/api/admin/ingest -H "Authorization: Bearer TU_TOKEN" -H "Content-Type: application/json" -d '{"pages": 1}' +``` + +Ojo con esto: cada llamada consume cuota real de la API de Adzuna, no se recomienda correrlo repetidamente sin necesidad. + +**Activar el scheduler automatico** + +Si se necesita que el pipeline diario (ingesta + calculo de tendencias + evaluacion de alertas) corra solo, se cambia `SCHEDULER_ENABLED=true` en `.env` y se reinicia el servidor. En desarrollo normal se deja en `false`. diff --git a/docs/GUIA_GIT.md b/docs/GUIA_GIT.md new file mode 100644 index 0000000..4bc12d7 --- /dev/null +++ b/docs/GUIA_GIT.md @@ -0,0 +1,105 @@ +# Git en el dia a dia + +Esta guia cubre los comandos de Git que usamos en SkillStat. No asume conocimiento previo. Si ya dominas Git puedes usarla como referencia rapida. + +## Como funciona Git en este proyecto + +Git guarda el historial de todos los cambios que hacemos al codigo. Cada vez que guardamos un cambio con `commit` quedamos un registro de que cambio, quien lo hizo y cuando. + +Las ramas nos permiten trabajar en algo nuevo sin afectar lo que ya funciona. Cuando terminamos, integramos nuestro trabajo a `develop` a traves de un Pull Request en GitHub. + +## Los comandos que mas usamos + +**Ver en que rama estamos y que archivos cambiamos:** +```bash +git status +``` + +**Ver el historial de commits:** +```bash +git log --oneline +``` + +**Traer los cambios mas recientes del repositorio remoto:** +```bash +git pull origin develop +``` + +**Crear una rama nueva desde develop:** +```bash +git checkout develop +git pull origin develop +git checkout -b feature/nombre-de-la-tarea +``` + +**Guardar nuestros cambios en un commit:** +```bash +git add . +git commit -m "feat(scope): descripcion del cambio" +``` + +**Subir nuestra rama al repositorio remoto:** +```bash +git push origin feature/nombre-de-la-tarea +``` + +**Actualizar nuestra rama con los ultimos cambios de develop:** +```bash +git fetch origin +git rebase origin/develop +``` + +**Cambiar de rama:** +```bash +git checkout nombre-de-la-rama +``` + +**Ver todas las ramas disponibles:** +```bash +git branch -a +``` + +## El flujo completo de una tarea + +```bash +# Empezamos siempre desde develop actualizado +git checkout develop +git pull origin develop + +# Creamos nuestra rama +git checkout -b feature/mi-tarea + +# Trabajamos... hacemos cambios... y guardamos +git add . +git commit -m "feat(modulo): descripcion" + +# Si develop recibio nuevos cambios mientras trabajabamos +git fetch origin +git rebase origin/develop + +# Subimos nuestra rama +git push origin feature/mi-tarea + +# Desde GitHub abrimos el Pull Request hacia develop +``` + +## Lo que no hacemos + +- No hacemos commits directamente sobre `develop` o `main`. +- No usamos `git push --force` en ramas compartidas. +- No hacemos merge de nuestro propio Pull Request. +- No subimos el archivo `.env` ni ninguna credencial al repositorio. + +## Cuando algo sale mal + +Si nos equivocamos en el mensaje de un commit antes de subir los cambios: +```bash +git commit --amend -m "feat(scope): mensaje corregido" +``` + +Si queremos deshacer el ultimo commit pero conservar los cambios: +```bash +git reset --soft HEAD~1 +``` + +Si tenemos dudas sobre algo que no esta en esta guia lo preguntamos antes de intentar comandos desconocidos. diff --git a/docs/LIMPIEZA_RESIDUALES.md b/docs/LIMPIEZA_RESIDUALES.md new file mode 100644 index 0000000..fd13f0a --- /dev/null +++ b/docs/LIMPIEZA_RESIDUALES.md @@ -0,0 +1,20 @@ +# Eliminación de archivos residuales de sesiones de debug [1] + +## Contexto + +Durante la auditoría técnica pre-Ronda 14 se detectaron cuatro archivos no planificados en backend/ que fueron generados durante sesiones de depuración manual y nunca formaron parte del scaffold del proyecto: + +- backend/clean.py (80 líneas) +- backend/clean2.py (79 líneas) +- backend/clean3.py (78 líneas) +- backend/test_read.py (13 líneas) + +Los archivos fueron eliminados del working tree de develop durante la sesión de diagnóstico. Esta rama los elimina formalmente del historial mediante este registro de decisión. + +## Decisión + +Se eliminan sin recuperación. No contenían lógica de aplicación, pruebas formales ni configuración. Eran scripts ad-hoc de depuración sin valor para el proyecto. + +## Consecuencias + +El directorio backend/ queda alineado con el scaffold original. Cualquier utilidad de depuración futura debe crearse en backend/tests/ o en scripts/ con nombre descriptivo y commitearse como parte del flujo normal de desarrollo. diff --git a/docs/adr/ADR-001-neon-como-proveedor-postgresql.md b/docs/adr/ADR-001-neon-como-proveedor-postgresql.md new file mode 100644 index 0000000..bc3f7e4 --- /dev/null +++ b/docs/adr/ADR-001-neon-como-proveedor-postgresql.md @@ -0,0 +1,26 @@ +# ADR-001: Neon como proveedor de PostgreSQL sobre Render Postgres + +## Estado +Aceptado + +## Contexto +SkillStat requería una base de datos PostgreSQL gestionada para su primer despliegue en producción. La evaluación se hizo contra el estado real del mercado free-tier en julio de 2026, no contra documentación heredada o supuestos de sesiones anteriores. + +Render ofrece PostgreSQL gestionado como parte de su ecosistema, lo que en principio simplificaría la arquitectura al mantener base de datos y aplicación bajo el mismo proveedor. Sin embargo, se confirmó mediante búsqueda directa que las bases de datos gratuitas de Render expiran a los 30 días de creadas, con solo 14 días de gracia posteriores y sin mecanismo de respaldo nativo incluido en el tier gratuito. Esta condición contradecía documentación previa que el equipo tenía registrada, que indicaba incorrectamente una expiración a 90 días. + +Railway se descartó de la evaluación sin llegar a comparación detallada: ya no ofrece un tier gratuito real y exige método de pago desde el trial. + +## Decisión +Usamos Neon como proveedor de PostgreSQL para el entorno de producción. + +Neon ofrece un tier gratuito permanente sin fecha de expiración, sin requerir tarjeta de crédito, con 0.5 GB de almacenamiento y 100 horas de cómputo mensuales. Es compatible al 100% con PostgreSQL estándar, sin ser un fork con comportamiento divergente, lo que no introduce riesgo de incompatibilidad con SQLAlchemy, Alembic, ni con ninguna sentencia SQL ya escrita para el proyecto. + +## Alternativas consideradas +**Render Postgres.** Descartado por la expiración real de 30 días en el tier gratuito (confirmada contra el estado actual del servicio, no contra documentación desactualizada), los 14 días de gracia insuficientes para una migración de emergencia sin aviso previo, y la ausencia de respaldo nativo en ese mismo tier. Mantener aplicación y base de datos bajo el mismo proveedor no compensaba el riesgo de pérdida de datos por expiración silenciosa. + +**Railway.** Descartado antes de una comparación técnica profunda: la ausencia de tier gratuito real sin método de pago lo sacaba de consideración para las restricciones del proyecto (sin presupuesto asignado para infraestructura). + +## Consecuencias +La base de datos de producción vive en un proveedor distinto al de hosting de la aplicación (Render), lo que introduce una dependencia externa adicional a monitorear, pero elimina el riesgo de expiración que Render Postgres habría representado. + +Queda como responsabilidad activa monitorear el consumo real contra los límites del tier gratuito de Neon (0.5 GB de almacenamiento, 100 horas de cómputo mensual), dado que superar esos límites sí tiene consecuencia directa sobre la disponibilidad del servicio, a diferencia de una simple expiración por tiempo. diff --git a/docs/adr/ADR-002-servicios-separados-backend-frontend.md b/docs/adr/ADR-002-servicios-separados-backend-frontend.md new file mode 100644 index 0000000..7281f57 --- /dev/null +++ b/docs/adr/ADR-002-servicios-separados-backend-frontend.md @@ -0,0 +1,24 @@ +# ADR-002: Servicios separados para backend y frontend en Render + +## Estado +Aceptado + +## Contexto +Al preparar el primer despliegue en producción, existía la opción de fusionar backend y frontend en un único Web Service de Render, sirviendo el frontend como archivos estáticos desde el mismo proceso Flask, o mantenerlos como dos servicios independientes: un Web Service para el backend y un Static Site para el frontend. + +El backend ya estaba construido como una API JSON pura, sin ningún uso de `render_template` ni de `static_folder` de Flask en ningún punto del código existente. Esto significaba que fusionar ambos no era una continuación natural de la arquitectura ya construida, sino una modificación activa para acomodar una sola instancia de despliegue. + +El tier gratuito de Render duerme los Web Services tras 15 minutos de inactividad, con un cold start de 30 a 60 segundos en el primer request posterior. Un Static Site en Render, en cambio, nunca duerme bajo ese mismo tier. + +## Decisión +Usamos dos servicios independientes en Render: un Web Service para el backend Flask (`https://skillstat.onrender.com`) y un Static Site para el frontend (`https://skillstat-ss.onrender.com`), reflejando la separación de capas ya existente en el código (arquitectura orientada a servicios, con el backend como API pura). + +## Alternativas consideradas +**Servicio único fusionado.** Evaluado explícitamente contra la separación. Habría requerido introducir `render_template` y `static_folder` en un backend que hasta ese momento no los usaba en ningún endpoint, es decir, una modificación de arquitectura motivada únicamente por conveniencia de despliegue, no por una necesidad real del sistema. Adicionalmente, fusionar ambos habría hecho que el frontend se durmiera junto con el backend en el ciclo de sleep del free tier, perdiendo la ventaja real de que un Static Site nunca duerme. Se descartó porque no resolvía ningún problema que la separación no resolviera ya, y sí introducía una regresión de disponibilidad para el frontend. + +## Consecuencias +El proyecto opera con dos dominios `.onrender.com` distintos en vez de uno solo, lo que introdujo directamente el problema de cookies cross-site documentado en [ADR-005](./ADR-005-samesite-none-condicional-por-entorno.md). Esta es una consecuencia conocida y aceptada de la decisión, no un efecto secundario no previsto. + +El frontend permanece siempre disponible sin cold start, mientras que el backend sí experimenta cold start tras inactividad, una asimetría consciente y aceptada: el costo de espera recae únicamente en las llamadas a la API, no en la carga inicial de la interfaz. + +Si el proyecto migra en el futuro a un dominio propio con subdominios reales (`app.` y `api.`), esta separación en dos servicios se mantiene sin cambios estructurales; solo cambiaría el dominio detrás de cada uno. diff --git a/docs/adr/ADR-003-github-actions-como-disparador-pipeline.md b/docs/adr/ADR-003-github-actions-como-disparador-pipeline.md new file mode 100644 index 0000000..0f05b56 --- /dev/null +++ b/docs/adr/ADR-003-github-actions-como-disparador-pipeline.md @@ -0,0 +1,26 @@ +# ADR-003: GitHub Actions como disparador externo del pipeline diario + +## Estado +Aceptado + +## Contexto +El pipeline diario de generación de datos (ingesta de vacantes, cálculo de tendencias, evaluación de alertas) corría originalmente mediante APScheduler dentro del mismo proceso Flask, disparado por un cron interno a medianoche. + +Al desplegar en el tier gratuito de Render (ver [ADR-002](./ADR-002-servicios-separados-backend-frontend.md)), el Web Service se duerme tras 15 minutos de inactividad. Si el proceso está dormido a la hora programada, el job simplemente no se dispara, sin ningún error visible que lo señale; el pipeline deja de correr de forma silenciosa. + +## Decisión +Usamos un workflow de GitHub Actions (`schedule: cron` diario, con `workflow_dispatch` disponible para disparo manual) que hace una petición HTTP real hacia un endpoint dedicado del backend, en vez de depender de un proceso interno que puede estar dormido. + +## Alternativas consideradas +**Render Cron Jobs.** Descartado por no ser gratuito: se confirmó mediante búsqueda directa un costo mínimo de 1 USD al mes, aun para la configuración más económica disponible. GitHub Actions cumple el mismo propósito sin costo alguno para un repositorio del tamaño y volumen de ejecución de este proyecto. + +**Disparo manual.** Descartado porque rompe la propuesta de valor central del proyecto: los datos deben actualizarse automáticamente cada día sin intervención humana. Un pipeline que depende de que alguien recuerde ejecutarlo manualmente no cumple esa promesa. + +**Mantener el scheduler interno (APScheduler).** Descartado por incompatibilidad directa con el ciclo de sleep del tier gratuito de Render, que es la causa raíz del problema que esta decisión resuelve. Mantenerlo habría dejado el pipeline fallando de forma intermitente e indetectable. + +## Consecuencias +El disparo del pipeline ya no depende del estado de actividad del Web Service, se ejecuta desde la infraestructura de GitHub, independiente de si el backend está dormido o despierto en ese momento (el propio request HTTP del workflow despierta al servicio si estaba dormido). + +Se introduce una dependencia nueva sobre la disponibilidad de GitHub Actions como plataforma. El endpoint que recibe el disparo requiere su propio mecanismo de autenticación, independiente del sistema de usuarios existente (ver [ADR-004](./ADR-004-api-key-dedicada-para-pipeline.md)). + +El workflow solo se indexa y ejecuta automáticamente desde la rama por defecto del repositorio. Mientras `main` no se actualice con este cambio, el disparo automático no ocurre todavía de forma real, solo verificable manualmente vía `curl` contra el endpoint. diff --git a/docs/adr/ADR-004-api-key-dedicada-para-pipeline.md b/docs/adr/ADR-004-api-key-dedicada-para-pipeline.md new file mode 100644 index 0000000..6143ae1 --- /dev/null +++ b/docs/adr/ADR-004-api-key-dedicada-para-pipeline.md @@ -0,0 +1,22 @@ +# ADR-004: API key dedicada para autenticar el endpoint de disparo del pipeline + +## Estado +Aceptado + +## Contexto +El endpoint `POST /api/admin/trigger-pipeline`, introducido junto con la decisión de usar GitHub Actions como disparador externo (ver [ADR-003](./ADR-003-github-actions-como-disparador-pipeline.md)), necesitaba un mecanismo de autenticación propio. El sistema de autenticación existente del proyecto está construido sobre JWT en cookies httpOnly, un esquema diseñado para sesiones de navegador con un usuario humano detrás. + +GitHub Actions no tiene navegador ni maneja cookies, por lo que el esquema de autenticación ya existente no era utilizable directamente para este caso sin adaptaciones adicionales. + +## Decisión +Usamos un header dedicado (`X-Pipeline-Trigger-Key`) validado con `hmac.compare_digest` contra un secreto almacenado en una variable de entorno de un solo propósito (`PIPELINE_TRIGGER_SECRET`), completamente separado del sistema de JWT y roles ya existente. + +## Alternativas consideradas +**JWT de larga duración de un admin real.** Descartado porque mezclaría la identidad de una persona real con un proceso automatizado. Si ese admin cambia de contraseña, revoca sus tokens, o su cuenta se desactiva por cualquier razón administrativa, el pipeline automatizado se rompería como efecto colateral de una acción que no tiene relación alguna con él. Además, un JWT de larga duración es una superficie de ataque mayor que una API key de un solo propósito: si se filtra, otorga todos los permisos de ese usuario admin, no solo la capacidad de disparar el pipeline. + +## Consecuencias +El endpoint de disparo del pipeline queda completamente desacoplado del sistema de usuarios: no pasa por `role_required`, no depende de ningún JWT, y no se ve afectado por cambios en las cuentas de administradores reales. + +Se introduce una variable de entorno adicional (`PIPELINE_TRIGGER_SECRET`) que requiere el mismo tratamiento de secreto que cualquier otra credencial del proyecto: nunca se transcribe en documentación, nunca se comitea, y tiene guard incondicional de arranque equivalente al ya existente para otras claves de servicios externos. + +La comparación se hace con `hmac.compare_digest` en vez de una comparación directa de strings, para evitar ataques de temporización (timing attacks) sobre la validación del secreto. diff --git a/docs/adr/ADR-005-samesite-none-condicional-por-entorno.md b/docs/adr/ADR-005-samesite-none-condicional-por-entorno.md new file mode 100644 index 0000000..90b7bea --- /dev/null +++ b/docs/adr/ADR-005-samesite-none-condicional-por-entorno.md @@ -0,0 +1,20 @@ +# ADR-005: SameSite=None condicional por entorno para cookies JWT + +## Estado +Aceptado + +## Contexto +La separación de backend y frontend en dos servicios distintos de Render (ver [ADR-002](./ADR-002-servicios-separados-backend-frontend.md)) significa que ambos dominios `.onrender.com` son técnicamente cross-site entre sí, aunque compartan el mismo dominio raíz superficialmente. + +Se confirmó con evidencia directa en el navegador (pestaña Application → Cookies) que ninguna cookie del backend llegaba a guardarse cuando el login se hacía desde el frontend: el valor original `JWT_COOKIE_SAMESITE="Lax"` hace que el navegador descarte la cookie en un escenario cross-site. Se confirmó también que `csrf_access_token` sufría exactamente el mismo rechazo, por compartir el mismo parámetro `samesite` dentro de Flask-JWT-Extended. + +## Decisión +Usamos `JWT_COOKIE_SAMESITE` condicional según el entorno de ejecución: `"None"` en producción (que ya cuenta y requiere `Secure=True`), y `"Lax"` sin cambios en desarrollo y en testing, donde ambos servicios corren sobre el mismo origen (`localhost`) y no aplica el problema cross-site. + +## Alternativas consideradas +No se evaluaron alternativas de arquitectura distintas a la ya decidida en ADR-002 (mantener servicios separados). El único camino alternativo real habría sido revertir esa decisión y fusionar ambos servicios bajo un mismo dominio, lo que ya fue descartado con su propia justificación independiente en ese documento. + +## Consecuencias +La protección CSRF no se debilita con este cambio: el patrón Double Submit Cookie (`JWT_COOKIE_CSRF_PROTECT=True`) ya existente en el proyecto sigue operando de forma idéntica, independientemente del valor de `SameSite`. + +Este es un fix correcto para la arquitectura actual de dos dominios `.onrender.com` distintos, pero no es la solución definitiva de largo plazo. Si el proyecto migra en el futuro a un dominio propio con `app.` y `api.` como subdominios reales del mismo dominio raíz, la solución definitiva sería volver a `SameSite=Lax`, aprovechando que subdominios de un mismo dominio raíz cuentan como same-site para el navegador. diff --git a/docs/adr/ADR-006-cloudflare-r2-para-respaldo-remoto.md b/docs/adr/ADR-006-cloudflare-r2-para-respaldo-remoto.md new file mode 100644 index 0000000..dafa564 --- /dev/null +++ b/docs/adr/ADR-006-cloudflare-r2-para-respaldo-remoto.md @@ -0,0 +1,22 @@ +# ADR-006: Cloudflare R2 como backend de almacenamiento para respaldos remotos + +## Estado +Aceptado + +## Contexto +SkillStat requería un backend de almacenamiento remoto para sus respaldos de base de datos, con dos restricciones no negociables dado el carácter de portafolio del proyecto y la ausencia de presupuesto de infraestructura (ver sección de restricciones de la materia): costo cero de operación sostenida, y un tier gratuito que no expire con el tiempo ni con el uso normal esperado del proyecto. + +El criterio decisivo no fue el costo de almacenamiento en sí, a esa escala de datos, la diferencia entre proveedores es de centavos sino el costo de egress: cuánto cobra cada proveedor por sacar datos hacia fuera de su plataforma, que es exactamente la operación que ocurre cada vez que se restaura un respaldo. + +## Decisión +Usamos Cloudflare R2, integrado vía su API compatible con S3 mediante `boto3`, como backend de almacenamiento remoto para los respaldos de la base de datos. + +## Alternativas consideradas +**Amazon S3.** Descartado por su estructura de egress: cobra 0.09 USD por GB de datos transferidos hacia fuera después de los primeros 100 GB gratuitos al mes. R2 no cobra nada por egress bajo ninguna circunstancia. Para un proceso de restauración de respaldo que es egress por definición, esto representa un costo recurrente que R2 elimina por completo, sin importar cuántas veces se necesite restaurar. El almacenamiento en sí también es más económico en R2 (0.015 USD por GB al mes frente a 0.023 USD por GB en S3), pero esa diferencia es secundaria frente al ahorro en egress. + +**Google Cloud Storage.** Descartado sin llegar a una comparación de pricing detallada: introducir un tercer proveedor de nube distinto a los ya usados en el proyecto (Render, Neon) habría sumado una cuenta y un panel de administración adicionales sin ninguna ventaja concreta sobre R2 en cuanto al criterio decisivo (egress cero), que GCS no ofrece de forma nativa. + +## Consecuencias +Los respaldos pueden restaurarse tantas veces como sea necesario sin que el costo de transferencia se convierta en una variable a monitorear, lo cual es particularmente relevante considerando que ya se documentó un incidente real de restauración de respaldo con desincronización de esquema (ver rama test/backup-restore-coverage `PR #54`), escenario que en un proveedor con egress de pago habría tenido, además del costo de ingeniería, un costo económico directo por cada intento de recuperación. + +R2 implementa un subconjunto de la API de S3, no su totalidad, funciones avanzadas como S3 Object Lock o Intelligent-Tiering no tienen equivalente directo. Esto no representa una limitación real para el caso de uso actual del proyecto (subir y descargar archivos de respaldo), pero queda como restricción conocida si el uso del almacenamiento remoto se expandiera a necesidades más complejas en el futuro. diff --git a/docs/adr/ADR-007-savepoint-para-aislamiento-de-tests.md b/docs/adr/ADR-007-savepoint-para-aislamiento-de-tests.md new file mode 100644 index 0000000..0d18473 --- /dev/null +++ b/docs/adr/ADR-007-savepoint-para-aislamiento-de-tests.md @@ -0,0 +1,20 @@ +# ADR-007: Savepoints para aislamiento de sesiones en la suite de pruebas + +## Estado +Aceptado + +## Contexto +La sesión con scope (`scoped_session`) que provee Flask-SQLAlchemy 3.x está diseñada para el ciclo de vida de una petición HTTP real, no para el ciclo de vida de una prueba individual dentro de una suite de pytest. Sin una estrategia explícita de aislamiento, cada prueba que hace `commit()` sobre la base de datos persiste esos cambios de forma real, contaminando el estado que verá la siguiente prueba y rompiendo la independencia que una suite de pruebas confiable requiere. + +## Decisión +Usamos una sesión pura de SQLAlchemy configurada con `join_transaction_mode="create_savepoint"`, siguiendo la solución oficial documentada por SQLAlchemy 2.0 y Flask-SQLAlchemy 3.x para este problema específico. + +Cada prueba corre dentro de una transacción global abierta al inicio. Todo `db.session.commit()` que el código de la aplicación ejecute durante la prueba no se escribe a disco: se consolida como un savepoint (transacción anidada) dentro de esa transacción global. Al finalizar la prueba, el teardown ejecuta un `rollback()` sobre la transacción global completa, descartando de golpe todos los savepoints acumulados y dejando la base de datos exactamente en el estado en que estaba antes de que la prueba comenzara. + +## Alternativas consideradas +No se evaluaron alternativas de arquitectura propias frente a esta configuración: es la solución oficial y recomendada por la documentación de SQLAlchemy 2.0 y Flask-SQLAlchemy 3.x para el problema exacto de aislamiento de sesiones bajo pytest. La alternativa real frente a adoptarla no era un enfoque distinto igualmente válido, sino la ausencia de aislamiento automático confiable: dejar que cada prueba escribiera y persistiera cambios reales en la base de datos de test, con el riesgo de contaminación entre pruebas que eso implica. + +## Consecuencias +La suite de pruebas queda verificada como confiablemente aislada tras tres corridas consecutivas limpias, sin contaminación de estado entre pruebas. + +Esta estrategia asume que el código de la aplicación llama a `db.session.commit()` de forma normal, sin manejar transacciones anidadas propias que pudieran interferir con el savepoint que la suite ya está gestionando. Si en el futuro se introduce código que maneje sus propias transacciones anidadas explícitas, esa interacción debe revisarse contra este mecanismo antes de asumir que sigue funcionando sin cambios. diff --git a/docs/diagramas/diagrama-casos-de-uso-alto-nivel.md b/docs/diagramas/diagrama-casos-de-uso-alto-nivel.md new file mode 100644 index 0000000..d447667 --- /dev/null +++ b/docs/diagramas/diagrama-casos-de-uso-alto-nivel.md @@ -0,0 +1,63 @@ +# Diagrama de Casos de Uso - Vista de Alto Nivel + +Este diagrama agrupa los treinta y tres casos de uso técnicos reales (ver [diagrama-casos-de-uso-completo.md](./diagrama-casos-de-uso-completo.md)) en capacidades funcionales de negocio, pensado para presentación y para lectura rápida del alcance del sistema, alineado con lo que evalúa el Requisito 7 (CRUD, autenticación y validaciones) de la materia. + +```mermaid +%%{init: {"flowchart": {"curve": "stepBefore"}}}%% +flowchart LR + %% Actores + Visitante(["Visitante"]) + Registrado(["Usuario Registrado"]) + Admin(["Administrador"]) + + %% Herencia de actores + Registrado -.->|extiende| Visitante + Admin -.->|extiende| Registrado + + %% Límite del Sistema + subgraph Sistema["SkillStat"] + %% Casos de uso de Visitante + A(("Autenticarse y gestionar cuenta")) + D(("Consultar Panorama del mercado laboral")) + + %% Casos de uso de Registrado + B(("Gestionar perfil y habilidades")) + C(("Configurar alertas de mercado")) + + %% Casos de uso de Admin + E(("Administrar usuarios")) + F(("Administrar respaldos")) + G(("Administrar ingesta de datos")) + end + + %% Relaciones + Visitante --> A + Visitante --> D + + Registrado --> B + Registrado --> C + + Admin --> E + Admin --> F + Admin --> G + + %% Estilos UML (Neutros) + classDef actor fill:#f3f4f6,stroke:#374151,stroke-width:2px,color:#000 + classDef uc fill:#ffffff,stroke:#6b7280,stroke-width:1px,color:#000 + + class Visitante,Registrado,Admin actor + class A,B,C,D,E,F,G uc + style Sistema fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5 +``` + +## Correspondencia con el diagrama completo + +| Capacidad | Casos de uso técnicos agrupados | +|---|---| +| Autenticarse y gestionar cuenta | Registro, login (password y Google), logout, verificación de correo, recuperación de contraseña | +| Gestionar perfil y habilidades | Ver/actualizar perfil, cambiar contraseña, brecha de habilidades, agregar/eliminar habilidad | +| Configurar alertas de mercado | Crear, listar, eliminar, activar/desactivar alerta | +| Consultar Panorama del mercado laboral | Catálogos, resumen, ranking, tendencias, distribución geográfica, salarios, comparación | +| Administrar usuarios | Listar usuarios, cambiar rol, cambiar estado | +| Administrar respaldos | Ejecutar, listar, restaurar respaldo | +| Administrar ingesta de datos | Disparar ingesta de vacantes | diff --git a/docs/diagramas/diagrama-casos-de-uso-completo.md b/docs/diagramas/diagrama-casos-de-uso-completo.md new file mode 100644 index 0000000..8c6e0dd --- /dev/null +++ b/docs/diagramas/diagrama-casos-de-uso-completo.md @@ -0,0 +1,85 @@ +# Diagrama de Casos de Uso - Vista Completa + +Este diagrama enumera cada caso de uso técnico real del sistema, derivado directamente de los endpoints expuestos en los cinco controladores del backend (`admin_bp`, `alerts_bp`, `auth_bp`, `panorama_bp`, `profile_bp`). No es una representación conceptual del negocio: cada nodo corresponde a una ruta HTTP real y verificable en el código. + +El actor Administrador extiende al Usuario Registrado (relación de generalización): todo lo que puede hacer un Usuario Registrado, también puede hacerlo un Administrador, más las operaciones exclusivas de gestión. El actor Visitante representa a cualquier consumidor sin autenticación, dado que los endpoints de Panorama no requieren `jwt_required()`. + +```mermaid +%%{init: {"flowchart": {"curve": "stepBefore"}}}%% +flowchart LR + %% Actores + Visitante(["Visitante"]) + Registrado(["Usuario Registrado"]) + Admin(["Administrador"]) + + %% Herencia de actores + Registrado -.->|extiende| Visitante + Admin -.->|extiende| Registrado + + %% Límite del Sistema + subgraph Sistema["SkillStat"] + %% Bloque Visitante + UC1(("Registrarse")) + UC2(("Iniciar sesión con contraseña")) + UC3(("Iniciar sesión con Google")) + UC4(("Cerrar sesión")) + UC5(("Verificar correo")) + UC6(("Reenviar verificación de correo")) + UC7(("Solicitar recuperación de contraseña")) + UC8(("Restablecer contraseña")) + UC19(("Ver catálogo de habilidades")) + UC20(("Ver catálogos combinados")) + UC21(("Ver resumen general del mercado")) + UC22(("Ver ranking de habilidades top")) + UC23(("Ver tendencia de una habilidad")) + UC24(("Ver distribución geográfica")) + UC25(("Ver estadísticas salariales")) + UC26(("Comparar habilidades")) + + %% Bloque Registrado + UC9(("Ver perfil propio")) + UC10(("Actualizar perfil")) + UC11(("Cambiar contraseña")) + UC12(("Ver brecha de habilidades")) + UC13(("Agregar habilidad al perfil")) + UC14(("Eliminar habilidad del perfil")) + UC15(("Crear alerta")) + UC16(("Listar alertas propias")) + UC17(("Eliminar alerta")) + UC18(("Activar o desactivar alerta")) + + %% Bloque Admin + UC27(("Disparar ingesta de vacantes")) + UC28(("Ejecutar respaldo manual")) + UC29(("Listar respaldos")) + UC30(("Restaurar respaldo")) + UC31(("Listar usuarios")) + UC32(("Cambiar rol de usuario")) + UC33(("Cambiar estado de usuario")) + end + + %% Relaciones Visitante + Visitante --> UC1 & UC2 & UC3 & UC5 & UC6 & UC7 & UC8 + Visitante --> UC19 & UC20 & UC21 & UC22 & UC23 & UC24 & UC25 & UC26 + + %% Relaciones Registrado + Registrado --> UC4 & UC9 & UC10 & UC11 & UC12 & UC13 & UC14 + Registrado --> UC15 & UC16 & UC17 & UC18 + + %% Relaciones Admin + Admin --> UC27 & UC28 & UC29 & UC30 & UC31 & UC32 & UC33 + + %% Estilos UML (Neutros) + classDef actor fill:#f3f4f6,stroke:#374151,stroke-width:2px,color:#000 + classDef uc fill:#ffffff,stroke:#6b7280,stroke-width:1px,color:#000 + + class Visitante,Registrado,Admin actor + class UC1,UC2,UC3,UC4,UC5,UC6,UC7,UC8,UC9,UC10,UC11,UC12,UC13,UC14,UC15,UC16,UC17,UC18,UC19,UC20,UC21,UC22,UC23,UC24,UC25,UC26,UC27,UC28,UC29,UC30,UC31,UC32,UC33 uc + style Sistema fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5 +``` + +## Notas de fidelidad al código + +`UC9` (Ver perfil propio) representa dos rutas técnicamente distintas y activas simultáneamente: `GET /auth/me` y `GET /profile/me`, registrado como deuda técnica pendiente de consolidación (ver DT-25). Este diagrama las representa como un único caso de uso porque su propósito funcional es idéntico, no porque el código ya esté unificado. + +Los ocho casos de uso de Panorama (`UC19` a `UC26`) no requieren autenticación en el código real; se muestran accesibles también para Usuario Registrado y Administrador por la relación de generalización, no porque existan rutas separadas para cada rol. diff --git a/docs/diagramas/diagrama-clases.md b/docs/diagramas/diagrama-clases.md new file mode 100644 index 0000000..a3a9911 --- /dev/null +++ b/docs/diagramas/diagrama-clases.md @@ -0,0 +1,162 @@ +# Diagrama de Clases + +Este diagrama representa la estructura de clases de los modelos del dominio, derivada directamente de los trece modelos SQLAlchemy en `backend/app/models/`. A diferencia del [modelo entidad-relación](./modelo-entidad-relacion.md), que expresa cardinalidad a nivel de tablas, este diagrama expresa la estructura a nivel de clases Python: atributos tipados, visibilidad, y métodos reales. + +La mayoría de estas clases son modelos de datos puros (Active Record vía SQLAlchemy), sin lógica de negocio propia solo tres declaran un método explícito (`__repr__`), usado exclusivamente para representación en logs y depuración, no para lógica de dominio. Este diagrama no inventa métodos que no existen en el código: donde una clase no tiene métodos propios, se muestra únicamente con sus atributos. + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#ffffff', 'primaryBorderColor': '#6b7280', 'lineColor': '#6b7280', 'textColor': '#000000', 'clusterBkg': 'transparent', 'clusterBorder': '#94a3b8'}}}%% +classDiagram + direction LR + + namespace Usuarios_y_Autenticacion { + class User { + +int id + +string email + +string first_name + +string last_name + +string password_hash + +string role + +datetime created_at + +datetime password_changed_at + +string intent + +datetime email_verified_at + +boolean is_active + +__repr__() string + } + class OAuthAccount { + +int id + +int user_id + +string provider + +string provider_user_id + +datetime created_at + +__repr__() string + } + class PasswordResetToken { + +int id + +int user_id + +string token_hash + +datetime expires_at + +datetime used_at + +datetime created_at + +__repr__() string + } + class EmailVerificationToken { + +int id + +int user_id + +string token_hash + +datetime expires_at + +datetime used_at + +datetime created_at + +__repr__() string + } + class Backup { + +int id + +int user_id + +string filename + +string storage_url + +datetime created_at + +string status + +bigint file_size_bytes + } + } + + namespace Intersecciones { + class UserSkill { + +int user_id + +int skill_id + +datetime created_at + } + class Alert { + +int id + +int user_id + +int skill_id + +string alert_type + +int threshold_value + +decimal threshold_percentage + +boolean active + +datetime created_at + } + } + + namespace Nucleo_Mercado { + class Category { + +int id + +string name + } + class Skill { + +int id + +string name + +string canonical_name + +int category_id + } + class Job { + +int id + +string source + +string title + +string company + +int city_id + +decimal salary_min + +decimal salary_max + +text raw_description + +string description_hash + +boolean processed + +datetime created_at + +datetime updated_at + } + class JobSkill { + +int job_id + +int skill_id + +decimal confidence_score + +datetime created_at + } + } + + namespace Geografia_y_Analitica { + class City { + +int id + +string name + +string state + +string country + +decimal lat + +decimal lon + } + class TrendSnapshot { + +int id + +int skill_id + +int city_id + +date date + +int demand_count + +decimal growth_rate + +decimal avg_salary + } + } + + %% 1. Relaciones de Usuarios (Internas al dominio) + User "1" --> "many" OAuthAccount : vincula + User "1" --> "many" PasswordResetToken : solicita + User "1" --> "many" EmailVerificationToken : solicita + User "0..1" --> "many" Backup : genera + + %% 2. Relaciones Puente (Conectan Usuarios con Core) + User "1" --> "many" UserSkill : declara + User "1" --> "many" Alert : configura + Skill "1" --> "many" UserSkill : declarada por + Skill "1" --> "many" Alert : monitoreada por + + %% 3. Relaciones Core Mercado + Category "1" --> "many" Skill : clasifica + Job "1" --> "many" JobSkill : requiere + Skill "1" --> "many" JobSkill : detectada en + + %% 4. Relaciones Analíticas y Geográficas + City "0..1" --> "many" Job : ubica + City "0..1" --> "many" TrendSnapshot : ubica + Skill "1" --> "many" TrendSnapshot : medida en +``` + +## Notas de fidelidad al código + +La visibilidad `+` (pública) se aplica a todos los atributos porque SQLAlchemy no impone encapsulamiento real a nivel de columna; no existe distinción de atributos privados o protegidos en ninguno de los trece modelos. + +`User` no declara `relationship()` hacia `PasswordResetToken` ni hacia `EmailVerificationToken`, aunque la relación existe a nivel de llave foránea con `ondelete="CASCADE"`. Este diagrama muestra la relación estructural real de la base de datos; el acceso a nivel de objeto ORM ocurre siempre a través del repositorio correspondiente, no por navegación directa desde `User`. diff --git a/docs/diagramas/diagrama-componentes.md b/docs/diagramas/diagrama-componentes.md new file mode 100644 index 0000000..ee91120 --- /dev/null +++ b/docs/diagramas/diagrama-componentes.md @@ -0,0 +1,97 @@ +# Diagrama de Componentes + +Este diagrama representa la arquitectura por capas real de SkillStat, derivada de los cinco controladores, los nueve servicios de `backend/app/services/`, y las integraciones externas confirmadas en el código. Los servicios nunca acceden a la base de datos directamente ni conocen Flask; median siempre a través de repositorios, según la regla explícita documentada en `backend/app/services/README.md`. + +```mermaid +%%{init: {"flowchart": {"curve": "stepBefore"}}}%% +flowchart LR + %% 1. PRESENTACIÓN + subgraph Presentacion["Capa de Presentación (Controllers)"] + AdminBP["admin_bp"] + AlertsBP["alerts_bp"] + AuthBP["auth_bp"] + PanoramaBP["panorama_bp"] + ProfileBP["profile_bp"] + end + + %% 2. APLICACIÓN + subgraph Aplicacion["Capa de Aplicación (Services)"] + IngestionS["IngestionService"] + SkillsExtractionS["SkillsExtractionService\n(motor spaCy + EntityRuler)"] + MarketTrendsS["MarketTrendsService\n(pandas)"] + AlertsS["AlertsService"] + BackupS["BackupService\n(pg_dump / pg_restore)"] + EmailS["EmailService"] + ProfileS["ProfileService"] + StorageS["RemoteStorageService\n(boto3)"] + end + + %% 3. INFRAESTRUCTURA + subgraph Infraestructura["Capa de Infraestructura (Repositories)"] + Repos[("Repositorios\nuser, job, skill, job_skill,\nalert, trend_snapshot, backup, city")] + DB[("PostgreSQL")] + end + + %% 4. SERVICIOS EXTERNOS + subgraph Externos["Servicios Externos"] + Adzuna[["Adzuna API"]] + Nominatim[["Nominatim\nOpenStreetMap"]] + Resend[["Resend API"]] + R2[["Cloudflare R2"]] + end + + %% Relaciones Presentación -> Aplicación + AdminBP --> IngestionS + AdminBP --> BackupS + ProfileBP --> ProfileS + AuthBP --> EmailS + + %% Relaciones Presentación -> Infraestructura (Bypass arquitectónico) + AlertsBP --> Repos + AuthBP --> Repos + PanoramaBP --> Repos + + %% Relaciones Aplicación -> Infraestructura / Externos / Internos + IngestionS --> SkillsExtractionS + IngestionS --> Repos + IngestionS --> Adzuna + + MarketTrendsS --> Repos + + AlertsS --> Repos + AlertsS --> EmailS + + BackupS --> StorageS + BackupS --> DB + + ProfileS --> Repos + ProfileS -.->|hash de contraseña| Repos + + EmailS --> Resend + StorageS --> R2 + + %% Relaciones Infraestructura -> Base de Datos / Externos + Repos --> DB + Repos -.->|geocodificación de ciudad| Nominatim + + %% Estilos minimalistas + classDef neutral fill:#ffffff,stroke:#6b7280,stroke-width:1px,color:#000 + + class AdminBP,AlertsBP,AuthBP,PanoramaBP,ProfileBP neutral + class IngestionS,SkillsExtractionS,MarketTrendsS,AlertsS,BackupS,EmailS,ProfileS,StorageS neutral + class Repos,DB neutral + class Adzuna,Nominatim,Resend,R2 neutral + + style Presentacion fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5 + style Aplicacion fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5 + style Infraestructura fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5 + style Externos fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5 +``` + +## Notas de fidelidad al código + +`AdminBP` también invoca directamente a `MarketTrendsService` y `AlertsService` a través del endpoint `/trigger-pipeline`, ya representado en el [diagrama de secuencia de alertas](./diagrama-secuencia-alertas.md); se omite esa flecha aquí para no duplicar la misma relación en dos diagramas con propósitos distintos. + +`BackupService` no delega en un repositorio para la ejecución física del respaldo, invoca `pg_dump`/`pg_restore` directamente vía `subprocess`, razón por la cual se conecta a `PostgreSQL` de forma directa en este diagrama, a diferencia del resto de los servicios que median siempre por `Repositorios`. Esta es una excepción real y documentada en el propio código (comentarios de `backup_service.py` explican la necesidad de cerrar la sesión de SQLAlchemy antes de invocar `pg_restore` para evitar deadlocks), no una inconsistencia del diagrama. + +`RemoteStorageService` (Cloudflare R2) actúa como capa de resiliencia adicional sobre el respaldo local ya existente, nunca como mecanismo único: si R2 no está configurado o falla, el respaldo local generado por `BackupService` sigue siendo válido, según lo documentado en [ADR-006](../adr/ADR-006-cloudflare-r2-para-respaldo-remoto.md). diff --git a/docs/diagramas/diagrama-despliegue.md b/docs/diagramas/diagrama-despliegue.md new file mode 100644 index 0000000..e74b174 --- /dev/null +++ b/docs/diagramas/diagrama-despliegue.md @@ -0,0 +1,64 @@ +# Diagrama de Despliegue + +Este diagrama representa la infraestructura real de producción de SkillStat, derivada directamente de las decisiones documentadas en [ADR-001](../adr/ADR-001-neon-como-proveedor-postgresql.md), [ADR-002](../adr/ADR-002-servicios-separados-backend-frontend.md) y [ADR-003](../adr/ADR-003-github-actions-como-disparador-pipeline.md). + +```mermaid +%%{init: {"flowchart": {"curve": "stepBefore"}}}%% +flowchart LR + %% 1. ORÍGENES (Columna Izquierda) + subgraph Cliente["Navegador del usuario"] + Browser["Cliente web"] + end + + subgraph GitHub["GitHub"] + Repo["Repositorio SkillStat"] + Actions["GitHub Actions\ncron diario + workflow_dispatch"] + end + + %% 2. CÓMPUTO (Columna Central) + subgraph RenderInfra["Render (nodo de despliegue)"] + StaticSite["Static Site\nskillstat-ss.onrender.com\nnunca duerme"] + WebService["Web Service\nskillstat.onrender.com\ngunicorn run:app\nduerme tras 15 min de inactividad"] + end + + %% 3. ALMACENAMIENTO (Columna Derecha) + subgraph NeonInfra["Neon (nodo de datos)"] + Postgres[("PostgreSQL\ntier gratuito permanente")] + end + + subgraph R2Infra["Cloudflare R2 (nodo de respaldo)"] + Bucket[("Bucket de respaldos\nvía boto3, egress cero")] + end + + %% Relaciones de red y CI/CD + Browser -->|HTTPS| StaticSite + Browser -->|HTTPS, fetch API| WebService + + %% Retorno estático (El motor lo ruteará por el borde gracias a stepBefore) + StaticSite -.->|sirve archivos estáticos| Browser + + Repo -->|deploy automático en push| StaticSite + Repo -->|deploy automático en push| WebService + Actions -->|POST /api/admin/trigger-pipeline\nX-Pipeline-Trigger-Key| WebService + + %% Relaciones de persistencia + WebService -->|SQLAlchemy / Alembic| Postgres + WebService -->|boto3, S3-compatible| Bucket + + %% Estilos minimalistas + classDef neutral fill:#ffffff,stroke:#6b7280,stroke-width:1px,color:#000 + + class Browser,StaticSite,WebService,Postgres,Bucket,Repo,Actions neutral + + style Cliente fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5 + style GitHub fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5 + style RenderInfra fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5 + style NeonInfra fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5 + style R2Infra fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5 +``` + +## Notas de infraestructura + +El Web Service experimenta cold start de 30 a 60 segundos tras 15 minutos de inactividad, consecuencia aceptada de la decisión documentada en ADR-002. El Static Site nunca duerme bajo el mismo tier, por lo que la carga inicial de la interfaz no sufre ese retraso, solo las llamadas subsecuentes a la API. + +El disparo del pipeline diario ocurre exclusivamente vía GitHub Actions hacia el endpoint dedicado, autenticado con una API key de un solo propósito (ver [ADR-004](../adr/ADR-004-api-key-dedicada-para-pipeline.md)), completamente al margen del sistema de autenticación de usuarios. diff --git a/docs/diagramas/diagrama-secuencia-alertas.md b/docs/diagramas/diagrama-secuencia-alertas.md new file mode 100644 index 0000000..1863a46 --- /dev/null +++ b/docs/diagramas/diagrama-secuencia-alertas.md @@ -0,0 +1,84 @@ +# Diagrama de Secuencia: Generación y Envío de Alertas + +Este diagrama ilustra el flujo completo del pipeline diario de alertas, desde el disparo externo hasta la notificación al usuario. Es el flujo que representa el núcleo funcional de la propuesta de valor de SkillStat: la entrega proactiva de cambios relevantes en el mercado laboral, no solo su consulta pasiva. + +Se eligió este flujo como el diagrama de secuencia ancla del proyecto por sobre el flujo de login, porque conecta directamente tres decisiones arquitectónicas ya documentadas ([ADR-003](../adr/ADR-003-github-actions-como-disparador-pipeline.md), [ADR-004](../adr/ADR-004-api-key-dedicada-para-pipeline.md)) con la lógica de negocio real del sistema. + +```mermaid +sequenceDiagram + autonumber + + box transparent "Desencadenador" + participant GHA as GitHub Actions + end + + box transparent "Presentación / API" + participant EP as Endpoint /trigger-pipeline + end + + box transparent "Capa de Aplicación (Core)" + participant MTS as MarketTrendsService + participant AS as AlertsService + end + + box transparent "Infraestructura (Datos)" + participant Repo as Repositorios\n(alert, skill, trend_snapshot, user) + participant DB as PostgreSQL + end + + box transparent "Infraestructura (Notificaciones)" + participant ES as EmailService + participant Resend as Resend API + end + + GHA->>EP: POST /api/admin/trigger-pipeline\nheader X-Pipeline-Trigger-Key + EP->>EP: hmac.compare_digest(key, PIPELINE_TRIGGER_SECRET) + + alt Key inválida + EP-->>GHA: 401 Unauthorized + else Key válida + EP->>MTS: generate_snapshots() + MTS->>Repo: consulta job_skills recientes + Repo->>DB: SELECT + DB-->>Repo: filas + Repo-->>MTS: datos agregados + + MTS->>Repo: guarda trend_snapshots + Repo->>DB: INSERT trend_snapshots + DB-->>Repo: confirmación de guardado + Repo-->>MTS: éxito + + MTS-->>EP: snapshots_generated + + EP->>AS: evaluate_and_notify() + AS->>Repo: obtiene alertas activas (active = true) + Repo->>DB: SELECT user_alerts + DB-->>Repo: alertas activas + Repo-->>AS: lista de alertas + + loop Por cada alerta activa + AS->>Repo: obtiene snapshot más reciente de la skill + Repo-->>AS: trend_snapshot + AS->>AS: evalúa umbral\n(threshold_value o threshold_percentage\nsegún alert_type) + + alt Umbral cumplido + AS->>ES: send_alert_email(user, skill, snapshot) + ES->>Resend: POST /emails + Resend-->>ES: 200 OK + else Umbral no cumplido + AS->>AS: descarta, continúa loop + end + end + + AS-->>EP: notifications_sent + EP-->>GHA: 200 {snapshots_generated, notifications_sent} + end +``` + +## Notas del flujo + +La autenticación del endpoint ocurre antes de cualquier trabajo real, descartando peticiones no autorizadas sin tocar la base de datos, según lo decidido en ADR-004. + +La evaluación de umbral distingue entre alertas de tipo `ABSOLUTE` (comparación directa contra `threshold_value`) y `TREND` (comparación contra `threshold_percentage`), restricción impuesta a nivel de base de datos mediante `CheckConstraint` en el modelo `Alert`, no solo a nivel de lógica de aplicación. + +El envío real de correos depende de Resend, actualmente en modo sandbox, lo que significa que solo la cuenta propietaria de la API key recibe correos reales sin importar cuántas alertas se generen para otros usuarios. Esta limitación queda documentada como deuda activa fuera del alcance de este diagrama. diff --git a/docs/diagramas/modelo-entidad-relacion.md b/docs/diagramas/modelo-entidad-relacion.md new file mode 100644 index 0000000..ce611c9 --- /dev/null +++ b/docs/diagramas/modelo-entidad-relacion.md @@ -0,0 +1,164 @@ +# Modelo Entidad-Relación + +Este diagrama representa la estructura relacional completa de la base de datos de SkillStat, derivada directamente de los trece modelos SQLAlchemy definidos en `backend/app/models/`. Las cardinalidades y nulabilidades reflejan exactamente las restricciones declaradas en el código, no una interpretación aproximada del dominio. + +`JobSkill` y `UserSkill` se modelan como entidades propias, no como simples líneas de relación muchos-a-muchos, porque ambas llevan llave primaria compuesta y columnas adicionales (`confidence_score` en la primera, `created_at` en ambas), cumpliendo con el patrón de Association Object que exige la tercera forma normal. + +## Notación + +`||` indica exactamente uno. `|o` indica cero o uno. `o{` indica cero o muchos. `|{` indica uno o muchos. `PK` marca llave primaria, `FK` llave foránea, `UK` restricción de unicidad. + +```mermaid +%%{init: { 'er': { 'layoutDirection': 'LR' } } }%% +erDiagram + %% 1. NÚCLEO DE USUARIOS Y AUTENTICACIÓN + USERS ||--o{ OAUTH_ACCOUNTS : "vincula" + USERS ||--o{ PASSWORD_RESET_TOKENS : "solicita" + USERS ||--o{ EMAIL_VERIFICATION_TOKENS : "solicita" + USERS |o--o{ BACKUPS : "genera" + + %% 2. TABLAS PUENTE / INTERSECCIÓN (Centro geométrico) + USERS ||--o{ USER_SKILLS : "declara" + USERS ||--o{ ALERTS : "configura" + SKILLS ||--o{ USER_SKILLS : "declarada por" + SKILLS ||--o{ ALERTS : "monitoreada por" + + %% 3. NÚCLEO CORE: MERCADO Y HABILIDADES + CATEGORIES ||--o{ SKILLS : "clasifica" + JOBS ||--o{ JOB_SKILLS : "requiere" + SKILLS ||--o{ JOB_SKILLS : "detectada en" + + %% 4. NÚCLEO GEOGRÁFICO Y ANALÍTICA + CITIES |o--o{ JOBS : "ubica" + CITIES |o--o{ TREND_SNAPSHOTS : "ubica" + SKILLS ||--o{ TREND_SNAPSHOTS : "medida en" + + %% DEFINICIÓN DE ENTIDADES (El orden aquí no afecta el renderizado, solo las relaciones de arriba) + + CATEGORIES { + int id PK + string name UK + } + + CITIES { + int id PK + string name UK + string state + string country + decimal lat + decimal lon + } + + SKILLS { + int id PK + string name + string canonical_name UK + int category_id FK + } + + JOBS { + int id PK + string source + string title + string company + int city_id FK + decimal salary_min + decimal salary_max + text raw_description + string description_hash UK + boolean processed + datetime created_at + datetime updated_at + } + + JOB_SKILLS { + int job_id PK, FK + int skill_id PK, FK + decimal confidence_score + datetime created_at + } + + USERS { + int id PK + string email UK + string first_name + string last_name + string password_hash + string role + datetime created_at + datetime password_changed_at + string intent + datetime email_verified_at + boolean is_active + } + + USER_SKILLS { + int user_id PK, FK + int skill_id PK, FK + datetime created_at + } + + OAUTH_ACCOUNTS { + int id PK + int user_id FK + string provider + string provider_user_id + datetime created_at + } + + PASSWORD_RESET_TOKENS { + int id PK + int user_id FK + string token_hash UK + datetime expires_at + datetime used_at + datetime created_at + } + + EMAIL_VERIFICATION_TOKENS { + int id PK + int user_id FK + string token_hash UK + datetime expires_at + datetime used_at + datetime created_at + } + + ALERTS { + int id PK + int user_id FK + int skill_id FK + string alert_type + int threshold_value + decimal threshold_percentage + boolean active + datetime created_at + } + + TREND_SNAPSHOTS { + int id PK + int skill_id FK + int city_id FK + date date + int demand_count + decimal growth_rate + decimal avg_salary + } + + BACKUPS { + int id PK + int user_id FK + string filename + string storage_url + datetime created_at + string status + bigint file_size_bytes + } +``` + +## Notas estructurales + +`PasswordResetToken` y `EmailVerificationToken` tienen su llave foránea configurada con `ondelete="CASCADE"`, así que al eliminar un usuario sus tokens pendientes se eliminan junto con él a nivel de base de datos, aunque el modelo `User` no declara `relationship()` explícita hacia ninguno de los dos, a diferencia del resto de sus relaciones. El acceso a esos tokens ocurre siempre a través de su repositorio correspondiente, no por +navegación directa desde el objeto usuario. + +Las llaves foráneas nulables de este modelo son tres: `city_id` en `Job` y en `TrendSnapshot`, permitiendo vacantes o métricas sin geolocalización resuelta, y `user_id` en `Backup`, permitiendo respaldos automáticos ejecutados por el scheduler sin un usuario físico asociado. diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..ef98d8c --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,38 @@ +# frontend/ + +Aqui vive todo lo que el usuario ve y con lo que interactua: las vistas +HTML, los estilos CSS y la logica JavaScript del cliente. + +## Estructura + +``` +frontend/ +├── assets/ +│ ├── css/ Estilos organizados por funcion +│ ├── js/ Logica del cliente organizada por responsabilidad +│ └── images/ Iconos y recursos graficos +└── views/ Un archivo HTML por cada pantalla de la aplicacion +``` + +## Como organizamos los estilos + +- `css/base/` - variables globales, reset y tipografia. Lo que aplica a toda la app. +- `css/components/` - estilos de piezas reutilizables: botones, tarjetas, graficas. +- `css/layouts/` - rejillas y contenedores estructurales. +- `css/pages/` - estilos especificos de cada pantalla. +- `css/main.css` - punto de entrada que importa todo lo anterior. + +## Como organizamos el JavaScript + +- `js/api/` - funciones que se comunican con la API del backend. Una por dominio. +- `js/components/` - inicializacion de componentes visuales como Chart.js o el mapa. +- `js/pages/` - logica especifica de cada pantalla. +- `js/utils/` - funciones compartidas: formateo de numeros, validacion, manejo del token. + +## Las vistas + +Cada pantalla tiene su propio archivo HTML en `views/`. El archivo +`views/panorama.html` es el dashboard principal del sistema. + +No ponemos logica de backend aqui. No accedemos a la base de datos +desde el frontend. Todo pasa por la API REST del backend. diff --git a/frontend/assets/css/base/_reset.css b/frontend/assets/css/base/_reset.css new file mode 100644 index 0000000..03be8e4 --- /dev/null +++ b/frontend/assets/css/base/_reset.css @@ -0,0 +1,95 @@ +/* Consume los tokens de _variables.css,por lo que este archivo debe cargarse despues de _variables.css */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; + scrollbar-gutter: stable; +} + +body { + min-height: 100vh; + font-family: var(--font-body); + font-weight: var(--font-weight-regular); + font-size: var(--text-base); + line-height: 1.5; + color: var(--color-text-primary); + background-color: var(--color-bg-base); + /* Permite que el cambio de tema se sienta como una transicion intencional */ + transition: var(--transition-theme); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +input, +button, +textarea, +select { + font: inherit; + color: inherit; +} + +button { + cursor: pointer; + background: none; + border: none; +} + +a { + color: inherit; + text-decoration: none; +} + +ul, +ol { + list-style: none; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: var(--font-heading); + font-weight: var(--font-weight-bold); + line-height: 1.2; +} + +table { + border-collapse: collapse; + width: 100%; +} + +/* Foco visible obligatorio para navegacion por teclado. Nunca se elimina el outline sin sustituirlo por una alternativa igual o mas visible */ +:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* Respeta la preferencia del usuario de reducir movimiento, sin excepcion, incluyendo el toggle de tema y el carrusel del index */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/frontend/assets/css/base/_typography.css b/frontend/assets/css/base/_typography.css new file mode 100644 index 0000000..952646b --- /dev/null +++ b/frontend/assets/css/base/_typography.css @@ -0,0 +1,118 @@ +/* Definimos las clases de tipografia reutilizables los cuales se aplican explicitamente via clase HTML, no como estilos globales de elemento, para mantener control exacto sobre donde aparece cada tratamiento tipografico */ + +/* Headings del Panorama */ + +.text-hero { + font-family: var(--font-heading); + font-size: var(--text-3xl); + font-weight: var(--font-weight-bold); + line-height: 1.1; + color: var(--color-text-primary); + letter-spacing: -0.02em; +} + +.text-h1 { + font-family: var(--font-heading); + font-size: var(--text-2xl); + font-weight: var(--font-weight-bold); + line-height: 1.2; + color: var(--color-text-primary); +} + +.text-h2 { + font-family: var(--font-heading); + font-size: var(--text-xl); + font-weight: var(--font-weight-semibold); + line-height: 1.3; + color: var(--color-text-primary); +} + +.text-h3 { + font-family: var(--font-heading); + font-size: var(--text-lg); + font-weight: var(--font-weight-semibold); + line-height: 1.4; + color: var(--color-text-primary); +} + +/* Texto de cuerpo */ + +.text-body { + font-family: var(--font-body); + font-size: var(--text-base); + font-weight: var(--font-weight-regular); + line-height: 1.6; + color: var(--color-text-primary); +} + +.text-body-sm { + font-family: var(--font-body); + font-size: var(--text-sm); + font-weight: var(--font-weight-regular); + line-height: 1.5; + color: var(--color-text-secondary); +} + +/* Labels de datos y navegación - Antonio */ + +.text-label { + font-family: var(--font-label); + font-size: var(--text-sm); + font-weight: var(--font-weight-medium); + line-height: 1; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--color-text-secondary); +} + +.text-label-lg { + font-family: var(--font-label); + font-size: var(--text-base); + font-weight: var(--font-weight-medium); + line-height: 1; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-text-secondary); +} + +/* Números del Panorama - KPIs y metricas */ + +.text-metric { + font-family: var(--font-heading); + font-size: var(--text-2xl); + font-weight: var(--font-weight-bold); + line-height: 1; + color: var(--color-text-primary); + /* tabular-nums fuerza ancho fijo en digitos para que los numeros no salten visualmente cuando cambian sus valores en tiempo real */ + font-variant-numeric: tabular-nums; +} + +.text-metric-sm { + font-family: var(--font-heading); + font-size: var(--text-xl); + font-weight: var(--font-weight-semibold); + line-height: 1; + color: var(--color-text-primary); + font-variant-numeric: tabular-nums; +} + +/* Utilidades de color de texto */ + +.text-primary-color { + color: var(--color-primary); +} +.text-green { + color: var(--color-accent-green); +} +.text-orange { + color: var(--color-accent-orange); +} +.text-error { + color: var(--color-semantic-error); +} +.text-muted { + color: var(--color-text-secondary); +} +.text-disabled { + color: var(--color-text-disabled); +} diff --git a/frontend/assets/css/base/_utilities.css b/frontend/assets/css/base/_utilities.css new file mode 100644 index 0000000..9348c0f --- /dev/null +++ b/frontend/assets/css/base/_utilities.css @@ -0,0 +1,19 @@ +/* Clases utilitarias de proposito general que no encajan en tipografia, layout o componentes especificos. Nunca se escribe CSS inline en HTML; toda regla de estilo vive aqui o en su archivo correspondiente */ + +/* Oculta visualmente un elemento pero lo mantiene disponible para lectores de pantalla. Usado en headings semanticos que no necesitan representacion visual */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +/* Garantizamos que el atributo hidden siempre gane sobre cualquier display explicito que un componente defina en su propia clase; sin esto, un elemento con hidden y display:flex propio seguia siendovisible pese a tener hidden puesto. */ +[hidden] { + display: none !important; +} diff --git a/frontend/assets/css/base/_variables.css b/frontend/assets/css/base/_variables.css new file mode 100644 index 0000000..7999a31 --- /dev/null +++ b/frontend/assets/css/base/_variables.css @@ -0,0 +1,109 @@ +:root { + --color-bg-base: #f4f6f9; + --color-bg-surface: #ffffff; + --color-bg-elevated: #ffffff; + --color-bg-overlay: #f0f2f5; + --color-bg-warm: #dcc9a9; + + --color-primary: #4b607f; + --color-primary-light: #7e94b4; + --color-primary-subtle: #e8edf5; + + --color-accent-green: #21a675; + --color-accent-green-light: #29d194; + --color-accent-green-subtle: #e3f6ee; + + --color-accent-orange: #f3701e; + --color-accent-orange-light: #f58945; + + --color-semantic-error: #c72c31; + --color-semantic-error-subtle: #f9e5e6; + + --color-text-primary: #1a2535; + --color-text-secondary: #4b607f; + --color-text-disabled: #9fafc6; + + --color-border: #d1d9e3; + --color-border-subtle: #e8edf2; + + --font-display: "Gaseok One", sans-serif; + --font-heading: "Bakbak One", sans-serif; + --font-body: "Bakbak One", sans-serif; + --font-label: "Antonio", sans-serif; + + --font-weight-light: 300; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.5rem; + --text-2xl: 2rem; + --text-3xl: 2.5rem; + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + --space-16: 4rem; + + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-full: 9999px; + + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06); + --shadow-md: 0 2px 8px rgba(15, 23, 42, 0.08); + + --transition-fast: 150ms ease; + --transition-base: 250ms ease; + --transition-theme: + background-color 250ms ease, color 250ms ease, border-color 250ms ease; + + --z-base: 0; + --z-surface: 10; + --z-elevated: 100; + --z-overlay: 1000; +} + +[data-theme="dark"] { + --color-bg-base: #0f1923; + --color-bg-surface: #1a2535; + --color-bg-elevated: #2e3d52; + --color-bg-overlay: #3a4d64; + + --color-bg-warm: var(--color-bg-elevated); + + --color-primary: #4b607f; + --color-primary-light: #7e94b4; + --color-primary-subtle: #9fafc6; + + --color-accent-green: #21a675; + --color-accent-green-light: #29d194; + --color-accent-green-subtle: #16332a; + + --color-accent-orange: #f3701e; + --color-accent-orange-light: #f58945; + + --color-semantic-error: #c72c31; + --color-semantic-error-subtle: #9b2226; + + --color-text-primary: #e8edf2; + --color-text-secondary: #9fafc6; + --color-text-disabled: #616161; + + --color-border: #2e3d52; + --color-border-subtle: #1a2535; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4); +} diff --git a/frontend/assets/css/components/_alerts.css b/frontend/assets/css/components/_alerts.css new file mode 100644 index 0000000..06d10c7 --- /dev/null +++ b/frontend/assets/css/components/_alerts.css @@ -0,0 +1 @@ +/* _alerts.css — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/css/components/_buttons.css b/frontend/assets/css/components/_buttons.css new file mode 100644 index 0000000..bb127ee --- /dev/null +++ b/frontend/assets/css/components/_buttons.css @@ -0,0 +1,83 @@ +/* Sistema de botones. El primario usa el azul de marca porque asi quedo aprobado en el mockup, el naranja se reserva para enfasis textual y datos, nunca para la superficie de un boton */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + font-family: var(--font-label); + font-size: var(--text-base); + font-weight: var(--font-weight-medium); + letter-spacing: 0.02em; + padding: var(--space-3) var(--space-6); + border-radius: var(--radius-md); + border: 1px solid transparent; + cursor: pointer; + transition: var(--transition-base); + text-decoration: none; + white-space: nowrap; +} + +.btn--primary { + background-color: var(--color-primary); + color: #ffffff; +} + +.btn--primary:hover { + background-color: var(--color-primary-light); +} + +.btn--secondary { + background-color: var(--color-bg-surface); + color: var(--color-text-primary); + border-color: var(--color-border); +} + +.btn--secondary:hover { + border-color: var(--color-primary-light); + background-color: var(--color-bg-elevated); +} + +/* Lucide asigna la clase "lucide" a cada icono que renderiza, sin importar el nombre del icono especifico. La usamos para controlar el tamaño sin tener que volver a tocar el HTML */ +.btn .lucide { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +/* Pills de seleccion tipo toggle, distintos de .btn porque representan un estado activo/inactivo, no una accion puntual */ +.pill { + display: inline-flex; + align-items: center; + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-full); + border: 1px solid var(--color-border); + background-color: var(--color-bg-surface); + color: var(--color-text-secondary); + font-family: var(--font-label); + font-size: var(--text-sm); + cursor: pointer; + transition: var(--transition-base); + white-space: nowrap; +} + +.pill:hover { + border-color: var(--color-primary-light); +} + +.pill--active { + background-color: var(--color-primary); + border-color: var(--color-primary); + color: #ffffff; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Creamos esta variante pequena para que el boton encaje dentro de una card de grid sin desbordar. */ +.btn--sm { + padding: var(--space-1) var(--space-3); + font-size: var(--text-sm); +} diff --git a/frontend/assets/css/components/_cards.css b/frontend/assets/css/components/_cards.css new file mode 100644 index 0000000..078a3c8 --- /dev/null +++ b/frontend/assets/css/components/_cards.css @@ -0,0 +1,140 @@ +/* Sistema de tarjetas. Cubre las tarjetas de metrica del carrusel, las tarjetas de propuesta de valor, y el preview del Panorama simulado como ventana de navegador */ + + + +/* Superficie compartida de fondo y borde. Se aplica como clase utilitaria junto a metric-card, value-card y browser-chrome para no repetir las mismas dos declaraciones en cada variante */ +.surface { + background-color: var(--color-bg-surface); + border: 1px solid var(--color-border-subtle); +} + +/* Metric Card - slides del carrusel */ +.metric-card { + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-sm); +} + +/* Header compartido: mismo patron flex justify-between aparecia en metric-card__header y panorama-preview__header */ +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-4); +} + +.metric-card__live { + display: inline-flex; + align-items: center; + font-family: var(--font-label); + font-size: var(--text-xs); + font-weight: var(--font-weight-medium); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-accent-green); +} + +.metric-card__value { + margin-bottom: var(--space-2); +} + +.metric-card__detail { + color: var(--color-text-secondary); +} + +/* Value Card - propuestas de valor */ +.value-props__grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-6); +} + +@media (min-width: 1440px) { + .value-props__grid { + grid-template-columns: repeat(3, 1fr); + } +} + +.value-card { + border-radius: var(--radius-lg); + padding: var(--space-6); +} + +.value-card__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: var(--radius-md); + background-color: var(--color-accent-green-subtle); + color: var(--color-accent-green); + margin-bottom: var(--space-4); +} + +.value-card__icon .lucide { + width: 24px; + height: 24px; +} + +.value-card__title { + margin-bottom: var(--space-2); +} + +.value-card__desc { + color: var(--color-text-secondary); +} + +/* Browser Chrome - preview simulado del Panorama */ +.browser-chrome { + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-md); +} + +.browser-chrome__bar { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + background-color: var(--color-bg-elevated); + border-bottom: 1px solid var(--color-border-subtle); +} + +.browser-chrome__dot { + width: 8px; + height: 8px; + border-radius: var(--radius-full); +} + +/* Usamos los colores de marca */ +.browser-chrome__dot:nth-child(1) { background-color: var(--color-accent-orange); } +.browser-chrome__dot:nth-child(2) { background-color: var(--color-primary-light); } +.browser-chrome__dot:nth-child(3) { background-color: var(--color-accent-green); } + +.browser-chrome__url { + margin-left: var(--space-2); + color: var(--color-text-secondary); +} + +.browser-chrome__content { + padding: var(--space-6); +} + +.panorama-preview__live { + display: inline-flex; + align-items: center; + color: var(--color-accent-green); +} + +.panorama-preview__kpis { + display: flex; + gap: var(--space-4); + flex-wrap: wrap; +} + +.preview-kpi { + display: flex; + flex-direction: column; + gap: var(--space-1); +} diff --git a/frontend/assets/css/components/_carousel.css b/frontend/assets/css/components/_carousel.css new file mode 100644 index 0000000..9f0239f --- /dev/null +++ b/frontend/assets/css/components/_carousel.css @@ -0,0 +1,46 @@ +/* Mecanica del carrusel. La barra de scroll se oculta visualmente pero el gesto de swipe sigue funcionando para evitar el aspecto inacabado de una scrollbar nativa expuesta en mobile */ + +.carousel { + position: relative; +} + +.scroll-row { + display: flex; + overflow-x: auto; + scroll-snap-type: x mandatory; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; +} + +.scroll-row::-webkit-scrollbar { + display: none; +} + +.carousel__slide { + flex: 0 0 100%; + scroll-snap-align: start; +} + +.carousel__dots { + display: flex; + justify-content: center; + align-items: center; + gap: var(--space-2); + margin-top: var(--space-4); +} + +.carousel__dot { + width: 8px; + height: 8px; + border-radius: var(--radius-full); + background-color: var(--color-border); + border: none; + padding: 0; + cursor: pointer; + transition: var(--transition-base); +} + +.carousel__dot--active { + width: 24px; + background-color: var(--color-primary); +} diff --git a/frontend/assets/css/components/_charts.css b/frontend/assets/css/components/_charts.css new file mode 100644 index 0000000..d55b413 --- /dev/null +++ b/frontend/assets/css/components/_charts.css @@ -0,0 +1 @@ +/* _charts.css — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/css/components/_footer.css b/frontend/assets/css/components/_footer.css new file mode 100644 index 0000000..a9eb87c --- /dev/null +++ b/frontend/assets/css/components/_footer.css @@ -0,0 +1,73 @@ +/* Footer reutilizable en todas las vistas del proyecto, no solo index. En desktop usa grid para que la marca quede a la izquierda ocupando ambas filas, y los enlaces legales + copyright queden alineados a la derecha; en mobile todo se apila centrado */ + +.site-footer { + background-color: var(--color-bg-surface); + border-top: 1px solid var(--color-border-subtle); + padding-block: var(--space-10); + margin-top: var(--space-12); +} + +.site-footer .container { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.footer__brand { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.footer__tagline { + color: var(--color-text-secondary); +} + +.footer__legal { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.footer__link { + color: var(--color-text-secondary); +} + +.footer__link:hover { + color: var(--color-primary); +} + +.footer__copy { + color: var(--color-text-disabled); + font-size: var(--text-sm); +} + +@media (min-width: 768px) { + .site-footer .container { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + align-items: start; + column-gap: var(--space-8); + row-gap: var(--space-2); + } + + .footer__brand { + grid-column: 1; + grid-row: 1 / 3; + } + + .footer__legal { + grid-column: 2; + grid-row: 1; + flex-direction: row; + gap: var(--space-6); + justify-content: flex-end; + } + + .footer__copy { + grid-column: 2; + grid-row: 2; + text-align: right; + } +} diff --git a/frontend/assets/css/components/_forms.css b/frontend/assets/css/components/_forms.css new file mode 100644 index 0000000..e5e4bb1 --- /dev/null +++ b/frontend/assets/css/components/_forms.css @@ -0,0 +1,94 @@ +.form-group { + margin-bottom: var(--space-5); +} + +.form-label { + display: block; + font-family: var(--font-label); + font-size: var(--text-sm); + color: var(--color-text-primary); + margin-bottom: var(--space-2); +} + +.form-input { + width: 100%; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background-color: var(--color-bg-surface); + color: var(--color-text-primary); + font-family: var(--font-body); + font-size: var(--text-base); + transition: var(--transition-fast); +} + +.form-input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-subtle); +} + +.form-input::placeholder { + color: var(--color-text-disabled); +} + +.form-error { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + background-color: var(--color-semantic-error-subtle); + color: var(--color-semantic-error); + margin-bottom: var(--space-4); +} + +.password-checklist { + list-style: none; + margin-top: var(--space-2); + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.password-checklist li { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--color-text-disabled); + font-size: var(--text-sm); +} + +.password-checklist li::before { + content: "○"; +} + +.password-checklist li.is-valid { + color: var(--color-accent-green); +} + +.password-checklist li.is-valid::before { + content: "✓"; +} + +.form-group--checkbox { + display: flex; +} + +.form-checkbox-label { + display: flex; + align-items: flex-start; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--color-text-secondary); + cursor: pointer; +} + +.form-checkbox-label input { + margin-top: 2px; + flex-shrink: 0; +} + +.form-checkbox-label a { + color: var(--color-primary); +} diff --git a/frontend/assets/css/components/_navbar.css b/frontend/assets/css/components/_navbar.css new file mode 100644 index 0000000..7ba7cc3 --- /dev/null +++ b/frontend/assets/css/components/_navbar.css @@ -0,0 +1,261 @@ +/* Header fijo en la parte superior. El navbar replica la logica de ancho maximo del .container directamente, porque vive dentro de
sin un div .container intermedio, y necesita el mismo comportamiento responsivo sin depender de esa clase */ + +.site-header { + position: sticky; + top: 0; + z-index: var(--z-elevated); + background-color: var(--color-bg-surface); + border-bottom: 1px solid var(--color-border-subtle); + transition: var(--transition-theme); +} + +.navbar { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + max-width: 1280px; + margin-inline: auto; + padding: var(--space-4); + gap: var(--space-4); +} + +@media (min-width: 768px) { + .navbar { + padding: var(--space-4) var(--space-6); + } +} + +@media (min-width: 1440px) { + .navbar { + padding: var(--space-4) var(--space-8); + } +} + +@media (min-width: 1920px) { + .navbar { + max-width: 1600px; + } +} + +.navbar__logo { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.navbar__logo-img { + display: block; +} + +.navbar__menu-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: none; + border: none; + color: var(--color-text-primary); + cursor: pointer; +} + +.navbar__links { + display: none; + align-items: center; + gap: var(--space-6); + list-style: none; +} + +.navbar__link { + font-family: var(--font-label); + color: var(--color-text-secondary); + text-decoration: none; +} + +.navbar__link:hover { + color: var(--color-text-primary); +} + +.navbar__link--active { + color: var(--color-primary); + font-weight: var(--font-weight-medium); +} + +.navbar__actions { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.navbar__logout { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: none; + border: none; + color: var(--color-text-secondary); + cursor: pointer; +} + +.navbar__logout:hover { + color: var(--color-semantic-error); +} + +.navbar__profile-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: var(--radius-full); + background-color: var(--color-bg-elevated); + border: 1px solid var(--color-border); + color: var(--color-text-primary); + transition: var(--transition-base); + flex-shrink: 0; + text-decoration: none; +} + +.navbar__profile-link:hover { + border-color: var(--color-primary-light); +} + +.navbar__profile-link--active { + color: var(--color-primary); + border-color: var(--color-primary); +} + +.navbar__link[data-pending-page], +.nav-drawer__link[data-pending-page] { + opacity: 0.5; +} + +@media (min-width: 768px) { + .navbar__menu-toggle { + display: none; + } + + .navbar__links { + display: flex; + } +} + +/* Drawer de navegacion mobile. Vive fuera del flujo del header para cubrir toda la pantalla sin que el contenido de abajo lo empuje */ +.nav-drawer { + position: fixed; + inset: 0; + z-index: var(--z-overlay); + visibility: hidden; +} + +.nav-drawer__backdrop { + position: absolute; + inset: 0; + background-color: rgba(15, 23, 42, 0.5); + opacity: 0; + transition: opacity var(--transition-base); +} + +.nav-drawer__panel { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 280px; + max-width: 80vw; + background-color: var(--color-bg-surface); + padding: var(--space-6); + display: flex; + flex-direction: column; + transform: translateX(-100%); + transition: transform var(--transition-base); +} + +.nav-drawer--open { + visibility: visible; +} + +.nav-drawer--open .nav-drawer__backdrop { + opacity: 1; +} + +.nav-drawer--open .nav-drawer__panel { + transform: translateX(0); +} + +.nav-drawer__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-8); +} + +.nav-drawer__header-actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.nav-drawer__close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: none; + border: none; + color: var(--color-text-primary); + cursor: pointer; +} + +.nav-drawer__links { + display: flex; + flex-direction: column; + gap: var(--space-1); + list-style: none; + margin-bottom: auto; +} + +.nav-drawer__link { + display: block; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + color: var(--color-text-secondary); + text-decoration: none; +} + +.nav-drawer__link--active { + background-color: var(--color-primary-subtle); + color: var(--color-primary); + font-weight: var(--font-weight-medium); +} + +.nav-drawer__logout { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + background: none; + border: none; + border-top: 1px solid var(--color-border-subtle); + margin-top: var(--space-4); + padding-top: var(--space-6); + color: var(--color-text-secondary); + cursor: pointer; + width: 100%; +} + +.navbar__logout[hidden], +.navbar__profile-link[hidden], +.nav-drawer__logout[hidden] { + display: none; +} + +@media (min-width: 768px) { + .nav-drawer { + display: none; + } +} diff --git a/frontend/assets/css/components/_tables.css b/frontend/assets/css/components/_tables.css new file mode 100644 index 0000000..d475565 --- /dev/null +++ b/frontend/assets/css/components/_tables.css @@ -0,0 +1 @@ +/* _tables.css — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/css/components/_theme-toggle.css b/frontend/assets/css/components/_theme-toggle.css new file mode 100644 index 0000000..db90212 --- /dev/null +++ b/frontend/assets/css/components/_theme-toggle.css @@ -0,0 +1,49 @@ +/* Toggle de tema claro/oscuro. El cambio entre iconos sol y luna se anima con una transicion de opacidad y rotacion, nunca con reemplazo abrupto, para que el cambio de tema se sienta como una decision deliberada del usuario, no un parpadeo */ + +.theme-toggle { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: var(--radius-full); + background-color: var(--color-bg-elevated); + border: 1px solid var(--color-border); + color: var(--color-text-primary); + transition: var(--transition-base); + flex-shrink: 0; +} + +.theme-toggle:hover { + border-color: var(--color-primary-light); +} + +.theme-toggle__icon { + position: absolute; + width: 20px; + height: 20px; + transition: opacity var(--transition-base), transform var(--transition-base); +} + +.theme-toggle__icon--sun { + opacity: 1; + transform: scale(1) rotate(0deg); + color: var(--color-accent-orange); +} + +.theme-toggle__icon--moon { + opacity: 0; + transform: scale(0.5) rotate(-90deg); + color: var(--color-primary-light); +} + +[data-theme="dark"] .theme-toggle__icon--sun { + opacity: 0; + transform: scale(0.5) rotate(90deg); +} + +[data-theme="dark"] .theme-toggle__icon--moon { + opacity: 1; + transform: scale(1) rotate(0deg); +} diff --git a/frontend/assets/css/layouts/_containers.css b/frontend/assets/css/layouts/_containers.css new file mode 100644 index 0000000..8889d3a --- /dev/null +++ b/frontend/assets/css/layouts/_containers.css @@ -0,0 +1,26 @@ +/* Contenedor base reutilizable. Centra el contenido y aplica padding horizontal consistente segun el breakpoint, evitando que el contenido toque los bordes de la pantalla en mobile o se estire sin limite en desktop */ + +.container { + width: 100%; + max-width: 1280px; + margin-inline: auto; + padding-inline: var(--space-4); +} + +@media (min-width: 768px) { + .container { + padding-inline: var(--space-6); + } +} + +@media (min-width: 1440px) { + .container { + padding-inline: var(--space-8); + } +} + +@media (min-width: 1920px) { + .container { + max-width: 1600px; + } +} diff --git a/frontend/assets/css/layouts/_grid.css b/frontend/assets/css/layouts/_grid.css new file mode 100644 index 0000000..b00529c --- /dev/null +++ b/frontend/assets/css/layouts/_grid.css @@ -0,0 +1,13 @@ +/* Clases de grilla genericas reutilizables. Elegimos 1024px como breakpoint en lugar de 768px porque en tablets (portrait) los formularios de una columna siguen ofreciendo mejor experiencia de usuario. */ + +.grid-2-col { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-6); +} + +@media (min-width: 1024px) { + .grid-2-col { + grid-template-columns: 3fr 2fr; + } +} diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css new file mode 100644 index 0000000..ff040f8 --- /dev/null +++ b/frontend/assets/css/main.css @@ -0,0 +1,39 @@ +/* Punto de entrada único, todas las páginas HTML referencian únicamente este archivo. El orden de importación es arquitecturalmente obligatorio: tokens -> reset -> tipografía -> componentes -> layouts -> páginas. Cambiar el orden puede causar que tokens no estén disponibles cuando los componentes los necesitan */ + +/* Instanciamos la base de todo (tokens, reset y tipografía) */ +@import url("base/_variables.css"); +@import url("base/_reset.css"); +@import url("base/_typography.css"); +@import url("base/_utilities.css"); + +/* Instanciamos los componentes (descomente conforme se crea cada archivo) */ +@import url("components/_navbar.css"); +@import url("components/_buttons.css"); +@import url("components/_cards.css"); +@import url("components/_forms.css"); +/* @import url('components/_charts.css'); */ +/* @import url('components/_badges.css'); */ +/* @import url('components/_alerts.css'); */ +/* @import url('components/_tables.css'); */ +@import url("components/_carousel.css"); +@import url("components/_footer.css"); +@import url("components/_theme-toggle.css"); + +/* Instanciamos los layouts (descomente conforme se crea cada archivo) */ +@import url("layouts/_containers.css"); +@import url("layouts/_grid.css"); +/* @import url('layouts/_navbar-layout.css'); */ + +/* Instanciamos las páginas (descomente conforme se crea cada archivo) */ +@import url("pages/_index.css"); +@import url("pages/_panorama.css"); +@import url("pages/_auth.css"); +@import url("pages/_alertas.css"); +@import url("pages/_comparar.css"); +@import url("pages/_perfil.css"); +@import url("pages/_habilidades.css"); +@import url("pages/_salarios.css"); +@import url("pages/_verificar-correo.css"); +@import url("pages/_reportes.css"); +@import url("pages/_admin.css"); +@import url("pages/_errors.css"); diff --git a/frontend/assets/css/pages/_admin.css b/frontend/assets/css/pages/_admin.css new file mode 100644 index 0000000..bd77221 --- /dev/null +++ b/frontend/assets/css/pages/_admin.css @@ -0,0 +1,206 @@ +.admin-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 40vh; +} + +.admin-actions-section { + padding: var(--space-6) 0 var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-3); + align-items: flex-start; +} + +.admin-action-msg { + margin: 0; +} + +.admin-list-section { + padding-bottom: var(--space-8); +} + +.admin-empty { + padding: var(--space-8) 0; + color: var(--color-text-secondary); +} + +.admin-table-wrapper { + overflow-x: auto; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +/* Tabla como cards apiladas en mobile */ + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-sm); +} + +.admin-table thead { + display: none; +} + +.admin-table, +.admin-table tbody, +.admin-table tr, +.admin-table td { + display: block; + width: 100%; +} + +.admin-table tr { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + margin-bottom: var(--space-3); + padding: var(--space-3); +} + +.admin-table td { + border-bottom: none; + padding: var(--space-1) 0; + display: flex; + gap: var(--space-2); +} + +.admin-table td::before { + content: attr(data-label); + font-weight: 500; + color: var(--color-text-secondary); + min-width: 100px; + flex-shrink: 0; +} + +/* Controles de paginación */ + +.admin-pagination { + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-4) 0; +} + +.admin-page-info { + color: var(--color-text-secondary); +} + +/* Sub-navegación entre secciones del panel Admin */ + +.admin-subnav { + padding-block: 0 var(--space-6); +} + +.admin-subnav__pills { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +/* Tabla normal en desktop */ + +@media (min-width: 768px) { + .admin-table thead { + display: table-header-group; + } + + .admin-table { + display: table; + } + + .admin-table tbody { + display: table-row-group; + } + + .admin-table tr { + display: table-row; + border: none; + border-radius: 0; + margin-bottom: 0; + padding: 0; + } + + .admin-table th { + text-align: left; + padding: var(--space-2) var(--space-3); + border-bottom: 2px solid var(--color-border); + color: var(--color-text-secondary); + font-weight: 500; + white-space: nowrap; + } + + .admin-table td { + display: table-cell; + border-bottom: 1px solid var(--color-border); + padding: var(--space-3); + vertical-align: middle; + } + + .admin-table td::before { + display: none; + } +} + +.admin-role-select { + background: var(--color-bg-surface); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-1) var(--space-2); + font-size: var(--text-sm); + cursor: pointer; +} + +.admin-role-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.admin-role-select option[value="ADMIN"] { + color: var(--color-accent-orange); +} + +.admin-status-label { + display: inline-flex; + align-items: center; + gap: var(--space-2); + cursor: pointer; + font-size: var(--text-sm); + color: var(--color-text-secondary); +} + +.admin-status-label:has(input:disabled) { + opacity: 0.5; + cursor: not-allowed; +} + +.admin-row-error { + display: block; + font-size: var(--text-xs); + max-width: 180px; +} + +.restore-dialog { + border: none; + border-radius: var(--radius-md); + padding: var(--space-6); + max-width: 480px; + width: 90vw; + background: var(--color-bg-surface); + color: var(--color-text-primary); + box-shadow: var(--shadow-md); +} +.restore-dialog::backdrop { + background: rgba(0, 0, 0, 0.5); +} +.restore-dialog h2 { + margin-top: 0; +} +.restore-dialog-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-3); + margin-top: var(--space-4); +} diff --git a/frontend/assets/css/pages/_alertas.css b/frontend/assets/css/pages/_alertas.css new file mode 100644 index 0000000..257b527 --- /dev/null +++ b/frontend/assets/css/pages/_alertas.css @@ -0,0 +1,153 @@ +.alertas-form-section { + padding: var(--space-6) 0; +} + +.alertas-form { + display: grid; + gap: var(--space-4); + /* max-width se elimina para permitir que llene la columna del grid */ +} + +.alertas-list-section { + padding: var(--space-2) 0 var(--space-8); +} + +.alertas-list-section h2 { + margin-bottom: var(--space-4); +} + +.alertas-empty { + padding: var(--space-8) 0; + color: var(--color-text-secondary); +} + +.alertas-table-wrapper { + overflow-x: auto; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +.alerta-table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-sm); +} + +/* Estilos de Card Apilada por Defecto (Mobile) */ + +.alerta-table thead { + display: none; +} + +.alerta-table, +.alerta-table tbody, +.alerta-table tr, +.alerta-table td { + display: block; + width: 100%; +} + +.alerta-table tr { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + margin-bottom: var(--space-3); + padding: var(--space-3); +} + +.alerta-table td { + border-bottom: none; + padding: var(--space-1) 0; + display: flex; + gap: var(--space-2); +} + +.alerta-table td::before { + content: attr(data-label); + font-weight: 500; + color: var(--color-text-secondary); + min-width: 90px; + flex-shrink: 0; +} + +.alerta-table td:last-child { + width: 100%; + white-space: normal; + padding-top: var(--space-3); +} + +/* Estilos de Tabla Normal (Desktop) */ +@media (min-width: 768px) { + .alerta-table thead { + display: table-header-group; + } + + .alerta-table { + display: table; + } + + .alerta-table tbody { + display: table-row-group; + } + + .alerta-table tr { + display: table-row; + border: none; + border-radius: 0; + margin-bottom: 0; + padding: 0; + } + + .alerta-table th { + text-align: left; + padding: var(--space-2) var(--space-3); + border-bottom: 2px solid var(--color-border); + color: var(--color-text-secondary); + font-weight: 500; + white-space: nowrap; + } + + .alerta-table td { + display: table-cell; + border-bottom: 1px solid var(--color-border); + padding: var(--space-3); + vertical-align: middle; + } + + .alerta-table td::before { + display: none; + } + + .alerta-table td:last-child { + width: 1%; + white-space: nowrap; + padding-top: var(--space-3); + } +} + +.alertas-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 40vh; +} + +.alert-type-toggle { + display: flex; + gap: var(--space-4); + margin-top: var(--space-2); +} + +.alert-type-option { + display: flex; + align-items: center; + gap: var(--space-2); + cursor: pointer; + font-weight: 500; + color: var(--color-text-primary); +} + +.alert-type-option input[type="radio"] { + accent-color: var(--color-primary); + width: 1.25rem; + height: 1.25rem; +} diff --git a/frontend/assets/css/pages/_auth.css b/frontend/assets/css/pages/_auth.css new file mode 100644 index 0000000..23f4c05 --- /dev/null +++ b/frontend/assets/css/pages/_auth.css @@ -0,0 +1,214 @@ +.auth-screen { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.auth-form-panel { + position: relative; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-bg-surface); + padding: var(--space-8) var(--space-4); + flex: 1; +} + +.auth-close { + position: absolute; + top: var(--space-4); + right: var(--space-4); + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--radius-full); + background: none; + border: none; + color: var(--color-text-primary); + cursor: pointer; +} + +.auth-form-panel__content { + width: 100%; + max-width: 400px; +} + +.auth-form-panel__logo { + margin-bottom: var(--space-8); +} + +.auth-form-panel__subtitle { + color: var(--color-text-secondary); + margin-top: var(--space-2); + margin-bottom: var(--space-6); +} + +.auth-form-panel__placeholder { + color: var(--color-text-disabled); + font-style: italic; + margin-bottom: var(--space-6); +} + +.auth-form-panel__switch { + color: var(--color-text-secondary); +} + +.auth-form-panel__switch-link { + background: none; + border: none; + color: var(--color-accent-orange); + font-weight: var(--font-weight-medium); + cursor: pointer; + padding: 0; +} + +.auth-marketing-panel { + display: none; + align-items: center; + justify-content: center; + background-color: var(--color-bg-overlay); + padding: var(--space-8); +} + +.auth-marketing-panel__content { + max-width: 480px; +} + +.auth-marketing-panel__headline { + font-family: var(--font-heading); + font-size: var(--text-2xl); + margin-bottom: var(--space-3); +} + +.auth-marketing-panel__subtitle { + color: var(--color-text-secondary); +} + +@media (min-width: 1024px) { + .auth-screen { + flex-direction: row; + } + + .auth-marketing-panel { + display: flex; + flex: 1; + } + + [data-auth-mode="login"] .auth-marketing-panel { + order: 1; + } + + [data-auth-mode="login"] .auth-form-panel { + order: 2; + } + + [data-auth-mode="register"] .auth-form-panel { + order: 1; + } + + [data-auth-mode="register"] .auth-marketing-panel { + order: 2; + } +} + +.auth-form { + margin-top: var(--space-6); + margin-bottom: var(--space-4); +} + +.auth-form__submit { + width: 100%; + margin-top: var(--space-2); +} + +.legal-placeholder { + min-height: 100vh; + display: flex; + align-items: center; +} + +.legal-placeholder .container { + max-width: 600px; +} + +.legal-placeholder p { + color: var(--color-text-secondary); + margin-block: var(--space-4) var(--space-6); +} + +.auth-google { + margin-bottom: var(--space-2); +} + +[data-google-button] { + width: 100%; + min-height: 44px; + display: flex; + justify-content: center; +} + +.auth-divider { + display: flex; + align-items: center; + gap: var(--space-3); + margin-block: var(--space-5); +} + +.auth-divider::before, +.auth-divider::after { + content: ""; + flex: 1; + height: 1px; + background-color: var(--color-border-subtle); +} + +.auth-divider__text { + color: var(--color-text-disabled); + white-space: nowrap; +} + +/* Modal de Vinculación de Google */ +.auth-modal-overlay { + position: fixed; + inset: 0; + background-color: var(--color-bg-overlay); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: var(--space-4); +} + +.auth-modal-overlay[hidden] { + display: none !important; +} + +.auth-modal { + width: 100%; + max-width: 400px; + padding: var(--space-6); + border-radius: var(--radius-lg, 12px); + text-align: center; +} + +.auth-modal__title { + margin-bottom: var(--space-4); + font-family: var(--font-heading); + font-size: var(--text-xl); +} + +.auth-modal__text { + margin-bottom: var(--space-6); +} + +.auth-modal__actions { + display: flex; + gap: var(--space-4); + justify-content: center; +} + +.auth-modal__actions .btn { + flex: 1; +} diff --git a/frontend/assets/css/pages/_comparar.css b/frontend/assets/css/pages/_comparar.css new file mode 100644 index 0000000..d0c61c0 --- /dev/null +++ b/frontend/assets/css/pages/_comparar.css @@ -0,0 +1,65 @@ +.comparar-header { + padding-block: var(--space-8) var(--space-6); +} + +.comparar-header__subtitle { + color: var(--color-text-secondary); + margin-top: var(--space-2); +} + +.comparar-selector { + padding-block: var(--space-6); +} + +.comparar-selector__chips { + gap: var(--space-2); + padding-bottom: var(--space-2); +} + +.comparar-selector__chip { + flex: 0 0 auto; +} + +.comparar-selector__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + margin-top: var(--space-4); +} + +.comparar-selector__count { + color: var(--color-text-secondary); +} + +.comparar-results { + padding-block: var(--space-6) var(--space-8); +} + +.comparar-results .container { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); +} + +@media (min-width: 768px) { + .comparar-results .container { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } +} + +.comparar-results__salary { + color: var(--color-text-secondary); + margin-top: var(--space-2); +} + +.comparar-error { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + background-color: var(--color-semantic-error-subtle); + color: var(--color-semantic-error); + margin-top: var(--space-4); +} diff --git a/frontend/assets/css/pages/_errors.css b/frontend/assets/css/pages/_errors.css new file mode 100644 index 0000000..91e4e61 --- /dev/null +++ b/frontend/assets/css/pages/_errors.css @@ -0,0 +1,22 @@ +.error-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 60vh; +} + +.error-page__content { + text-align: center; +} + +.error-page__code { + font-size: calc(var(--text-3xl) * 5); + font-weight: var(--font-weight-bold); + color: var(--color-accent-orange); + margin-bottom: var(--space-2); +} + +.error-page__message { + color: var(--color-text-secondary); + margin-bottom: var(--space-6); +} diff --git a/frontend/assets/css/pages/_habilidades.css b/frontend/assets/css/pages/_habilidades.css new file mode 100644 index 0000000..de118a6 --- /dev/null +++ b/frontend/assets/css/pages/_habilidades.css @@ -0,0 +1,165 @@ +.habilidades-header { + padding-block: var(--space-8) var(--space-6); +} + +.habilidades-content { + padding-block: var(--space-4) var(--space-12); +} + +.habilidades-toolbar { + display: flex; + flex-direction: column; + gap: var(--space-4); + margin-bottom: var(--space-8); +} + +@media (min-width: 768px) { + .habilidades-toolbar { + flex-direction: row; + align-items: center; + justify-content: space-between; + } +} + +.habilidades-search { + position: relative; + flex: 1; + max-width: 400px; +} + +.habilidades-search__icon { + position: absolute; + left: var(--space-3); + top: 50%; + transform: translateY(-50%); + color: var(--color-text-disabled); + width: 20px; + height: 20px; +} + +.habilidades-search .form-input { + padding-left: var(--space-10); + width: 100%; +} + +.habilidades-filters { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.habilidades-filters--demand { + border-left: 1px solid var(--color-border); + padding-left: var(--space-3); + margin-left: var(--space-1); +} + +@media (max-width: 1023px) { + .habilidades-filters--demand { + border-left: none; + padding-left: 0; + margin-left: 0; + width: 100%; + border-top: 1px solid var(--color-border-subtle); + padding-top: var(--space-3); + margin-top: var(--space-1); + } +} + +.habilidades-state { + text-align: center; + padding-block: var(--space-12); +} + +.habilidades-list { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); +} + +@media (min-width: 768px) { + .habilidades-list { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 1024px) { + .habilidades-list { + grid-template-columns: repeat(3, 1fr); + } +} + +.skill-item { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-6); + border-radius: var(--radius-lg); +} + +.skill-item__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-2); +} + +.skill-item__name { + font-size: var(--text-lg); + margin: 0; +} + +.skill-item__category { + background-color: var(--color-primary-subtle); + color: var(--color-primary); + font-size: var(--text-xs); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-full); + display: inline-block; + font-weight: var(--font-weight-medium); +} + +.skill-item__metrics { + display: flex; + align-items: center; + gap: var(--space-3); + margin-top: auto; +} + +.skill-item__bar-track { + flex: 1; + height: 8px; + background-color: var(--color-bg-base); + border-radius: var(--radius-full); + overflow: hidden; +} + +.skill-item__bar-fill { + height: 100%; + background-color: var(--color-primary); + border-radius: var(--radius-full); + transition: width var(--transition-base); +} + +.skill-item__count { + font-size: var(--text-sm); + font-weight: var(--font-weight-bold); + min-width: 2.5rem; + text-align: right; +} + +.skill-item__actions { + margin-top: var(--space-2); + display: flex; + justify-content: flex-start; +} + +.skill-item__actions .btn { + width: 100%; +} + +@media (min-width: 768px) { + .skill-item__actions .btn { + width: auto; + } +} diff --git a/frontend/assets/css/pages/_index.css b/frontend/assets/css/pages/_index.css new file mode 100644 index 0000000..39728f9 --- /dev/null +++ b/frontend/assets/css/pages/_index.css @@ -0,0 +1,144 @@ +/* Espaciado y composicion especifica de la landing page. Los componentes (cards, botones, carousel) ya tienen su propio padding interno; este archivo controla el ritmo vertical entre secciones y el ancho de lectura del contenido textual */ + +.hero { + padding-block: var(--space-12) var(--space-8); + text-align: center; +} + +.hero__eyebrow { + display: inline-flex; + align-items: center; + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-md); + background-color: var(--color-bg-surface); + border: 1px solid var(--color-border-subtle); + margin-bottom: var(--space-6); +} + +.hero__heading { + max-width: 720px; + margin-inline: auto; + margin-bottom: var(--space-6); +} + +.hero__subheading { + max-width: 560px; + margin-inline: auto; +} + +.carousel-section, +.cta-section { + padding-block: var(--space-8); +} + +.carousel-section .carousel { + max-width: 480px; + margin-inline: auto; +} + +.cta-section .container { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--space-3); + max-width: 480px; + margin-inline: auto; +} + +.cta-section .btn { + width: 100%; +} + +@media (min-width: 768px) { + .cta-section .container { + flex-direction: row; + justify-content: center; + max-width: none; + } + .cta-section .btn { + width: auto; + } + .carousel-section .carousel { + max-width: 600px; + } +} + +@media (min-width: 1024px) { + .carousel-section .carousel { + max-width: 100%; + } + .panorama-preview .browser-chrome { + max-width: 860px; + } + .hero__heading { + max-width: 900px; + } + .hero__subheading { + max-width: 720px; + } +} + +@media (min-width: 1440px) { + .panorama-preview .browser-chrome { + max-width: 1000px; + } +} + +.value-props { + padding-block: var(--space-12); +} + +.panorama-preview { + padding-block: var(--space-8) var(--space-16); + text-align: center; +} + +.panorama-preview__eyebrow { + margin-bottom: var(--space-6); +} + +.panorama-preview .browser-chrome { + max-width: 640px; + margin-inline: auto; + text-align: left; +} + +/* Top skills landing section */ +.top-skills-section { + padding-block: var(--space-12); + text-align: center; +} + +.top-skills-section__eyebrow { + display: inline-flex; + align-items: center; + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-md); + background-color: var(--color-bg-surface); + border: 1px solid var(--color-border-subtle); + margin-bottom: var(--space-6); +} + +.top-skills-section h2 { + margin-bottom: var(--space-8); +} + +.top-skills-section__chips { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--space-3); + margin-bottom: var(--space-8); +} + +.top-skills-section__link { + display: inline-block; + color: var(--color-primary); + text-decoration: none; + font-weight: 500; +} + +.top-skills-section__link:hover { + text-decoration: underline; +} + diff --git a/frontend/assets/css/pages/_panorama.css b/frontend/assets/css/pages/_panorama.css new file mode 100644 index 0000000..9d02875 --- /dev/null +++ b/frontend/assets/css/pages/_panorama.css @@ -0,0 +1,108 @@ +.panorama-header { + padding-block: var(--space-8) var(--space-6); +} + +.panorama-header__subtitle { + color: var(--color-text-secondary); + margin-top: var(--space-2); +} + +.panorama-header__live { + color: var(--color-accent-green); + font-weight: var(--font-weight-medium); +} + +.panorama-metrics { + padding-block: var(--space-6) var(--space-8); +} + +.panorama-metrics__track { + gap: var(--space-4); + padding-bottom: var(--space-2); +} + +.panorama-metrics__card { + flex: 0 0 260px; + scroll-snap-align: start; +} + +@media (min-width: 768px) { + .panorama-metrics__track { + overflow-x: visible; + } + + .panorama-metrics__card { + flex: 1; + } +} + +.panorama-skills { + padding-block: var(--space-8); +} + +.panorama-skills__header { + margin-bottom: var(--space-4); +} + +.panorama-skills__subtitle { + color: var(--color-text-secondary); +} + +.panorama-skills__filters { + display: flex; + gap: var(--space-2); + margin-bottom: var(--space-6); + flex-wrap: wrap; +} + +.skills-chart { + display: flex; + align-items: flex-end; + gap: var(--space-3); + height: 280px; + padding: var(--space-4); +} + +.skills-chart__column { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + height: 100%; + flex: 0 0 56px; +} + +.skills-chart__value { + margin-bottom: var(--space-2); + color: var(--color-text-secondary); +} + +.skills-chart__bar { + width: 100%; + max-width: 40px; + background-color: var(--color-primary-light); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + transition: height var(--transition-base); +} + +.skills-chart__bar--top { + background-color: var(--color-accent-orange); +} + +.skills-chart__label { + margin-top: var(--space-2); + color: var(--color-text-secondary); + text-align: center; + word-break: break-word; +} + +@media (min-width: 768px) { + .skills-chart { + height: 360px; + overflow-x: visible; + } + + .skills-chart__column { + flex: 1; + } +} diff --git a/frontend/assets/css/pages/_perfil.css b/frontend/assets/css/pages/_perfil.css new file mode 100644 index 0000000..b8b144a --- /dev/null +++ b/frontend/assets/css/pages/_perfil.css @@ -0,0 +1,115 @@ +/* Estilos especificos para la pagina de perfil. Mantenemos este archivo separado para no polucionar el CSS global con reglas que solo se usan aqui, y evitamos absolutamente el CSS inline para mantener la especificidad y el mantenimiento bajo control. */ + +.profile-loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 50vh; +} + +.profile-nav { + margin-bottom: var(--space-8); +} + +.profile-section { + margin-top: var(--space-6); +} + +.profile-section > h2 { + margin-bottom: var(--space-4); +} + +.profile-section > .text-body { + margin-bottom: var(--space-6); +} + +.profile-form { + width: 100%; +} + +.profile-card-context { + padding: var(--space-6); + border-radius: var(--radius-md); + height: fit-content; + position: sticky; + top: var(--space-8); +} + +.profile-card-context ul { + list-style: disc; + margin-left: var(--space-4); +} + +.profile-skills-list { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.profile-skills-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-8); +} + +@media (min-width: 1024px) { + .profile-skills-grid { + grid-template-columns: 2fr 1fr; + } + + .profile-skills-grid #brechas-container { + max-height: 320px; + overflow-y: auto; + } + + .profile-form .password-checklist { + flex-direction: row; + flex-wrap: wrap; + gap: var(--space-4); + } +} + +.profile-skills-sidebar { + padding: var(--space-4); + border-radius: var(--radius-md); + height: fit-content; +} + +.chip { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); + background-color: var(--color-bg-surface); + border: 1px solid var(--color-border-subtle); + font-size: var(--text-sm); +} + +.chip button { + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + padding: 0; + color: var(--color-text-disabled); +} + +.chip button:hover { + color: var(--color-text-primary); +} + +.chip__badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 var(--space-2); + border-radius: var(--radius-full); + background-color: var(--color-primary); + color: #fff; + font-size: var(--text-xs); + font-weight: 600; + line-height: 1.4; + min-width: 1.5rem; +} diff --git a/frontend/assets/css/pages/_reportes.css b/frontend/assets/css/pages/_reportes.css new file mode 100644 index 0000000..e1362da --- /dev/null +++ b/frontend/assets/css/pages/_reportes.css @@ -0,0 +1,197 @@ +.reporte-header { + padding-block: var(--space-8) var(--space-6); + border-bottom: 1px solid var(--color-border); +} + +.reporte-header__meta { + margin-top: var(--space-2); + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.reporte-header__subtitle { + color: var(--color-text-secondary); +} + +.reporte-header__date { + color: var(--color-text-secondary); +} + +.reporte-header__actions { + margin-top: var(--space-4); +} + +.reporte-metrics { + padding-block: var(--space-6) var(--space-8); +} + +.reporte-metrics__grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); +} + +.reporte-metrics__card { + /* hereda .surface y .metric-card del sistema global */ +} + +.reporte-skills { + padding-block: var(--space-8); +} + +.reporte-skills__header { + margin-bottom: var(--space-4); +} + +.reporte-skills__subtitle { + color: var(--color-text-secondary); + margin-top: var(--space-1); +} + +.reporte-table-wrapper { + overflow-x: auto; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +.reporte-table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); +} + +.reporte-table thead { + background-color: var(--color-surface-raised); +} + +.reporte-table th { + padding: var(--space-3) var(--space-4); + text-align: left; + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + border-bottom: 1px solid var(--color-border); + white-space: nowrap; +} + +.reporte-table td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); + color: var(--color-text-primary); +} + +.reporte-table tbody tr:last-child td { + border-bottom: none; +} + +.reporte-table tbody tr:hover { + background-color: var(--color-surface-raised); +} + +.reporte-table__rank { + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +.reporte-table__skill { + font-weight: var(--font-weight-medium); +} + +.reporte-table__skill--top { + color: var(--color-accent-orange); +} + +.reporte-table__mentions { + font-variant-numeric: tabular-nums; +} + +.reporte-table__pct { + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +@media (min-width: 768px) { + .reporte-header__meta { + flex-direction: row; + gap: var(--space-4); + align-items: center; + } + + .reporte-metrics__grid { + grid-template-columns: repeat(3, 1fr); + } + + .reporte-table { + font-size: var(--font-size-base); + } +} + +@media (min-width: 1024px) { + .reporte-header__actions { + margin-top: var(--space-6); + } +} + +@media print { + .site-header, + .nav-drawer, + [data-no-print] { + display: none !important; + } + + /* Forzamos blanco y negro porque la impresión no debe heredar el tema oscuro del usuario. */ + body, + .reporte-header, + .reporte-metrics, + .reporte-skills, + .surface, + .metric-card { + background-color: #ffffff !important; + color: #111111 !important; + box-shadow: none !important; + border-color: #cccccc !important; + } + + .reporte-metrics__card, + .metric-card { + page-break-inside: avoid; + break-inside: avoid; + } + + .reporte-table tr { + page-break-inside: avoid; + break-inside: avoid; + } + + .reporte-table thead { + display: table-header-group; + } + + .reporte-skills__header, + .reporte-metrics h2 { + page-break-after: avoid; + break-after: avoid; + } + + .reporte-metrics__grid { + grid-template-columns: repeat(3, 1fr); + gap: 12pt; + } + + .reporte-table th, + .reporte-table td, + .reporte-table__skill--top, + .reporte-table__pct, + .reporte-table__rank { + color: #111111 !important; + } + + .reporte-table thead { + background-color: #f0f0f0 !important; + } + + .reporte-table-wrapper { + overflow-x: visible; + border: 1px solid #cccccc; + } +} diff --git a/frontend/assets/css/pages/_salarios.css b/frontend/assets/css/pages/_salarios.css new file mode 100644 index 0000000..2c7751d --- /dev/null +++ b/frontend/assets/css/pages/_salarios.css @@ -0,0 +1,29 @@ +.salarios-selector-container { + margin-bottom: var(--space-8); +} + +.salary-card { + padding: var(--space-8) var(--space-6); + border-radius: var(--radius-lg); + text-align: center; + max-width: 800px; + margin: 0 auto; +} + +.salary-card__title { + margin-bottom: var(--space-4); +} + +.salary-card__range { + color: var(--color-accent-green); + margin-bottom: var(--space-4); + display: flex; + align-items: baseline; + justify-content: center; + gap: var(--space-2); +} + +.salary-card__currency { + font-weight: var(--font-weight-regular); + color: var(--color-text-secondary); +} diff --git a/frontend/assets/css/pages/_verificar-correo.css b/frontend/assets/css/pages/_verificar-correo.css new file mode 100644 index 0000000..131a551 --- /dev/null +++ b/frontend/assets/css/pages/_verificar-correo.css @@ -0,0 +1,125 @@ +/* Estilos para verificar-correo.html, son cuatro estados de verificación. Extiende _auth.css sin duplicar sus variables ni su layout base */ + +/* Estado contenedor */ +.verify-state { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: var(--space-3); +} + +/* Ícono de estado */ +.verify-state__icon { + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border-radius: var(--radius-full); + margin-bottom: var(--space-2); +} + +.verify-state__icon svg { + width: 32px; + height: 32px; +} + +.verify-state__icon--loading { + background-color: var(--color-bg-subtle); + color: var(--color-text-secondary); +} + +.verify-state__icon--success { + background-color: color-mix(in srgb, #21a675 15%, transparent); + color: #21a675; +} + +.verify-state__icon--warning { + background-color: color-mix( + in srgb, + var(--color-accent-orange) 12%, + transparent + ); + color: var(--color-accent-orange); +} + +.verify-state__icon--info { + background-color: color-mix(in srgb, #4b607f 12%, transparent); + color: #4b607f; +} + +/* Textos */ +.verify-state__title { + font-size: var(--text-xl); + margin: 0; +} + +.verify-state__subtitle { + color: var(--color-text-secondary); + margin: 0; +} + +/* Botón de acción principal */ +.verify-state__action { + width: 100%; + text-align: center; + margin-top: var(--space-2); +} + +/* Formulario de reenvío (alineado a la izquierda) */ +.verify-resend-form { + width: 100%; + text-align: left; + margin-top: var(--space-4); + margin-bottom: 0; +} + +/* Mensaje de éxito inline dentro del formulario */ +.verify-state__success-msg { + font-size: var(--text-sm); + color: #21a675; + padding: var(--space-3) var(--space-4); + border: 1px solid color-mix(in srgb, #21a675 30%, transparent); + border-radius: var(--radius-md); + background-color: color-mix(in srgb, #21a675 8%, transparent); + margin-bottom: var(--space-3); +} + +/* Animación de carga */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.spin-icon { + animation: spin 1s linear infinite; +} + +/* Banner inline para login con correo no verificado */ +.login-unverified-banner { + margin-top: var(--space-4); + padding: var(--space-4); + border: 1px solid + color-mix(in srgb, var(--color-accent-orange) 35%, transparent); + border-radius: var(--radius-md); + background-color: color-mix( + in srgb, + var(--color-accent-orange) 8%, + transparent + ); +} + +.login-unverified-banner__msg { + font-size: var(--text-sm); + color: var(--color-text-primary); + margin-bottom: var(--space-3); +} + +.login-unverified-banner__btn { + width: 100%; +} diff --git a/frontend/assets/css/pages/regiones.css b/frontend/assets/css/pages/regiones.css new file mode 100644 index 0000000..c576427 --- /dev/null +++ b/frontend/assets/css/pages/regiones.css @@ -0,0 +1,51 @@ +.regions-chart { + display: flex; + align-items: flex-end; + gap: var(--space-3); + height: 280px; + padding: var(--space-4); +} + +.regions-chart__column { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + height: 100%; + flex: 0 0 72px; +} + +.regions-chart__value { + margin-bottom: var(--space-2); + color: var(--color-text-secondary); +} + +.regions-chart__bar { + width: 100%; + max-width: 48px; + background-color: var(--color-primary-light); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + transition: height var(--transition-base); +} + +.regions-chart__bar--top { + background-color: var(--color-accent-orange); +} + +.regions-chart__label { + margin-top: var(--space-2); + color: var(--color-text-secondary); + text-align: center; + word-break: break-word; +} + +@media (min-width: 768px) { + .regions-chart { + height: 360px; + overflow-x: visible; + } + + .regions-chart__column { + flex: 1; + } +} diff --git a/frontend/assets/images/brand/.gitkeep b/frontend/assets/images/brand/.gitkeep new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/frontend/assets/images/brand/.gitkeep @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/assets/images/brand/favicon.ico b/frontend/assets/images/brand/favicon.ico new file mode 100644 index 0000000..9a6598e Binary files /dev/null and b/frontend/assets/images/brand/favicon.ico differ diff --git a/frontend/assets/images/brand/favicon.svg b/frontend/assets/images/brand/favicon.svg new file mode 100644 index 0000000..9bcdf16 --- /dev/null +++ b/frontend/assets/images/brand/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/assets/images/brand/skillstat-logo.svg b/frontend/assets/images/brand/skillstat-logo.svg new file mode 100644 index 0000000..6c5cd2b --- /dev/null +++ b/frontend/assets/images/brand/skillstat-logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/assets/images/icons/.gitkeep b/frontend/assets/images/icons/.gitkeep new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/frontend/assets/images/icons/.gitkeep @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/assets/js/api/admin.api.js b/frontend/assets/js/api/admin.api.js new file mode 100644 index 0000000..8857b05 --- /dev/null +++ b/frontend/assets/js/api/admin.api.js @@ -0,0 +1,25 @@ +async function listBackups(page = 1, perPage = 10) { + return apiGet(`/admin/backups?page=${page}&per_page=${perPage}`); +} + +async function createBackup() { + return apiPost("/admin/backup", {}); +} + +async function restoreBackup(backupId, confirmFilename) { + return apiPost(`/admin/backups/${backupId}/restore`, { + confirm_filename: confirmFilename, + }); +} + +async function listUsers(page = 1, perPage = 10) { + return apiGet(`/admin/users?page=${page}&per_page=${perPage}`); +} + +async function updateUserRole(userId, role) { + return apiPatch(`/admin/users/${userId}/role`, { role }); +} + +async function updateUserStatus(userId, isActive) { + return apiPatch(`/admin/users/${userId}/status`, { is_active: isActive }); +} diff --git a/frontend/assets/js/api/alertas.api.js b/frontend/assets/js/api/alertas.api.js new file mode 100644 index 0000000..f9ed9f4 --- /dev/null +++ b/frontend/assets/js/api/alertas.api.js @@ -0,0 +1,22 @@ +async function createAlert( + skillId, + alertType, + thresholdValue, + thresholdPercentage, +) { + const payload = { skill_id: skillId, alert_type: alertType }; + if (alertType === "ABSOLUTE") { + payload.threshold_value = thresholdValue; + } else { + payload.threshold_percentage = thresholdPercentage; + } + return apiPost("/alerts/", payload); +} + +async function listAlerts() { + return apiGet("/alerts/"); +} + +async function deleteAlert(alertId) { + return apiDelete(`/alerts/${alertId}`); +} diff --git a/frontend/assets/js/api/auth.api.js b/frontend/assets/js/api/auth.api.js new file mode 100644 index 0000000..83d679f --- /dev/null +++ b/frontend/assets/js/api/auth.api.js @@ -0,0 +1,27 @@ +async function registerUser(payload) { + return apiPost("/auth/register", payload); +} + +async function loginUser(credentials) { + return apiPost("/auth/login", credentials); +} + +async function verifyEmailToken(token) { + return apiGet(`/auth/verify-email?token=${encodeURIComponent(token)}`); +} + +async function confirmEmailVerification(token) { + return apiPost("/auth/verify-email", { token }); +} + +async function resendVerificationEmail(email) { + return apiPost("/auth/resend-verification", { email }); +} + +async function requestPasswordReset(email) { + return apiPost("/auth/forgot-password", { email }); +} + +async function resetPassword(token, newPassword) { + return apiPost("/auth/reset-password", { token, new_password: newPassword }); +} \ No newline at end of file diff --git a/frontend/assets/js/api/client.js b/frontend/assets/js/api/client.js new file mode 100644 index 0000000..b76ba46 --- /dev/null +++ b/frontend/assets/js/api/client.js @@ -0,0 +1,174 @@ +// API_BASE_URL se define en assets/js/config.js, que debe cargarse mediante un + + + + + + + + + + diff --git a/frontend/views/admin/usuarios.html b/frontend/views/admin/usuarios.html new file mode 100644 index 0000000..b528637 --- /dev/null +++ b/frontend/views/admin/usuarios.html @@ -0,0 +1,309 @@ + + + + + + + SkillStat - Usuarios + + + + + + + + + + + + +
+

Cargando usuarios...

+
+ +
+
+
+

Usuarios registrados

+
+
+ +
+
+ +
+
+ +
+
+ + + +
+ + + Página 1 de 1 + + +
+
+
+
+ + + + + + + + + + + + diff --git a/frontend/views/alertas.html b/frontend/views/alertas.html new file mode 100644 index 0000000..a1a969b --- /dev/null +++ b/frontend/views/alertas.html @@ -0,0 +1,344 @@ + + + + + + + SkillStat - Mis alertas + + + + + + + + + + + + +
+

Cargando alertas...

+
+ +
+
+
+

Mis alertas

+
+
+ +
+
+

Nueva alerta

+
+ +
+ + +
+
+ +
+ + +
+
+
+ + +
+ + +
+
+ +
+

Alertas activas

+ + +
+
+
+ + + + + + + + + + + + + diff --git a/frontend/views/comparar.html b/frontend/views/comparar.html new file mode 100644 index 0000000..0d0aa3d --- /dev/null +++ b/frontend/views/comparar.html @@ -0,0 +1,275 @@ + + + + + + + SkillStat - Comparar + + + + + + + + + + + + +
+
+
+

Comparar habilidades

+

+ Selecciona entre 2 y 5 habilidades para comparar su demanda y + salario. +

+
+
+ +
+
+
+ + +
+
+ + +
+ + + + + + + + + + + + diff --git a/frontend/views/cookies.html b/frontend/views/cookies.html new file mode 100644 index 0000000..5440f52 --- /dev/null +++ b/frontend/views/cookies.html @@ -0,0 +1,37 @@ + + + + + + SkillStat - Aviso de Cookies + + + + + + + + +
+
+

Aviso de Cookies

+

+ Este documento está en construcción. El equipo de SkillStat publicará + el contenido completo antes del lanzamiento. +

+ Volver +
+
+ + diff --git a/frontend/views/errors/404.html b/frontend/views/errors/404.html new file mode 100644 index 0000000..636904d --- /dev/null +++ b/frontend/views/errors/404.html @@ -0,0 +1,231 @@ + + + + + + SkillStat - No encontrado (404) + + + + + + + + + + + + +
+
+

404

+

+ Lo sentimos, no pudimos encontrar esta página. +

+ Volver +
+
+ + + + + + + + + + + diff --git a/frontend/views/errors/500.html b/frontend/views/errors/500.html new file mode 100644 index 0000000..ca32e97 --- /dev/null +++ b/frontend/views/errors/500.html @@ -0,0 +1,231 @@ + + + + + + SkillStat - Error Interno (500) + + + + + + + + + + + + +
+
+

500

+

+ Algo salió mal de nuestro lado. Estamos trabajando para solucionarlo. +

+ Volver +
+
+ + + + + + + + + + + diff --git a/frontend/views/habilidades.html b/frontend/views/habilidades.html new file mode 100644 index 0000000..1f27bee --- /dev/null +++ b/frontend/views/habilidades.html @@ -0,0 +1,280 @@ + + + + + + + SkillStat - Habilidades + + + + + + + + + + + + +
+
+
+

Habilidades del mercado tech

+
+
+ +
+
+
+ +
+ +
+
+ +
+

Cargando habilidades...

+
+ + + +
+ +
+
+
+
+ + + + + + + + + + + + diff --git a/frontend/views/index.html b/frontend/views/index.html new file mode 100644 index 0000000..c7f689d --- /dev/null +++ b/frontend/views/index.html @@ -0,0 +1,429 @@ + + + + + + + SkillStat - Inicio + + + + + + + + + + + + + + + + + + + +
+ +
+
+

+ Inteligencia de mercado laboral Mexicano +

+ +

+ El mercado tech mexicano, + medido + en tiempo real. +

+ +

+ Skills más demandados, salarios reales y distribución por ciudad. + Datos, no corazonadas. +

+
+
+ + + + + +
+ +
+ + +
+
+

+ Por qué usar SkillStat +

+ +
+
+ +

+ Demanda de skills en tiempo real +

+

+ Qué tecnologías piden hoy las empresas tech mexicanas. +

+
+ +
+ +

+ Benchmarks de salario por rol +

+

+ Rangos por seniority, stack y ciudad, sin adivinanzas. +

+
+ +
+ +

+ Cobertura nacional + remoto +

+

+ CDMX, Guadalajara, Monterrey, Querétaro e híbrido. +

+
+
+
+
+ + +
+
+

+ Skills más demandados ahora +

+

Lo que el mercado pide hoy

+
+ +
+ Ver el Panorama completo → +
+
+ + +
+
+

+ Así se ve tu Panorama +

+ + +
+
+
+ + + + + + + + + + + + + + + + + diff --git a/frontend/views/olvide-contrasena.html b/frontend/views/olvide-contrasena.html new file mode 100644 index 0000000..2a5ba4e --- /dev/null +++ b/frontend/views/olvide-contrasena.html @@ -0,0 +1,117 @@ + + + + + + + SkillStat - Olvidé mi contraseña + + + + + + + + +
+
+ + +
+ + +
+

Recuperar contraseña

+

+ Ingresa tu correo y te enviaremos un enlace para restablecerla. +

+
+ +
+
+ + +
+ + +
+ + +
+ +

+ + Volver a inicio de sesión + +

+
+
+
+ +
+
+
+

Recupera tu acceso.

+

+ Vuelve a tener el pulso del mercado tech mexicano a tu + disposición. +

+
+
+
+
+ + + + + + + + + + diff --git a/frontend/views/panorama.html b/frontend/views/panorama.html new file mode 100644 index 0000000..549723c --- /dev/null +++ b/frontend/views/panorama.html @@ -0,0 +1,361 @@ + + + + + + + SkillStat - Panorama + + + + + + + + + + + + +
+
+
+

Panorama

+

+ Inteligencia de mercado laboral tecnológico · México · + Actualizado +

+
+
+ +
+
+
+
+
+ Vacantes activas + Actualizado +
+

+ — +

+

+ Cargando datos... +

+
+ +
+
+ Empresas contratando + Actualizado +
+

+ — +

+

+ Cargando datos... +

+
+ +
+
+ Habilidades rastreadas + Actualizado +
+

+ — +

+

+ Cargando datos... +

+
+
+
+
+ +
+
+
+

Top skills demandados

+

+ Menciones en vacantes +

+
+ +
+ + + +
+ +
+
+
+
+ + + + + + + + + + + + + diff --git a/frontend/views/perfil.html b/frontend/views/perfil.html new file mode 100644 index 0000000..126b0be --- /dev/null +++ b/frontend/views/perfil.html @@ -0,0 +1,462 @@ + + + + + + + SkillStat - Mi perfil + + + + + + + + + + + + +
+
+
+

Mi perfil

+
+
+ +
+
+
+ + + +
+
+
+ +
+
+ +
+

Mis datos

+
+
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Información de cuenta

+
+ +

Cargando...

+
+
+ +

Cargando...

+
+
+ +

Cargando...

+
+
+
+
+ + + + + + +
+
+
+ + +
+

Cargando perfil...

+
+ + + + + + + + + + diff --git a/frontend/views/privacidad.html b/frontend/views/privacidad.html new file mode 100644 index 0000000..c4864b7 --- /dev/null +++ b/frontend/views/privacidad.html @@ -0,0 +1,37 @@ + + + + + + SkillStat - Política de Privacidad + + + + + + + + +
+
+

Política de Privacidad

+

+ Este documento está en construcción. El equipo de SkillStat publicará + el contenido completo antes del lanzamiento. +

+ Volver +
+
+ + diff --git a/frontend/views/regiones.html b/frontend/views/regiones.html new file mode 100644 index 0000000..37eaf0f --- /dev/null +++ b/frontend/views/regiones.html @@ -0,0 +1,262 @@ + + + + + + + SkillStat - Regiones + + + + + + + + + + + + + +
+
+
+
+

Regiones

+

+ Demanda agregada por estado +

+
+ +
+ +
+ +
+ +
+
+
+
+ + + + + + + + + + + + diff --git a/frontend/views/register.html b/frontend/views/register.html new file mode 100644 index 0000000..dab1d49 --- /dev/null +++ b/frontend/views/register.html @@ -0,0 +1,302 @@ + + + + + + + SkillStat - Iniciar sesión + + + + + + + + +
+
+ + +
+ + +
+

Iniciar sesión

+

+ Accede a tu tablero de inteligencia laboral. +

+
+ + +
+
+ +
+ +
+ o continúa con tu correo +
+ +
+
+ + +
+ + +
+ +
+ + + +
+ + +
+ +

+ ¿Sin cuenta? + +

+
+ + +
+
+ +
+
+
+

+ El pulso del mercado tech mexicano. +

+

+ Skills, salarios y regiones, actualizados a diario. +

+
+ + +
+
+
+ + + + + + + + + + + + + + + + diff --git a/frontend/views/reportes.html b/frontend/views/reportes.html new file mode 100644 index 0000000..c1d1cf6 --- /dev/null +++ b/frontend/views/reportes.html @@ -0,0 +1,383 @@ + + + + + + + SkillStat - Reporte de Mercado + + + + + + + + + + + + +
+
+
+

Reporte de Mercado Laboral Tech

+
+

+ Inteligencia de mercado · México · Tecnología +

+

+ Generado el +

+
+
+ +
+
+
+ +
+
+
+
+
+ Vacantes activas + Actualizado +
+

+ — +

+

+ Cargando datos... +

+
+ +
+
+ Empresas contratando + Actualizado +
+

+ — +

+

+ Cargando datos... +

+
+ +
+
+ Habilidades rastreadas + Actualizado +
+

+ — +

+

+ Cargando datos... +

+
+
+
+
+ +
+
+
+

Top 15 habilidades más demandadas

+

+ Menciones en vacantes activas · México · Tecnología +

+
+ +
+ + + + + + + + + + + + + + +
#HabilidadMenciones% relativo
+ Cargando datos... +
+
+
+
+
+ + + + + + + + + + + + diff --git a/frontend/views/restablecer-contrasena.html b/frontend/views/restablecer-contrasena.html new file mode 100644 index 0000000..ef2a016 --- /dev/null +++ b/frontend/views/restablecer-contrasena.html @@ -0,0 +1,154 @@ + + + + + + + SkillStat - Restablecer contraseña + + + + + + + + +
+
+ + +
+ + +
+

Restablecer contraseña

+

+ Crea una nueva contraseña segura para tu cuenta. +

+
+ +
+ +
+
+ + +
+ + +
    +
  • Mínimo 8 caracteres
  • +
  • Una letra mayúscula
  • +
  • Un número
  • +
  • Un carácter especial
  • +
+
+ +
+ + +
+ + +
+
+ + + +
+
+
+ +
+
+
+

+ Seguridad para tus datos. +

+

+ Mantén el acceso a tu cuenta siempre protegido. +

+
+
+
+
+ + + + + + + + + + diff --git a/frontend/views/salarios.html b/frontend/views/salarios.html new file mode 100644 index 0000000..0644500 --- /dev/null +++ b/frontend/views/salarios.html @@ -0,0 +1,290 @@ + + + + + + + SkillStat - Salarios + + + + + + + + + + + + +
+
+
+

Salarios del mercado tech

+

+ Los salarios mostrados provienen de vacantes que declaran rango + salarial explícitamente. Actualmente representan una muestra parcial + del mercado; se amplían conforme se incorporan más vacantes con dato + disponible. +

+
+
+ +
+
+
+
+ +
+
+
+ + + + + + +
+
+
+ + + + + + + + + + + + diff --git a/frontend/views/terminos.html b/frontend/views/terminos.html new file mode 100644 index 0000000..3dacdc2 --- /dev/null +++ b/frontend/views/terminos.html @@ -0,0 +1,37 @@ + + + + + + SkillStat - Términos y Condiciones + + + + + + + + +
+
+

Términos y Condiciones

+

+ Este documento está en construcción. El equipo de SkillStat publicará + el contenido completo antes del lanzamiento. +

+ Volver +
+
+ + diff --git a/frontend/views/verificar-correo.html b/frontend/views/verificar-correo.html new file mode 100644 index 0000000..2e62e44 --- /dev/null +++ b/frontend/views/verificar-correo.html @@ -0,0 +1,208 @@ + + + + + + + SkillStat - Verificar correo + + + + + + + + +
+
+ + +
+ + +
+
+ +
+

Verificando tu correo...

+

+ Espera un momento mientras procesamos tu enlace. +

+
+ + + + + + + + +
+
+ +
+
+
+

+ Un paso más para explorar el mercado. +

+

+ Verificamos tu correo para mantener tu cuenta segura. +

+
+
+
+
+ + + + + + + + + + diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..f677df6 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,32 @@ +# scripts/ + +Aqui viven herramientas de uso manual para tareas de mantenimiento +y configuracion del proyecto. Estos scripts no forman parte de la +aplicacion y no se ejecutan automaticamente. + +## Los scripts disponibles + +**`seed_db.py`** - Carga los datos iniciales en la base de datos: +categorias de habilidades, ciudades base y el catalogo de habilidades +del diccionario ESCO. Se corre una sola vez al configurar un ambiente +nuevo. + +**`build_dictionary.py`** - Descarga y procesa la taxonomia ESCO para +generar el archivo `data/dictionaries/skills_esco.jsonl`. Se corre +cuando actualizamos la version del diccionario. + +**`backup_manual.py`** - Genera un respaldo de la base de datos de +forma manual sin pasar por la interfaz de administracion. Util para +respaldos puntuales antes de cambios importantes. + +## Como correr un script + +```bash +# Nos aseguramos de estar en la carpeta backend con el entorno activo +cd backend +source .venv/bin/activate # Mac / Linux +.venv\Scripts\activate # Windows + +# Corremos el script desde la raiz del repositorio +python scripts/nombre_del_script.py +``` diff --git a/scripts/backup_manual.py b/scripts/backup_manual.py new file mode 100644 index 0000000..a090661 --- /dev/null +++ b/scripts/backup_manual.py @@ -0,0 +1 @@ +# backup_manual — SkillStat \ No newline at end of file diff --git a/scripts/build_dictionary.py b/scripts/build_dictionary.py new file mode 100644 index 0000000..0f00db9 --- /dev/null +++ b/scripts/build_dictionary.py @@ -0,0 +1,54 @@ +import json +import os + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DICT_DIR = os.path.join(BASE_DIR, "data", "dictionaries") +OUTPUT_FILE = os.path.join(DICT_DIR, "skills_esco.jsonl") + +CORE_SKILLS = [ + "Python", "JavaScript", "TypeScript", "Java", "C#", "C++", "Ruby", + "PHP", "Go", "Rust", "Swift", "Kotlin", + "React", "Angular", "Vue.js", "Node.js", "Express", "Django", "Flask", + "FastAPI", "Spring Boot", ".NET", + "SQL", "MySQL", "PostgreSQL", "MongoDB", "SQLite", "NoSQL", "Redis", + "Cassandra", "Elasticsearch", + "AWS", "Azure", "Google Cloud", "GCP", "Docker", "Kubernetes", + "Terraform", "Jenkins", "CI/CD", "Linux", + "Machine Learning", "Data Science", "Artificial Intelligence", "NLP", + "Deep Learning", "TensorFlow", "PyTorch", "Pandas", "NumPy", + "Scikit-learn", + "Git", "GitHub", "GitLab", "Bitbucket", + "Agile", "Scrum", "Jira", "Figma", + "HTML", "CSS", "Sass", "Tailwind", "Bootstrap", + "GraphQL", "REST API", "Microservices", +] + + +def build_dictionary(): + os.makedirs(DICT_DIR, exist_ok=True) + print(f"Construyendo diccionario NLP en: {OUTPUT_FILE}") + + patterns = [] + for skill in CORE_SKILLS: + skill_lower = skill.lower() + + if " " in skill: + # Para skills multipalabra usamos una lista de tokens con LOWER porque el EntityRuler necesita matchear cada token por separado. + token_pattern = [{"LOWER": token.lower()} for token in skill.split()] + patterns.append({"label": "SKILL", "pattern": token_pattern}) + else: + # Para skills de una sola palabra usamos LOWER directamente para que el matching sea insensible a mayusculas en el texto. + patterns.append({ + "label": "SKILL", + "pattern": [{"LOWER": skill_lower}] + }) + + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: + for entry in patterns: + f.write(json.dumps(entry) + "\n") + + print(f"Exito: {len(CORE_SKILLS)} habilidades exportadas como {len(patterns)} patrones LOWER.") + + +if __name__ == "__main__": + build_dictionary() \ No newline at end of file diff --git a/scripts/seed_db.py b/scripts/seed_db.py new file mode 100644 index 0000000..086af74 --- /dev/null +++ b/scripts/seed_db.py @@ -0,0 +1,34 @@ +from app import create_app +from app.extensions import db +from app.models.skill import Skill + +SEED_SKILLS = [ + {"name": "Python", "canonical_name": "PYTHON", "category_id": 1}, + {"name": "React", "canonical_name": "REACT", "category_id": 2}, + {"name": "Vue", "canonical_name": "VUE", "category_id": 2}, + {"name": "Flask", "canonical_name": "FLASK", "category_id": 3}, + {"name": "Django", "canonical_name": "DJANGO", "category_id": 3}, + {"name": "Docker", "canonical_name": "DOCKER", "category_id": 4}, + {"name": "AWS", "canonical_name": "AWS", "category_id": 4}, + {"name": "PostgreSQL", "canonical_name": "POSTGRESQL", "category_id": 5}, + {"name": "Pandas", "canonical_name": "PANDAS", "category_id": 5}, + {"name": "Microservicios", "canonical_name": "MICROSERVICIOS", "category_id": 6}, +] + +def seed_skills(): + app = create_app() + with app.app_context(): + for skill_data in SEED_SKILLS: + existing = db.session.execute( + db.select(Skill).filter_by(name=skill_data["name"]) + ).scalar_one_or_none() + if existing: + print(f"Skill ya existe, se omite: {skill_data['name']}") + continue + skill = Skill(**skill_data) + db.session.add(skill) + db.session.commit() + print(f"Seed completado: {len(SEED_SKILLS)} skills verificados/creados.") + +if __name__ == "__main__": + seed_skills() \ No newline at end of file