From 2bb3d9ddf1220ac1d2972c2006f1aeed5fe7e751 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Wed, 3 Jun 2026 15:20:10 -0600 Subject: [PATCH 001/277] chore: scaffold complete project structure --- .editorconfig | 1 + .github/PULL_REQUEST_TEMPLATE.md | 19 +++++++++ .github/workflows/.gitkeep | 1 + .gitignore | 41 +++++++++++++++++++ backend/.env.example | 1 + backend/app/__init__.py | 1 + backend/app/config.py | 1 + backend/app/controllers/__init__.py | 1 + backend/app/controllers/admin_bp.py | 1 + backend/app/controllers/alerts_bp.py | 1 + backend/app/controllers/auth_bp.py | 1 + backend/app/controllers/panorama_bp.py | 1 + backend/app/extensions.py | 1 + backend/app/models/__init__.py | 1 + backend/app/models/alert.py | 1 + backend/app/models/backup.py | 1 + backend/app/models/category.py | 1 + backend/app/models/city.py | 1 + backend/app/models/job.py | 1 + backend/app/models/job_skill.py | 1 + backend/app/models/skill.py | 1 + backend/app/models/trend_snapshot.py | 1 + backend/app/models/user.py | 1 + backend/app/repositories/__init__.py | 1 + backend/app/repositories/alert_repository.py | 1 + backend/app/repositories/backup_repository.py | 1 + backend/app/repositories/base_repository.py | 1 + .../app/repositories/category_repository.py | 1 + backend/app/repositories/city_repository.py | 1 + backend/app/repositories/job_repository.py | 1 + backend/app/repositories/skill_repository.py | 1 + backend/app/repositories/trend_repository.py | 1 + backend/app/repositories/user_repository.py | 1 + backend/app/schemas/__init__.py | 1 + backend/app/schemas/alert_schema.py | 1 + backend/app/schemas/auth_schema.py | 1 + backend/app/schemas/skill_schema.py | 1 + backend/app/services/__init__.py | 1 + backend/app/services/alerts_service.py | 1 + backend/app/services/backup_service.py | 1 + backend/app/services/ingestion_service.py | 1 + backend/app/services/market_trends_service.py | 1 + .../app/services/skills_extraction_service.py | 1 + backend/app/utils/__init__.py | 1 + backend/app/utils/errors.py | 1 + backend/app/utils/hash.py | 1 + backend/app/utils/response.py | 1 + backend/app/utils/security.py | 1 + backend/clients/__init__.py | 1 + backend/clients/adzuna_client.py | 1 + backend/clients/base_client.py | 1 + backend/clients/nominatim_client.py | 1 + backend/clients/sendgrid_client.py | 1 + backend/migrations/.gitkeep | 1 + backend/requirements-dev.txt | 1 + backend/requirements.txt | 1 + backend/run.py | 1 + backend/scheduler/__init__.py | 1 + backend/scheduler/jobs.py | 1 + backend/tests/__init__.py | 1 + backend/tests/conftest.py | 1 + backend/tests/fixtures/sample_jobs.json | 1 + backend/tests/fixtures/sample_skills.json | 1 + backend/tests/integration/__init__.py | 1 + .../integration/test_alerts_endpoints.py | 1 + .../tests/integration/test_auth_endpoints.py | 1 + .../integration/test_panorama_endpoints.py | 1 + backend/tests/unit/__init__.py | 1 + backend/tests/unit/test_alerts_service.py | 1 + backend/tests/unit/test_market_trends.py | 1 + backend/tests/unit/test_skills_extraction.py | 1 + data/dictionaries/skill_aliases.json | 1 + data/dictionaries/skills_custom.jsonl | 1 + data/dictionaries/skills_esco.jsonl | 1 + data/samples/vacantes_sample.json | 1 + docs/diagramas/.gitkeep | 1 + docs/diagramas/casos-uso.png.gitkeep | 1 + docs/propuesta/.gitkeep | 1 + frontend/assets/css/base/_reset.css | 1 + frontend/assets/css/base/_typography.css | 1 + frontend/assets/css/base/_variables.css | 1 + frontend/assets/css/components/_alerts.css | 1 + frontend/assets/css/components/_buttons.css | 1 + frontend/assets/css/components/_cards.css | 1 + frontend/assets/css/components/_charts.css | 1 + frontend/assets/css/components/_forms.css | 1 + frontend/assets/css/components/_navbar.css | 1 + frontend/assets/css/components/_tables.css | 1 + frontend/assets/css/layouts/_containers.css | 1 + frontend/assets/css/layouts/_grid.css | 1 + frontend/assets/css/main.css | 1 + frontend/assets/css/pages/_admin.css | 1 + frontend/assets/css/pages/_alertas.css | 1 + frontend/assets/css/pages/_auth.css | 1 + frontend/assets/css/pages/_panorama.css | 1 + frontend/assets/images/brand/.gitkeep | 1 + frontend/assets/images/icons/.gitkeep | 1 + frontend/assets/js/api/admin.api.js | 1 + frontend/assets/js/api/alertas.api.js | 1 + frontend/assets/js/api/auth.api.js | 1 + frontend/assets/js/api/client.js | 1 + frontend/assets/js/api/panorama.api.js | 1 + frontend/assets/js/components/chart.init.js | 1 + frontend/assets/js/components/map.init.js | 1 + frontend/assets/js/components/navbar.js | 1 + frontend/assets/js/pages/admin.js | 1 + frontend/assets/js/pages/alertas.js | 1 + frontend/assets/js/pages/comparar.js | 1 + frontend/assets/js/pages/panorama.js | 1 + frontend/assets/js/utils/auth.utils.js | 1 + frontend/assets/js/utils/formatters.js | 1 + frontend/assets/js/utils/validators.js | 1 + frontend/views/admin/respaldos.html | 1 + frontend/views/admin/usuarios.html | 1 + frontend/views/alertas.html | 1 + frontend/views/comparar.html | 1 + frontend/views/errors/404.html | 1 + frontend/views/errors/500.html | 1 + frontend/views/index.html | 1 + frontend/views/panorama.html | 1 + frontend/views/register.html | 1 + scripts/backup_manual.py | 1 + scripts/build_dictionary.py | 1 + scripts/seed_db.py | 1 + 124 files changed, 182 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/.gitkeep create mode 100644 .gitignore create mode 100644 backend/.env.example create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/controllers/__init__.py create mode 100644 backend/app/controllers/admin_bp.py create mode 100644 backend/app/controllers/alerts_bp.py create mode 100644 backend/app/controllers/auth_bp.py create mode 100644 backend/app/controllers/panorama_bp.py create mode 100644 backend/app/extensions.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/alert.py create mode 100644 backend/app/models/backup.py create mode 100644 backend/app/models/category.py create mode 100644 backend/app/models/city.py create mode 100644 backend/app/models/job.py create mode 100644 backend/app/models/job_skill.py create mode 100644 backend/app/models/skill.py create mode 100644 backend/app/models/trend_snapshot.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/repositories/__init__.py create mode 100644 backend/app/repositories/alert_repository.py create mode 100644 backend/app/repositories/backup_repository.py create mode 100644 backend/app/repositories/base_repository.py create mode 100644 backend/app/repositories/category_repository.py create mode 100644 backend/app/repositories/city_repository.py create mode 100644 backend/app/repositories/job_repository.py create mode 100644 backend/app/repositories/skill_repository.py create mode 100644 backend/app/repositories/trend_repository.py create mode 100644 backend/app/repositories/user_repository.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/alert_schema.py create mode 100644 backend/app/schemas/auth_schema.py create mode 100644 backend/app/schemas/skill_schema.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/alerts_service.py create mode 100644 backend/app/services/backup_service.py create mode 100644 backend/app/services/ingestion_service.py create mode 100644 backend/app/services/market_trends_service.py create mode 100644 backend/app/services/skills_extraction_service.py create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/app/utils/errors.py create mode 100644 backend/app/utils/hash.py create mode 100644 backend/app/utils/response.py create mode 100644 backend/app/utils/security.py create mode 100644 backend/clients/__init__.py create mode 100644 backend/clients/adzuna_client.py create mode 100644 backend/clients/base_client.py create mode 100644 backend/clients/nominatim_client.py create mode 100644 backend/clients/sendgrid_client.py create mode 100644 backend/migrations/.gitkeep create mode 100644 backend/requirements-dev.txt create mode 100644 backend/requirements.txt create mode 100644 backend/run.py create mode 100644 backend/scheduler/__init__.py create mode 100644 backend/scheduler/jobs.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/fixtures/sample_jobs.json create mode 100644 backend/tests/fixtures/sample_skills.json create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/integration/test_alerts_endpoints.py create mode 100644 backend/tests/integration/test_auth_endpoints.py create mode 100644 backend/tests/integration/test_panorama_endpoints.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 backend/tests/unit/test_alerts_service.py create mode 100644 backend/tests/unit/test_market_trends.py create mode 100644 backend/tests/unit/test_skills_extraction.py create mode 100644 data/dictionaries/skill_aliases.json create mode 100644 data/dictionaries/skills_custom.jsonl create mode 100644 data/dictionaries/skills_esco.jsonl create mode 100644 data/samples/vacantes_sample.json create mode 100644 docs/diagramas/.gitkeep create mode 100644 docs/diagramas/casos-uso.png.gitkeep create mode 100644 docs/propuesta/.gitkeep create mode 100644 frontend/assets/css/base/_reset.css create mode 100644 frontend/assets/css/base/_typography.css create mode 100644 frontend/assets/css/base/_variables.css create mode 100644 frontend/assets/css/components/_alerts.css create mode 100644 frontend/assets/css/components/_buttons.css create mode 100644 frontend/assets/css/components/_cards.css create mode 100644 frontend/assets/css/components/_charts.css create mode 100644 frontend/assets/css/components/_forms.css create mode 100644 frontend/assets/css/components/_navbar.css create mode 100644 frontend/assets/css/components/_tables.css create mode 100644 frontend/assets/css/layouts/_containers.css create mode 100644 frontend/assets/css/layouts/_grid.css create mode 100644 frontend/assets/css/main.css create mode 100644 frontend/assets/css/pages/_admin.css create mode 100644 frontend/assets/css/pages/_alertas.css create mode 100644 frontend/assets/css/pages/_auth.css create mode 100644 frontend/assets/css/pages/_panorama.css create mode 100644 frontend/assets/images/brand/.gitkeep create mode 100644 frontend/assets/images/icons/.gitkeep create mode 100644 frontend/assets/js/api/admin.api.js create mode 100644 frontend/assets/js/api/alertas.api.js create mode 100644 frontend/assets/js/api/auth.api.js create mode 100644 frontend/assets/js/api/client.js create mode 100644 frontend/assets/js/api/panorama.api.js create mode 100644 frontend/assets/js/components/chart.init.js create mode 100644 frontend/assets/js/components/map.init.js create mode 100644 frontend/assets/js/components/navbar.js create mode 100644 frontend/assets/js/pages/admin.js create mode 100644 frontend/assets/js/pages/alertas.js create mode 100644 frontend/assets/js/pages/comparar.js create mode 100644 frontend/assets/js/pages/panorama.js create mode 100644 frontend/assets/js/utils/auth.utils.js create mode 100644 frontend/assets/js/utils/formatters.js create mode 100644 frontend/assets/js/utils/validators.js create mode 100644 frontend/views/admin/respaldos.html create mode 100644 frontend/views/admin/usuarios.html create mode 100644 frontend/views/alertas.html create mode 100644 frontend/views/comparar.html create mode 100644 frontend/views/errors/404.html create mode 100644 frontend/views/errors/500.html create mode 100644 frontend/views/index.html create mode 100644 frontend/views/panorama.html create mode 100644 frontend/views/register.html create mode 100644 scripts/backup_manual.py create mode 100644 scripts/build_dictionary.py create mode 100644 scripts/seed_db.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1 @@ + 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/.gitignore b/.gitignore new file mode 100644 index 0000000..61a02a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# 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/ diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..19a76f1 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..49d1b14 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# __init__ — SkillStat \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..0b17151 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1 @@ +# config — SkillStat \ No newline at end of file diff --git a/backend/app/controllers/__init__.py b/backend/app/controllers/__init__.py new file mode 100644 index 0000000..653787e --- /dev/null +++ b/backend/app/controllers/__init__.py @@ -0,0 +1 @@ +# controllers/__init__ — SkillStat \ No newline at end of file diff --git a/backend/app/controllers/admin_bp.py b/backend/app/controllers/admin_bp.py new file mode 100644 index 0000000..4a8b649 --- /dev/null +++ b/backend/app/controllers/admin_bp.py @@ -0,0 +1 @@ +# admin_bp — SkillStat \ No newline at end of file diff --git a/backend/app/controllers/alerts_bp.py b/backend/app/controllers/alerts_bp.py new file mode 100644 index 0000000..905f18b --- /dev/null +++ b/backend/app/controllers/alerts_bp.py @@ -0,0 +1 @@ +# alerts_bp — SkillStat \ No newline at end of file diff --git a/backend/app/controllers/auth_bp.py b/backend/app/controllers/auth_bp.py new file mode 100644 index 0000000..f4034ea --- /dev/null +++ b/backend/app/controllers/auth_bp.py @@ -0,0 +1 @@ +# auth_bp — SkillStat \ No newline at end of file diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py new file mode 100644 index 0000000..f9bb59f --- /dev/null +++ b/backend/app/controllers/panorama_bp.py @@ -0,0 +1 @@ +# panorama_bp — SkillStat \ No newline at end of file diff --git a/backend/app/extensions.py b/backend/app/extensions.py new file mode 100644 index 0000000..667691b --- /dev/null +++ b/backend/app/extensions.py @@ -0,0 +1 @@ +# extensions — SkillStat \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..ba8073c --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +# models/__init__ — SkillStat \ No newline at end of file diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py new file mode 100644 index 0000000..dcfcf33 --- /dev/null +++ b/backend/app/models/alert.py @@ -0,0 +1 @@ +# alert — SkillStat \ No newline at end of file diff --git a/backend/app/models/backup.py b/backend/app/models/backup.py new file mode 100644 index 0000000..dd81b4e --- /dev/null +++ b/backend/app/models/backup.py @@ -0,0 +1 @@ +# backup — SkillStat \ No newline at end of file diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 0000000..02ab375 --- /dev/null +++ b/backend/app/models/category.py @@ -0,0 +1 @@ +# category — SkillStat \ No newline at end of file diff --git a/backend/app/models/city.py b/backend/app/models/city.py new file mode 100644 index 0000000..522d78c --- /dev/null +++ b/backend/app/models/city.py @@ -0,0 +1 @@ +# city — SkillStat \ No newline at end of file diff --git a/backend/app/models/job.py b/backend/app/models/job.py new file mode 100644 index 0000000..69318c1 --- /dev/null +++ b/backend/app/models/job.py @@ -0,0 +1 @@ +# job — SkillStat \ No newline at end of file diff --git a/backend/app/models/job_skill.py b/backend/app/models/job_skill.py new file mode 100644 index 0000000..e81b798 --- /dev/null +++ b/backend/app/models/job_skill.py @@ -0,0 +1 @@ +# job_skill — SkillStat \ No newline at end of file diff --git a/backend/app/models/skill.py b/backend/app/models/skill.py new file mode 100644 index 0000000..39caa02 --- /dev/null +++ b/backend/app/models/skill.py @@ -0,0 +1 @@ +# skill — SkillStat \ No newline at end of file diff --git a/backend/app/models/trend_snapshot.py b/backend/app/models/trend_snapshot.py new file mode 100644 index 0000000..4cce0af --- /dev/null +++ b/backend/app/models/trend_snapshot.py @@ -0,0 +1 @@ +# trend_snapshot — SkillStat \ No newline at end of file diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..5356314 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1 @@ +# user — SkillStat \ No newline at end of file diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..47c32eb --- /dev/null +++ b/backend/app/repositories/__init__.py @@ -0,0 +1 @@ +# repositories/__init__ — SkillStat \ No newline at end of file diff --git a/backend/app/repositories/alert_repository.py b/backend/app/repositories/alert_repository.py new file mode 100644 index 0000000..64b9c74 --- /dev/null +++ b/backend/app/repositories/alert_repository.py @@ -0,0 +1 @@ +# alert_repository — SkillStat \ No newline at end of file diff --git a/backend/app/repositories/backup_repository.py b/backend/app/repositories/backup_repository.py new file mode 100644 index 0000000..ec06807 --- /dev/null +++ b/backend/app/repositories/backup_repository.py @@ -0,0 +1 @@ +# backup_repository — SkillStat \ No newline at end of file diff --git a/backend/app/repositories/base_repository.py b/backend/app/repositories/base_repository.py new file mode 100644 index 0000000..1df0dae --- /dev/null +++ b/backend/app/repositories/base_repository.py @@ -0,0 +1 @@ +# base_repository — SkillStat \ No newline at end of file diff --git a/backend/app/repositories/category_repository.py b/backend/app/repositories/category_repository.py new file mode 100644 index 0000000..2dc73ad --- /dev/null +++ b/backend/app/repositories/category_repository.py @@ -0,0 +1 @@ +# category_repository — SkillStat \ No newline at end of file diff --git a/backend/app/repositories/city_repository.py b/backend/app/repositories/city_repository.py new file mode 100644 index 0000000..057e9f7 --- /dev/null +++ b/backend/app/repositories/city_repository.py @@ -0,0 +1 @@ +# city_repository — SkillStat \ No newline at end of file diff --git a/backend/app/repositories/job_repository.py b/backend/app/repositories/job_repository.py new file mode 100644 index 0000000..16dc31b --- /dev/null +++ b/backend/app/repositories/job_repository.py @@ -0,0 +1 @@ +# job_repository — SkillStat \ No newline at end of file diff --git a/backend/app/repositories/skill_repository.py b/backend/app/repositories/skill_repository.py new file mode 100644 index 0000000..327bf51 --- /dev/null +++ b/backend/app/repositories/skill_repository.py @@ -0,0 +1 @@ +# skill_repository — SkillStat \ No newline at end of file diff --git a/backend/app/repositories/trend_repository.py b/backend/app/repositories/trend_repository.py new file mode 100644 index 0000000..43a9d0c --- /dev/null +++ b/backend/app/repositories/trend_repository.py @@ -0,0 +1 @@ +# trend_repository — SkillStat \ No newline at end of file diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py new file mode 100644 index 0000000..a8a8204 --- /dev/null +++ b/backend/app/repositories/user_repository.py @@ -0,0 +1 @@ +# user_repository — SkillStat \ No newline at end of file diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..555aee5 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +# schemas/__init__ — SkillStat \ No newline at end of file diff --git a/backend/app/schemas/alert_schema.py b/backend/app/schemas/alert_schema.py new file mode 100644 index 0000000..5f1077b --- /dev/null +++ b/backend/app/schemas/alert_schema.py @@ -0,0 +1 @@ +# alert_schema — SkillStat \ No newline at end of file diff --git a/backend/app/schemas/auth_schema.py b/backend/app/schemas/auth_schema.py new file mode 100644 index 0000000..038d6ef --- /dev/null +++ b/backend/app/schemas/auth_schema.py @@ -0,0 +1 @@ +# auth_schema — SkillStat \ No newline at end of file diff --git a/backend/app/schemas/skill_schema.py b/backend/app/schemas/skill_schema.py new file mode 100644 index 0000000..9e5533d --- /dev/null +++ b/backend/app/schemas/skill_schema.py @@ -0,0 +1 @@ +# skill_schema — SkillStat \ No newline at end of file diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..8c95b67 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +# services/__init__ — SkillStat \ No newline at end of file diff --git a/backend/app/services/alerts_service.py b/backend/app/services/alerts_service.py new file mode 100644 index 0000000..46d7ea1 --- /dev/null +++ b/backend/app/services/alerts_service.py @@ -0,0 +1 @@ +# alerts_service — SkillStat \ No newline at end of file diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py new file mode 100644 index 0000000..c32068f --- /dev/null +++ b/backend/app/services/backup_service.py @@ -0,0 +1 @@ +# backup_service — SkillStat \ No newline at end of file diff --git a/backend/app/services/ingestion_service.py b/backend/app/services/ingestion_service.py new file mode 100644 index 0000000..a6e5b53 --- /dev/null +++ b/backend/app/services/ingestion_service.py @@ -0,0 +1 @@ +# ingestion_service — SkillStat \ No newline at end of file diff --git a/backend/app/services/market_trends_service.py b/backend/app/services/market_trends_service.py new file mode 100644 index 0000000..15f1045 --- /dev/null +++ b/backend/app/services/market_trends_service.py @@ -0,0 +1 @@ +# market_trends_service — SkillStat \ No newline at end of file diff --git a/backend/app/services/skills_extraction_service.py b/backend/app/services/skills_extraction_service.py new file mode 100644 index 0000000..3b89d95 --- /dev/null +++ b/backend/app/services/skills_extraction_service.py @@ -0,0 +1 @@ +# skills_extraction_service — SkillStat \ No newline at end of file diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..63fd2f4 --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1 @@ +# utils/__init__ — SkillStat \ No newline at end of file diff --git a/backend/app/utils/errors.py b/backend/app/utils/errors.py new file mode 100644 index 0000000..1f765b9 --- /dev/null +++ b/backend/app/utils/errors.py @@ -0,0 +1 @@ +# errors — SkillStat \ No newline at end of file diff --git a/backend/app/utils/hash.py b/backend/app/utils/hash.py new file mode 100644 index 0000000..b6f6fef --- /dev/null +++ b/backend/app/utils/hash.py @@ -0,0 +1 @@ +# hash — SkillStat \ No newline at end of file diff --git a/backend/app/utils/response.py b/backend/app/utils/response.py new file mode 100644 index 0000000..ae043e4 --- /dev/null +++ b/backend/app/utils/response.py @@ -0,0 +1 @@ +# response — SkillStat \ No newline at end of file diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py new file mode 100644 index 0000000..d187075 --- /dev/null +++ b/backend/app/utils/security.py @@ -0,0 +1 @@ +# security — SkillStat \ No newline at end of file diff --git a/backend/clients/__init__.py b/backend/clients/__init__.py new file mode 100644 index 0000000..d4eef60 --- /dev/null +++ b/backend/clients/__init__.py @@ -0,0 +1 @@ +# clients/__init__ — SkillStat \ No newline at end of file diff --git a/backend/clients/adzuna_client.py b/backend/clients/adzuna_client.py new file mode 100644 index 0000000..eda2ea1 --- /dev/null +++ b/backend/clients/adzuna_client.py @@ -0,0 +1 @@ +# adzuna_client — SkillStat \ No newline at end of file diff --git a/backend/clients/base_client.py b/backend/clients/base_client.py new file mode 100644 index 0000000..db2f7e2 --- /dev/null +++ b/backend/clients/base_client.py @@ -0,0 +1 @@ +# base_client — SkillStat \ No newline at end of file diff --git a/backend/clients/nominatim_client.py b/backend/clients/nominatim_client.py new file mode 100644 index 0000000..e48525f --- /dev/null +++ b/backend/clients/nominatim_client.py @@ -0,0 +1 @@ +# nominatim_client — SkillStat \ No newline at end of file diff --git a/backend/clients/sendgrid_client.py b/backend/clients/sendgrid_client.py new file mode 100644 index 0000000..a20b95c --- /dev/null +++ b/backend/clients/sendgrid_client.py @@ -0,0 +1 @@ +# sendgrid_client — SkillStat \ No newline at end of file diff --git a/backend/migrations/.gitkeep b/backend/migrations/.gitkeep new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/backend/migrations/.gitkeep @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..19a76f1 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..19a76f1 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..63c1164 --- /dev/null +++ b/backend/run.py @@ -0,0 +1 @@ +# run — SkillStat \ 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..084e931 --- /dev/null +++ b/backend/scheduler/jobs.py @@ -0,0 +1 @@ +# jobs — SkillStat \ No newline at end of file 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..0ea4230 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1 @@ +# conftest — SkillStat \ 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/test_alerts_endpoints.py b/backend/tests/integration/test_alerts_endpoints.py new file mode 100644 index 0000000..0bba45e --- /dev/null +++ b/backend/tests/integration/test_alerts_endpoints.py @@ -0,0 +1 @@ +# test_alerts_endpoints — SkillStat \ No newline at end of file diff --git a/backend/tests/integration/test_auth_endpoints.py b/backend/tests/integration/test_auth_endpoints.py new file mode 100644 index 0000000..d42222f --- /dev/null +++ b/backend/tests/integration/test_auth_endpoints.py @@ -0,0 +1 @@ +# test_auth_endpoints — SkillStat \ No newline at end of file diff --git a/backend/tests/integration/test_panorama_endpoints.py b/backend/tests/integration/test_panorama_endpoints.py new file mode 100644 index 0000000..04e1143 --- /dev/null +++ b/backend/tests/integration/test_panorama_endpoints.py @@ -0,0 +1 @@ +# test_panorama_endpoints — SkillStat \ No newline at end of file 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_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_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_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/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..ad47dbb --- /dev/null +++ b/data/dictionaries/skills_esco.jsonl @@ -0,0 +1 @@ +[] \ No newline at end of file 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/diagramas/.gitkeep b/docs/diagramas/.gitkeep new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/docs/diagramas/.gitkeep @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/diagramas/casos-uso.png.gitkeep b/docs/diagramas/casos-uso.png.gitkeep new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/docs/diagramas/casos-uso.png.gitkeep @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/propuesta/.gitkeep b/docs/propuesta/.gitkeep new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/docs/propuesta/.gitkeep @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/assets/css/base/_reset.css b/frontend/assets/css/base/_reset.css new file mode 100644 index 0000000..f3a15a2 --- /dev/null +++ b/frontend/assets/css/base/_reset.css @@ -0,0 +1 @@ +/* _reset.css — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/css/base/_typography.css b/frontend/assets/css/base/_typography.css new file mode 100644 index 0000000..1915275 --- /dev/null +++ b/frontend/assets/css/base/_typography.css @@ -0,0 +1 @@ +/* _typography.css — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/css/base/_variables.css b/frontend/assets/css/base/_variables.css new file mode 100644 index 0000000..2d6a979 --- /dev/null +++ b/frontend/assets/css/base/_variables.css @@ -0,0 +1 @@ +/* _variables.css — SkillStat */ \ No newline at end of file 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..f446fb1 --- /dev/null +++ b/frontend/assets/css/components/_buttons.css @@ -0,0 +1 @@ +/* _buttons.css — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/css/components/_cards.css b/frontend/assets/css/components/_cards.css new file mode 100644 index 0000000..2ac0c93 --- /dev/null +++ b/frontend/assets/css/components/_cards.css @@ -0,0 +1 @@ +/* _cards.css — SkillStat */ \ No newline at end of file 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/_forms.css b/frontend/assets/css/components/_forms.css new file mode 100644 index 0000000..f7273f7 --- /dev/null +++ b/frontend/assets/css/components/_forms.css @@ -0,0 +1 @@ +/* _forms.css — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/css/components/_navbar.css b/frontend/assets/css/components/_navbar.css new file mode 100644 index 0000000..544ea5c --- /dev/null +++ b/frontend/assets/css/components/_navbar.css @@ -0,0 +1 @@ +/* _navbar.css — SkillStat */ \ No newline at end of file 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/layouts/_containers.css b/frontend/assets/css/layouts/_containers.css new file mode 100644 index 0000000..11fec5b --- /dev/null +++ b/frontend/assets/css/layouts/_containers.css @@ -0,0 +1 @@ +/* _containers.css — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/css/layouts/_grid.css b/frontend/assets/css/layouts/_grid.css new file mode 100644 index 0000000..f5e1c53 --- /dev/null +++ b/frontend/assets/css/layouts/_grid.css @@ -0,0 +1 @@ +/* _grid.css — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css new file mode 100644 index 0000000..b2b63af --- /dev/null +++ b/frontend/assets/css/main.css @@ -0,0 +1 @@ +/* main.css — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/css/pages/_admin.css b/frontend/assets/css/pages/_admin.css new file mode 100644 index 0000000..8b95d86 --- /dev/null +++ b/frontend/assets/css/pages/_admin.css @@ -0,0 +1 @@ +/* _admin.css — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/css/pages/_alertas.css b/frontend/assets/css/pages/_alertas.css new file mode 100644 index 0000000..c366731 --- /dev/null +++ b/frontend/assets/css/pages/_alertas.css @@ -0,0 +1 @@ +/* _alertas.css — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/css/pages/_auth.css b/frontend/assets/css/pages/_auth.css new file mode 100644 index 0000000..59200ac --- /dev/null +++ b/frontend/assets/css/pages/_auth.css @@ -0,0 +1 @@ +/* _auth.css — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/css/pages/_panorama.css b/frontend/assets/css/pages/_panorama.css new file mode 100644 index 0000000..84020ab --- /dev/null +++ b/frontend/assets/css/pages/_panorama.css @@ -0,0 +1 @@ +/* _panorama.css — SkillStat */ \ No newline at end of file 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/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..968b4a8 --- /dev/null +++ b/frontend/assets/js/api/admin.api.js @@ -0,0 +1 @@ +/* admin.api.js — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/js/api/alertas.api.js b/frontend/assets/js/api/alertas.api.js new file mode 100644 index 0000000..d51602e --- /dev/null +++ b/frontend/assets/js/api/alertas.api.js @@ -0,0 +1 @@ +/* alertas.api.js — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/js/api/auth.api.js b/frontend/assets/js/api/auth.api.js new file mode 100644 index 0000000..3fd0f8c --- /dev/null +++ b/frontend/assets/js/api/auth.api.js @@ -0,0 +1 @@ +/* auth.api.js — SkillStat */ \ 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..ccc14e9 --- /dev/null +++ b/frontend/assets/js/api/client.js @@ -0,0 +1 @@ +/* client.js — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/js/api/panorama.api.js b/frontend/assets/js/api/panorama.api.js new file mode 100644 index 0000000..e5da3ed --- /dev/null +++ b/frontend/assets/js/api/panorama.api.js @@ -0,0 +1 @@ +/* panorama.api.js — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/js/components/chart.init.js b/frontend/assets/js/components/chart.init.js new file mode 100644 index 0000000..059177c --- /dev/null +++ b/frontend/assets/js/components/chart.init.js @@ -0,0 +1 @@ +/* chart.init.js — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/js/components/map.init.js b/frontend/assets/js/components/map.init.js new file mode 100644 index 0000000..5b1c1b6 --- /dev/null +++ b/frontend/assets/js/components/map.init.js @@ -0,0 +1 @@ +/* map.init.js — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/js/components/navbar.js b/frontend/assets/js/components/navbar.js new file mode 100644 index 0000000..54939d5 --- /dev/null +++ b/frontend/assets/js/components/navbar.js @@ -0,0 +1 @@ +/* navbar.js — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/js/pages/admin.js b/frontend/assets/js/pages/admin.js new file mode 100644 index 0000000..863b2a3 --- /dev/null +++ b/frontend/assets/js/pages/admin.js @@ -0,0 +1 @@ +/* admin.js — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/js/pages/alertas.js b/frontend/assets/js/pages/alertas.js new file mode 100644 index 0000000..9e0f289 --- /dev/null +++ b/frontend/assets/js/pages/alertas.js @@ -0,0 +1 @@ +/* alertas.js — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/js/pages/comparar.js b/frontend/assets/js/pages/comparar.js new file mode 100644 index 0000000..7280861 --- /dev/null +++ b/frontend/assets/js/pages/comparar.js @@ -0,0 +1 @@ +/* comparar.js — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/js/pages/panorama.js b/frontend/assets/js/pages/panorama.js new file mode 100644 index 0000000..dd6b08f --- /dev/null +++ b/frontend/assets/js/pages/panorama.js @@ -0,0 +1 @@ +/* panorama.js — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/js/utils/auth.utils.js b/frontend/assets/js/utils/auth.utils.js new file mode 100644 index 0000000..0df3e29 --- /dev/null +++ b/frontend/assets/js/utils/auth.utils.js @@ -0,0 +1 @@ +/* auth.utils.js — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/js/utils/formatters.js b/frontend/assets/js/utils/formatters.js new file mode 100644 index 0000000..4e6e95a --- /dev/null +++ b/frontend/assets/js/utils/formatters.js @@ -0,0 +1 @@ +/* formatters.js — SkillStat */ \ No newline at end of file diff --git a/frontend/assets/js/utils/validators.js b/frontend/assets/js/utils/validators.js new file mode 100644 index 0000000..6bae3c6 --- /dev/null +++ b/frontend/assets/js/utils/validators.js @@ -0,0 +1 @@ +/* validators.js — SkillStat */ \ No newline at end of file diff --git a/frontend/views/admin/respaldos.html b/frontend/views/admin/respaldos.html new file mode 100644 index 0000000..6f9291b --- /dev/null +++ b/frontend/views/admin/respaldos.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/views/admin/usuarios.html b/frontend/views/admin/usuarios.html new file mode 100644 index 0000000..82bc224 --- /dev/null +++ b/frontend/views/admin/usuarios.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/views/alertas.html b/frontend/views/alertas.html new file mode 100644 index 0000000..14a9c64 --- /dev/null +++ b/frontend/views/alertas.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/views/comparar.html b/frontend/views/comparar.html new file mode 100644 index 0000000..5b45c1e --- /dev/null +++ b/frontend/views/comparar.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/views/errors/404.html b/frontend/views/errors/404.html new file mode 100644 index 0000000..ef3457a --- /dev/null +++ b/frontend/views/errors/404.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/views/errors/500.html b/frontend/views/errors/500.html new file mode 100644 index 0000000..fe4d176 --- /dev/null +++ b/frontend/views/errors/500.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/views/index.html b/frontend/views/index.html new file mode 100644 index 0000000..292c7d5 --- /dev/null +++ b/frontend/views/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/views/panorama.html b/frontend/views/panorama.html new file mode 100644 index 0000000..51adeae --- /dev/null +++ b/frontend/views/panorama.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/views/register.html b/frontend/views/register.html new file mode 100644 index 0000000..176986d --- /dev/null +++ b/frontend/views/register.html @@ -0,0 +1 @@ + \ No newline at end of file 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..a2c5ab9 --- /dev/null +++ b/scripts/build_dictionary.py @@ -0,0 +1 @@ +# build_dictionary — SkillStat \ No newline at end of file diff --git a/scripts/seed_db.py b/scripts/seed_db.py new file mode 100644 index 0000000..484c6f5 --- /dev/null +++ b/scripts/seed_db.py @@ -0,0 +1 @@ +# seed_db — SkillStat \ No newline at end of file From 4eb1b42068bbf278c77d6b7524ae679be6edc3c0 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 4 Jun 2026 07:25:50 -0600 Subject: [PATCH 002/277] docs: add internal project documentation and team guidelines --- CONTRIBUTING.md | 142 +++++++++++++++++++++++++++++ backend/README.md | 29 ++++++ backend/app/controllers/README.md | 26 ++++++ backend/app/models/README.md | 22 +++++ backend/app/repositories/README.md | 23 +++++ backend/app/services/README.md | 28 ++++++ backend/clients/README.md | 23 +++++ data/README.md | 23 +++++ docs/ARQUITECTURA.md | 46 ++++++++++ docs/GUIA_ENTORNO.md | 100 ++++++++++++++++++++ docs/GUIA_GIT.md | 111 ++++++++++++++++++++++ frontend/README.md | 38 ++++++++ scripts/README.md | 32 +++++++ 13 files changed, 643 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 backend/README.md create mode 100644 backend/app/controllers/README.md create mode 100644 backend/app/models/README.md create mode 100644 backend/app/repositories/README.md create mode 100644 backend/app/services/README.md create mode 100644 backend/clients/README.md create mode 100644 data/README.md create mode 100644 docs/ARQUITECTURA.md create mode 100644 docs/GUIA_ENTORNO.md create mode 100644 docs/GUIA_GIT.md create mode 100644 frontend/README.md create mode 100644 scripts/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c823edd --- /dev/null +++ b/CONTRIBUTING.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. diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..ff5b8a6 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,29 @@ +# 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. 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/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/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/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/clients/README.md b/backend/clients/README.md new file mode 100644 index 0000000..02b3205 --- /dev/null +++ b/backend/clients/README.md @@ -0,0 +1,23 @@ +# clients/ + +Aqui viven los clientes que se comunican con APIs externas. Cada archivo +envuelve una sola API y expone metodos claros para que los servicios +puedan usarla sin conocer los detalles de la comunicacion HTTP. + +## Lo que va aqui + +- La logica de autenticacion con cada API externa +- El manejo de errores y reintentos de conexion +- La transformacion de la respuesta al formato que el sistema necesita + +## Los clientes del proyecto + +| Archivo | API que envuelve | +|---------|-----------------| +| `adzuna_client.py` | Adzuna Jobs API - fuente de vacantes | +| `nominatim_client.py` | Nominatim / OpenStreetMap - geocodificacion | +| `sendgrid_client.py` | SendGrid - envio de correos de alerta | +| `base_client.py` | Logica compartida: reintentos, timeouts, headers | + +Los servicios nunca llaman directamente a `requests` o `httpx`. +Siempre usan estos clientes. 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/docs/ARQUITECTURA.md b/docs/ARQUITECTURA.md new file mode 100644 index 0000000..bcb4340 --- /dev/null +++ b/docs/ARQUITECTURA.md @@ -0,0 +1,46 @@ +# Arquitectura de SkillStat + +SkillStat es una plataforma de inteligencia de mercado laboral que +recopila vacantes tecnologicas, extrae habilidades con procesamiento +de lenguaje natural y presenta los resultados en un dashboard analitico +llamado Panorama. + +## Como esta organizado el repositorio + +``` +SkillStat/ +├── frontend/ # Interfaz web: vistas, estilos y logica del cliente +├── backend/ # API REST, servicios, modelos y acceso a datos +├── data/ # Activos de datos para el procesamiento NLP +├── scripts/ # Herramientas de uso manual para el equipo +└── docs/ # Documentacion interna del proyecto +``` + +## Las capas del backend + +El backend sigue una arquitectura en capas donde cada capa tiene una +responsabilidad clara y no invade la del resto: + +| Capa | Donde vive | Que hace | +|------|-----------|----------| +| Controladores | `backend/app/controllers/` | Recibe peticiones HTTP y devuelve respuestas | +| Servicios | `backend/app/services/` | Contiene la logica de negocio del sistema | +| Repositorios | `backend/app/repositories/` | Habla con la base de datos | +| Modelos | `backend/app/models/` | Define la estructura de los datos | +| Clientes | `backend/clients/` | Se comunica con APIs externas | + +Los controladores llaman a los servicios. Los servicios llaman a los +repositorios. Los repositorios hablan con la base de datos. Los clientes +son usados por los servicios para comunicarse con el exterior. + +## Donde encontrar cada cosa + +- Las rutas de la API viven en `backend/app/controllers/` +- La logica que procesa vacantes y extrae habilidades vive en `backend/app/services/` +- Las tablas de la base de datos estan definidas en `backend/app/models/` +- Las consultas a la base de datos viven en `backend/app/repositories/` +- Las conexiones con Adzuna, Nominatim y SendGrid viven en `backend/clients/` +- Las vistas HTML del Panorama y el resto de pantallas viven en `frontend/views/` +- Los archivos de estilos estan en `frontend/assets/css/` +- La logica del cliente esta en `frontend/assets/js/` +- El diccionario de habilidades tecnologicas vive en `data/dictionaries/` diff --git a/docs/GUIA_ENTORNO.md b/docs/GUIA_ENTORNO.md new file mode 100644 index 0000000..1c4c973 --- /dev/null +++ b/docs/GUIA_ENTORNO.md @@ -0,0 +1,100 @@ +# 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` +- 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. + +```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 +``` + +> El archivo requirements.txt se completara en los proximos pasos del +> desarrollo. Si aun esta vacio este paso se puede omitir por ahora. + +**5. 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 con los valores que se +nos proporcionen. Nunca subimos el archivo `.env` al repositorio. + +**6. 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 + +## 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`. +- Para cualquier otro problema lo comentamos en el canal del equipo + antes de intentar soluciones por cuenta propia. diff --git a/docs/GUIA_GIT.md b/docs/GUIA_GIT.md new file mode 100644 index 0000000..3211378 --- /dev/null +++ b/docs/GUIA_GIT.md @@ -0,0 +1,111 @@ +# 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/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/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 +``` From f0452992c202472a220d0166238e8e2b85177fad Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 4 Jun 2026 22:56:23 -0600 Subject: [PATCH 003/277] chore(deps): define production and development dependencies --- backend/.env.example | 35 +++++++++++++++++++++++++++++++++- backend/requirements-dev.txt | 20 ++++++++++++++++++- backend/requirements.txt | 37 +++++++++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 19a76f1..306195c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1 +1,34 @@ - \ No newline at end of file +# 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 + +# APIs externas +ADZUNA_APP_ID=app-id-de-adzuna +ADZUNA_APP_KEY=api-key-de-adzuna +SENDGRID_API_KEY=api-key-de-sendgrid + +# Almacenamiento de respaldos +BACKUP_STORAGE_URL=url-del-almacenamiento +BACKUP_STORAGE_KEY=clave-del-almacenamiento + +# 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 diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 19a76f1..08afca8 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -1 +1,19 @@ - \ No newline at end of file +# 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 index 19a76f1..63ee370 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1 +1,36 @@ - \ No newline at end of file +# 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 + +# Envío de correos electrónicos +sendgrid>=6.11,<7.0 + +# Servidor WSGI para producción +gunicorn>=23.0,<24.0 From c05b59fc163e65e76154e69887fefb4b2db1177f Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 4 Jun 2026 23:03:52 -0600 Subject: [PATCH 004/277] chore(config): add environment-based Flask configuration --- backend/app/config.py | 103 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 0b17151..0736421 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1 +1,102 @@ -# config — SkillStat \ No newline at end of file +import os +from datetime import timedelta + + +class BaseConfig: + """Configuración base compartida por todos los entornos""" + + 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" + ) + JWT_ACCESS_TOKEN_EXPIRES = timedelta( + seconds=int(os.environ.get("JWT_ACCESS_TOKEN_EXPIRES", 86400)) + ) + + JWT_ERROR_MESSAGE_KEY = "error" + + CORS_ORIGINS = os.environ.get( + "CORS_ORIGINS", "http://localhost:5500" + ).split(",") + + # APIs externas + ADZUNA_APP_ID = os.environ.get("ADZUNA_APP_ID") + ADZUNA_APP_KEY = os.environ.get("ADZUNA_APP_KEY") + SENDGRID_API_KEY = os.environ.get("SENDGRID_API_KEY") + + # Almacenamiento de respaldos + BACKUP_STORAGE_URL = os.environ.get("BACKUP_STORAGE_URL") + BACKUP_STORAGE_KEY = os.environ.get("BACKUP_STORAGE_KEY") + + # Scheduler + 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) + ) + + +class DevelopmentConfig(BaseConfig): + """Configuración para el entorno de desarrollo local""" + + DEBUG = True + SQLALCHEMY_DATABASE_URI = os.environ.get( + "DATABASE_URL", + "postgresql://postgres:postgres@localhost:5432/skillstat_dev", + ) + + +class ProductionConfig(BaseConfig): + """Configuración para el entorno de producción""" + + DEBUG = False + TESTING = False + + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") + + if not SQLALCHEMY_DATABASE_URI: + raise RuntimeError( + "DATABASE_URL no está definida. " + "La aplicación no puede iniciar en producción " + "sin una base de datos configurada." + ) + + SQLALCHEMY_ENGINE_OPTIONS = { + **BaseConfig.SQLALCHEMY_ENGINE_OPTIONS, + "pool_size": 10, + "max_overflow": 20, + } + + +class TestingConfig(BaseConfig): + """Configuración para el entorno de pruebas automatizadas""" + + TESTING = True + DEBUG = True + + SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" + + JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=5) + + SCHEDULER_ENABLED = False + + +config_map = { + "development": DevelopmentConfig, + "production": ProductionConfig, + "testing": TestingConfig, +} From d59fa1fd3d75c016c98184ffe87e647904555a81 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 4 Jun 2026 23:09:23 -0600 Subject: [PATCH 005/277] chore(extensions): instantiate Flask extensions for deferred initialization --- backend/app/extensions.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/app/extensions.py b/backend/app/extensions.py index 667691b..602a1cb 100644 --- a/backend/app/extensions.py +++ b/backend/app/extensions.py @@ -1 +1,9 @@ -# extensions — SkillStat \ No newline at end of file +from flask_sqlalchemy import SQLAlchemy +from flask_jwt_extended import JWTManager +from flask_cors import CORS +from flask_migrate import Migrate + +db = SQLAlchemy() +jwt = JWTManager() +cors = CORS() +migrate = Migrate() From b103f33dad37869d36d1afd9122fd6fe5c8b4a48 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 5 Jun 2026 10:57:02 -0600 Subject: [PATCH 006/277] chore(controllers): add minimal Blueprint stubs for application factory --- backend/app/controllers/admin_bp.py | 6 +++++- backend/app/controllers/alerts_bp.py | 6 +++++- backend/app/controllers/auth_bp.py | 6 +++++- backend/app/controllers/panorama_bp.py | 6 +++++- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/backend/app/controllers/admin_bp.py b/backend/app/controllers/admin_bp.py index 4a8b649..32c1d60 100644 --- a/backend/app/controllers/admin_bp.py +++ b/backend/app/controllers/admin_bp.py @@ -1 +1,5 @@ -# admin_bp — SkillStat \ No newline at end of file +# Registramos aquí todas las rutas de administración del sistema: +# gestión de usuarios, ejecución de respaldos y restauración de datos +from flask import Blueprint + +admin_bp = Blueprint("admin", __name__) diff --git a/backend/app/controllers/alerts_bp.py b/backend/app/controllers/alerts_bp.py index 905f18b..86f17ab 100644 --- a/backend/app/controllers/alerts_bp.py +++ b/backend/app/controllers/alerts_bp.py @@ -1 +1,5 @@ -# alerts_bp — SkillStat \ No newline at end of file +# Registramos aquí todas las rutas de gestión de alertas: +# crear, listar, actualizar y eliminar alertas del usuario autenticado +from flask import Blueprint + +alerts_bp = Blueprint("alerts", __name__) diff --git a/backend/app/controllers/auth_bp.py b/backend/app/controllers/auth_bp.py index f4034ea..989b1f7 100644 --- a/backend/app/controllers/auth_bp.py +++ b/backend/app/controllers/auth_bp.py @@ -1 +1,5 @@ -# auth_bp — SkillStat \ No newline at end of file +# Registramos aquí todas las rutas relacionadas con autenticación: +# registro de cuenta, inicio de sesión y cierre de sesión +from flask import Blueprint + +auth_bp = Blueprint("auth", __name__) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py index f9bb59f..fa6ec8d 100644 --- a/backend/app/controllers/panorama_bp.py +++ b/backend/app/controllers/panorama_bp.py @@ -1 +1,5 @@ -# panorama_bp — SkillStat \ No newline at end of file +# Registramos aquí todas las rutas del Panorama: rankings de habilidades, +# distribución geográfica, evolución temporal y comparación entre tecnologías +from flask import Blueprint + +panorama_bp = Blueprint("panorama", __name__) From 7cea3481ee760ddbfea844a6d0ec0076026ab8a5 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 5 Jun 2026 16:20:37 -0600 Subject: [PATCH 007/277] feat(app): implement Flask application factory --- backend/app/__init__.py | 179 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 1 deletion(-) diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 49d1b14..ac79fc7 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1 +1,178 @@ -# __init__ — SkillStat \ No newline at end of file +# Construimos la aplicación usando el patrón application factory para poder +# crear instancias independientes según el entorno: desarrollo, producción o pruebas +import os +from flask import Flask, jsonify +from dotenv import load_dotenv + +from app.config import config_map +from app.extensions import db, jwt, cors, migrate + + +def create_app(env: str = None) -> Flask: + """Crea y configura una instancia de la aplicación Flask""" + + # Cargamos las variables de entorno antes de leer cualquier configuración + # para que estén disponibles cuando se instancian las clases de config + load_dotenv() + + app = Flask(__name__, instance_relative_config=False) + + # Seleccionamos la configuración según el entorno + 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_blueprints(app) + _register_error_handlers(app) + _register_health_check(app) + + return app + + +def _init_extensions(app: Flask) -> None: + """Conecta las extensiones con la instancia de la aplicación""" + db.init_app(app) + jwt.init_app(app) + cors.init_app( + app, + resources={r"/api/*": {"origins": app.config["CORS_ORIGINS"]}}, + ) + migrate.init_app(app, db) + _configure_jwt_errors() + + +def _configure_jwt_errors() -> None: + """Registra los manejadores de error de JWT para que sigan el formato + uniforme de la API en lugar del formato por defecto de la librería""" + + @jwt.expired_token_loader + def expired_token(_header, _payload): + return jsonify({ + "error": { + "code": "TOKEN_EXPIRED", + "message": "El token de acceso ha expirado. Inicia sesión de nuevo.", + } + }), 401 + + @jwt.invalid_token_loader + def invalid_token(_error): + return jsonify({ + "error": { + "code": "TOKEN_INVALID", + "message": "El token de acceso no es válido.", + } + }), 401 + + @jwt.unauthorized_loader + def missing_token(_error): + return jsonify({ + "error": { + "code": "UNAUTHORIZED", + "message": "Se requiere un token de acceso para usar este recurso.", + } + }), 401 + + +def _register_blueprints(app: Flask) -> None: + """Registra los Blueprints con sus prefijos de ruta correspondientes. + Importamos dentro de la función para evitar importaciones circulares + durante la inicialización de las extensiones""" + 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 + + 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") + + +def _register_error_handlers(app: Flask) -> None: + """Registra los manejadores de error HTTP para devolver respuestas JSON + con el formato uniforme de la API en lugar de páginas HTML por defecto""" + + @app.errorhandler(400) + def bad_request(_error): + return jsonify({ + "error": { + "code": "BAD_REQUEST", + "message": "La solicitud no tiene el formato correcto.", + } + }), 400 + + @app.errorhandler(401) + def unauthorized(_error): + return jsonify({ + "error": { + "code": "UNAUTHORIZED", + "message": "Se requiere autenticación para acceder a este recurso.", + } + }), 401 + + @app.errorhandler(403) + def forbidden(_error): + return jsonify({ + "error": { + "code": "FORBIDDEN", + "message": "No tienes permiso para realizar esta acción.", + } + }), 403 + + @app.errorhandler(404) + def not_found(_error): + return jsonify({ + "error": { + "code": "NOT_FOUND", + "message": "El recurso solicitado no existe.", + } + }), 404 + + @app.errorhandler(405) + def method_not_allowed(_error): + return jsonify({ + "error": { + "code": "METHOD_NOT_ALLOWED", + "message": "El método HTTP no está permitido para este recurso.", + } + }), 405 + + @app.errorhandler(409) + def conflict(_error): + return jsonify({ + "error": { + "code": "CONFLICT", + "message": "El recurso ya existe o hay un conflicto con el estado actual.", + } + }), 409 + + @app.errorhandler(422) + def unprocessable_entity(_error): + return jsonify({ + "error": { + "code": "VALIDATION_ERROR", + "message": "Los datos enviados no pasaron la validación.", + } + }), 422 + + @app.errorhandler(500) + def internal_error(_error): + return jsonify({ + "error": { + "code": "INTERNAL_ERROR", + "message": "Ocurrió un error interno. Por favor intenta de nuevo.", + } + }), 500 + + +def _register_health_check(app: Flask) -> None: + """Registra el endpoint de salud para verificar que el servicio está activo""" + + @app.route("/api/health") + def health_check(): + return jsonify({ + "status": "ok", + "service": "SkillStat API", + }), 200 + \ No newline at end of file From eebdc81e247055f3c59f92cb48a7692dd114a2a8 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 5 Jun 2026 16:27:49 -0600 Subject: [PATCH 008/277] chore(run): add development server entry point --- backend/run.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/run.py b/backend/run.py index 63c1164..0c5ba84 100644 --- a/backend/run.py +++ b/backend/run.py @@ -1 +1,14 @@ -# run — SkillStat \ No newline at end of file +# Iniciamos el servidor de desarrollo desde aquí. +# En producción usamos gunicorn directamente sin pasar por este archivo: +# gunicorn --bind 0.0.0.0:8000 "app:create_app()" +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run( + host="0.0.0.0", + port=5000, + debug=app.config.get("DEBUG", False), + ) + \ No newline at end of file From 92787d935404c16df42b322a2cfc4d6b85b1833f Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 5 Jun 2026 21:02:37 -0600 Subject: [PATCH 009/277] fix(config): move production DATABASE_URL validation to runtime --- backend/app/__init__.py | 11 +++++++++-- backend/app/config.py | 7 ------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/app/__init__.py b/backend/app/__init__.py index ac79fc7..0290049 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -11,8 +11,7 @@ def create_app(env: str = None) -> Flask: """Crea y configura una instancia de la aplicación Flask""" - # Cargamos las variables de entorno antes de leer cualquier configuración - # para que estén disponibles cuando se instancian las clases de config + # Cargamos las variables de entorno antes de leer cualquier configuración para que estén disponibles cuando se instancian las clases de config load_dotenv() app = Flask(__name__, instance_relative_config=False) @@ -22,6 +21,14 @@ def create_app(env: str = None) -> Flask: config_class = config_map.get(env, config_map["development"]) app.config.from_object(config_class) + # Verificamos que la base de datos esté configurada antes de continuar + # Hacemos esta validación aquí y no en la clase de configuración para que ocurra en tiempo de ejecución real y solo cuando el entorno es producción + if env == "production" and not app.config.get("SQLALCHEMY_DATABASE_URI"): + raise RuntimeError( + "DATABASE_URL no está definida. " + "La aplicación no puede iniciar en producción sin una base de datos configurada." + ) + _init_extensions(app) _register_blueprints(app) _register_error_handlers(app) diff --git a/backend/app/config.py b/backend/app/config.py index 0736421..3526bcc 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -68,13 +68,6 @@ class ProductionConfig(BaseConfig): SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") - if not SQLALCHEMY_DATABASE_URI: - raise RuntimeError( - "DATABASE_URL no está definida. " - "La aplicación no puede iniciar en producción " - "sin una base de datos configurada." - ) - SQLALCHEMY_ENGINE_OPTIONS = { **BaseConfig.SQLALCHEMY_ENGINE_OPTIONS, "pool_size": 10, From 71d67af4a8bfef6358f5ef1562c80c671ec919c2 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 6 Jun 2026 22:42:16 -0600 Subject: [PATCH 010/277] feat(models): implement base entities category, city and user --- backend/app/__init__.py | 3 +-- backend/app/models/category.py | 10 +++++++++- backend/app/models/city.py | 16 +++++++++++++++- backend/app/models/user.py | 20 +++++++++++++++++++- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 0290049..1037a27 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -181,5 +181,4 @@ def health_check(): return jsonify({ "status": "ok", "service": "SkillStat API", - }), 200 - \ No newline at end of file + }), 200 \ No newline at end of file diff --git a/backend/app/models/category.py b/backend/app/models/category.py index 02ab375..e5197d2 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -1 +1,9 @@ -# category — SkillStat \ No newline at end of file +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) \ No newline at end of file diff --git a/backend/app/models/city.py b/backend/app/models/city.py index 522d78c..65414c0 100644 --- a/backend/app/models/city.py +++ b/backend/app/models/city.py @@ -1 +1,15 @@ -# city — SkillStat \ No newline at end of file +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) \ No newline at end of file diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 5356314..eef1164 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1 +1,19 @@ -# user — SkillStat \ No newline at end of file +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) + password_hash = db.Column(db.String(255), nullable=False) + role = db.Column(db.String(20), nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + alerts = db.relationship('Alert', backref='user', lazy=True) + backups = db.relationship('Backup', backref='user', lazy=True) + + # Restringimos los roles permitidos directamente en la base de datos por seguridad + __table_args__ = ( + db.CheckConstraint("role IN ('GUEST', 'REGISTERED', 'ADMIN')", name='chk_users_role'), + ) \ No newline at end of file From 8ef35cca2c84380fc58ec017bdce17b2c4f6995e Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 6 Jun 2026 22:48:15 -0600 Subject: [PATCH 011/277] feat(models): implement first-level entities skill, job and backup --- backend/app/models/backup.py | 18 +++++++++++++++++- backend/app/models/job.py | 25 ++++++++++++++++++++++++- backend/app/models/skill.py | 15 ++++++++++++++- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/backend/app/models/backup.py b/backend/app/models/backup.py index dd81b4e..1dc4ca6 100644 --- a/backend/app/models/backup.py +++ b/backend/app/models/backup.py @@ -1 +1,17 @@ -# backup — SkillStat \ No newline at end of file +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) + + __table_args__ = ( + db.CheckConstraint("status IN ('PENDING', 'COMPLETED', 'FAILED')", name='chk_backups_status'), + ) \ No newline at end of file diff --git a/backend/app/models/job.py b/backend/app/models/job.py index 69318c1..d429322 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -1 +1,24 @@ -# job — SkillStat \ No newline at end of file +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) + processed = db.Column(db.Boolean, default=False, nullable=False) + + created_at = db.Column(db.DateTime, 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) \ No newline at end of file diff --git a/backend/app/models/skill.py b/backend/app/models/skill.py index 39caa02..1a7f586 100644 --- a/backend/app/models/skill.py +++ b/backend/app/models/skill.py @@ -1 +1,14 @@ -# skill — SkillStat \ No newline at end of file +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) \ No newline at end of file From e3cdf1ad76441f88468d125ecb4f8874df341a38 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 6 Jun 2026 22:53:19 -0600 Subject: [PATCH 012/277] feat(models): implement associative and analytical entities job_skill, alert, and trend_snapshot --- backend/app/models/alert.py | 13 ++++++++++++- backend/app/models/job_skill.py | 13 ++++++++++++- backend/app/models/trend_snapshot.py | 18 +++++++++++++++++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py index dcfcf33..89973ae 100644 --- a/backend/app/models/alert.py +++ b/backend/app/models/alert.py @@ -1 +1,12 @@ -# alert — SkillStat \ No newline at end of file +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) + threshold = db.Column(db.Integer, nullable=False) + active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) \ No newline at end of file diff --git a/backend/app/models/job_skill.py b/backend/app/models/job_skill.py index e81b798..009ebaa 100644 --- a/backend/app/models/job_skill.py +++ b/backend/app/models/job_skill.py @@ -1 +1,12 @@ -# job_skill — SkillStat \ No newline at end of file +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)) \ No newline at end of file diff --git a/backend/app/models/trend_snapshot.py b/backend/app/models/trend_snapshot.py index 4cce0af..0603189 100644 --- a/backend/app/models/trend_snapshot.py +++ b/backend/app/models/trend_snapshot.py @@ -1 +1,17 @@ -# trend_snapshot — SkillStat \ No newline at end of file +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=False) + 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'), + ) \ No newline at end of file From c0cf6b52608612f938ca3bbbf4a9896eb0ce026a Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 6 Jun 2026 22:58:54 -0600 Subject: [PATCH 013/277] feat(models): export all models in init --- backend/app/models/__init__.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ba8073c..927b57a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1 +1,21 @@ -# models/__init__ — SkillStat \ No newline at end of file +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 .alert import Alert +from .trend_snapshot import TrendSnapshot +from .backup import Backup + +__all__ = [ + 'Category', + 'City', + 'Skill', + 'Job', + 'JobSkill', + 'User', + 'Alert', + 'TrendSnapshot', + 'Backup' +] \ No newline at end of file From 0d4a5b6f6c12217f311fcdf6037a3e0b6b23220e Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 9 Jun 2026 17:11:09 -0600 Subject: [PATCH 014/277] refactor(models): add first_name and last_name to user model --- backend/app/models/user.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index eef1164..65f7e47 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -6,6 +6,8 @@ class User(db.Model): 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) password_hash = db.Column(db.String(255), nullable=False) role = db.Column(db.String(20), nullable=False) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) @@ -16,4 +18,7 @@ class User(db.Model): # Restringimos los roles permitidos directamente en la base de datos por seguridad __table_args__ = ( db.CheckConstraint("role IN ('GUEST', 'REGISTERED', 'ADMIN')", name='chk_users_role'), - ) \ No newline at end of file + ) + + def __repr__(self): + return f"" \ No newline at end of file From 2f1aa6f5009435ece5a1bfb5cb74a6dc3ae1d1da Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 9 Jun 2026 17:32:51 -0600 Subject: [PATCH 015/277] chore(db): initialize flask-migrate and alembic environment --- backend/migrations/.gitkeep | 1 - backend/migrations/README | 1 + backend/migrations/alembic.ini | 50 +++++++++++++ backend/migrations/env.py | 113 ++++++++++++++++++++++++++++++ backend/migrations/script.py.mako | 24 +++++++ 5 files changed, 188 insertions(+), 1 deletion(-) delete mode 100644 backend/migrations/.gitkeep create mode 100644 backend/migrations/README create mode 100644 backend/migrations/alembic.ini create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako diff --git a/backend/migrations/.gitkeep b/backend/migrations/.gitkeep deleted file mode 100644 index 5f28270..0000000 --- a/backend/migrations/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file 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"} From 44ab666acb9d2841415dd6d98fdf8a1aa572b20c Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 9 Jun 2026 17:43:24 -0600 Subject: [PATCH 016/277] chore(db): generate initial migration and register models metadata --- backend/app/__init__.py | 4 + ...9decfc36853f_initial_database_migration.py | 132 ++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 backend/migrations/versions/9decfc36853f_initial_database_migration.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 1037a27..242d6dc 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -30,6 +30,10 @@ def create_app(env: str = None) -> Flask: ) _init_extensions(app) + + # Importamos los modelos para que SQLAlchemy registre sus tablas en el metadata antes de que Alembic las lea durante la generación de migraciones + from app import models as _models # noqa: F401 + _register_blueprints(app) _register_error_handlers(app) _register_health_check(app) 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 ### From 7bd61b893c3a53420cf59e7ed73a05f433141070 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 9 Jun 2026 18:47:15 -0600 Subject: [PATCH 017/277] feat(repositories): implement generic base repository with rollback handling --- backend/app/repositories/base_repository.py | 47 ++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/backend/app/repositories/base_repository.py b/backend/app/repositories/base_repository.py index 1df0dae..4f8feca 100644 --- a/backend/app/repositories/base_repository.py +++ b/backend/app/repositories/base_repository.py @@ -1 +1,46 @@ -# base_repository — SkillStat \ No newline at end of file +from app.extensions import db + +class BaseRepository: + def __init__(self, model): + self.model = model + + def get_all(self): + return db.session.query(self.model).all() + + def get_by_id(self, id): + return db.session.get(self.model, id) + + def create(self, data): + # Protegemos la transacción con un bloque de manejo de errores para garantizar que una falla en la escritura no deje bloqueada la sesión de la base de datos + try: + instance = self.model(**data) + db.session.add(instance) + db.session.commit() + return instance + except Exception as e: + db.session.rollback() + raise e + + def update(self, id, data): + try: + instance = self.get_by_id(id) + if instance: + for key, value in data.items(): + setattr(instance, key, value) + db.session.commit() + return instance + except Exception as e: + db.session.rollback() + raise e + + def delete(self, id): + try: + instance = self.get_by_id(id) + if instance: + db.session.delete(instance) + db.session.commit() + return True + return False + except Exception as e: + db.session.rollback() + raise e \ No newline at end of file From 0dc0bbd122afdfbd53323e45e98d3e0cf581638e Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 9 Jun 2026 18:56:18 -0600 Subject: [PATCH 018/277] feat(repositories): implement specific repository classes for all domain models --- backend/app/repositories/alert_repository.py | 7 ++++++- backend/app/repositories/backup_repository.py | 7 ++++++- backend/app/repositories/category_repository.py | 7 ++++++- backend/app/repositories/city_repository.py | 7 ++++++- backend/app/repositories/job_repository.py | 7 ++++++- backend/app/repositories/job_skill_repository.py | 6 ++++++ backend/app/repositories/skill_repository.py | 7 ++++++- backend/app/repositories/trend_snapshot_repository.py | 6 ++++++ backend/app/repositories/user_repository.py | 7 ++++++- 9 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 backend/app/repositories/job_skill_repository.py create mode 100644 backend/app/repositories/trend_snapshot_repository.py diff --git a/backend/app/repositories/alert_repository.py b/backend/app/repositories/alert_repository.py index 64b9c74..bd0e190 100644 --- a/backend/app/repositories/alert_repository.py +++ b/backend/app/repositories/alert_repository.py @@ -1 +1,6 @@ -# alert_repository — SkillStat \ No newline at end of file +from app.repositories.base_repository import BaseRepository +from app.models import Alert + +class AlertRepository(BaseRepository): + def __init__(self): + super().__init__(Alert) \ No newline at end of file diff --git a/backend/app/repositories/backup_repository.py b/backend/app/repositories/backup_repository.py index ec06807..cff6149 100644 --- a/backend/app/repositories/backup_repository.py +++ b/backend/app/repositories/backup_repository.py @@ -1 +1,6 @@ -# backup_repository — SkillStat \ No newline at end of file +from app.repositories.base_repository import BaseRepository +from app.models import Backup + +class BackupRepository(BaseRepository): + def __init__(self): + super().__init__(Backup) \ No newline at end of file diff --git a/backend/app/repositories/category_repository.py b/backend/app/repositories/category_repository.py index 2dc73ad..7fc292e 100644 --- a/backend/app/repositories/category_repository.py +++ b/backend/app/repositories/category_repository.py @@ -1 +1,6 @@ -# category_repository — SkillStat \ No newline at end of file +from app.repositories.base_repository import BaseRepository +from app.models import Category + +class CategoryRepository(BaseRepository): + def __init__(self): + super().__init__(Category) \ No newline at end of file diff --git a/backend/app/repositories/city_repository.py b/backend/app/repositories/city_repository.py index 057e9f7..9d9d569 100644 --- a/backend/app/repositories/city_repository.py +++ b/backend/app/repositories/city_repository.py @@ -1 +1,6 @@ -# city_repository — SkillStat \ No newline at end of file +from app.repositories.base_repository import BaseRepository +from app.models import City + +class CityRepository(BaseRepository): + def __init__(self): + super().__init__(City) \ No newline at end of file diff --git a/backend/app/repositories/job_repository.py b/backend/app/repositories/job_repository.py index 16dc31b..551109e 100644 --- a/backend/app/repositories/job_repository.py +++ b/backend/app/repositories/job_repository.py @@ -1 +1,6 @@ -# job_repository — SkillStat \ No newline at end of file +from app.repositories.base_repository import BaseRepository +from app.models import Job + +class JobRepository(BaseRepository): + def __init__(self): + super().__init__(Job) \ No newline at end of file diff --git a/backend/app/repositories/job_skill_repository.py b/backend/app/repositories/job_skill_repository.py new file mode 100644 index 0000000..2f0dbb9 --- /dev/null +++ b/backend/app/repositories/job_skill_repository.py @@ -0,0 +1,6 @@ +from app.repositories.base_repository import BaseRepository +from app.models import JobSkill + +class JobSkillRepository(BaseRepository): + def __init__(self): + super().__init__(JobSkill) diff --git a/backend/app/repositories/skill_repository.py b/backend/app/repositories/skill_repository.py index 327bf51..6ba7033 100644 --- a/backend/app/repositories/skill_repository.py +++ b/backend/app/repositories/skill_repository.py @@ -1 +1,6 @@ -# skill_repository — SkillStat \ No newline at end of file +from app.repositories.base_repository import BaseRepository +from app.models import Skill + +class SkillRepository(BaseRepository): + def __init__(self): + super().__init__(Skill) \ No newline at end of file diff --git a/backend/app/repositories/trend_snapshot_repository.py b/backend/app/repositories/trend_snapshot_repository.py new file mode 100644 index 0000000..983be7f --- /dev/null +++ b/backend/app/repositories/trend_snapshot_repository.py @@ -0,0 +1,6 @@ +from app.repositories.base_repository import BaseRepository +from app.models import TrendSnapshot + +class TrendSnapshotRepository(BaseRepository): + def __init__(self): + super().__init__(TrendSnapshot) diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index a8a8204..33cb248 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -1 +1,6 @@ -# user_repository — SkillStat \ No newline at end of file +from app.repositories.base_repository import BaseRepository +from app.models import User + +class UserRepository(BaseRepository): + def __init__(self): + super().__init__(User) \ No newline at end of file From 88c9330a34bfff2feb2703bd65409302ffce5b37 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 9 Jun 2026 18:59:00 -0600 Subject: [PATCH 019/277] feat(repositories): expose specific repositories via __init__ --- backend/app/repositories/__init__.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py index 47c32eb..ee78759 100644 --- a/backend/app/repositories/__init__.py +++ b/backend/app/repositories/__init__.py @@ -1 +1,21 @@ -# repositories/__init__ — SkillStat \ No newline at end of file +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', +] \ No newline at end of file From 96792d840f3808d9de8f65763550e10fe7fd304d Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 9 Jun 2026 21:18:24 -0600 Subject: [PATCH 020/277] build(audit): configure black and flake8, format entire codebase --- backend/.flake8 | 8 + backend/app/__init__.py | 206 +++++++++++------- backend/app/config.py | 22 +- backend/app/controllers/__init__.py | 2 +- backend/app/models/__init__.py | 20 +- backend/app/models/alert.py | 9 +- backend/app/models/backup.py | 11 +- backend/app/models/category.py | 5 +- backend/app/models/city.py | 9 +- backend/app/models/job.py | 17 +- backend/app/models/job_skill.py | 11 +- backend/app/models/skill.py | 11 +- backend/app/models/trend_snapshot.py | 13 +- backend/app/models/user.py | 13 +- backend/app/repositories/__init__.py | 20 +- backend/app/repositories/alert_repository.py | 3 +- backend/app/repositories/backup_repository.py | 3 +- backend/app/repositories/base_repository.py | 3 +- .../app/repositories/category_repository.py | 3 +- backend/app/repositories/city_repository.py | 3 +- backend/app/repositories/job_repository.py | 3 +- .../app/repositories/job_skill_repository.py | 1 + backend/app/repositories/skill_repository.py | 3 +- backend/app/repositories/trend_repository.py | 2 +- .../repositories/trend_snapshot_repository.py | 1 + backend/app/repositories/user_repository.py | 3 +- backend/app/schemas/__init__.py | 2 +- backend/app/schemas/alert_schema.py | 2 +- backend/app/schemas/auth_schema.py | 2 +- backend/app/schemas/skill_schema.py | 2 +- backend/app/services/__init__.py | 2 +- backend/app/services/alerts_service.py | 2 +- backend/app/services/backup_service.py | 2 +- backend/app/services/ingestion_service.py | 2 +- backend/app/services/market_trends_service.py | 2 +- .../app/services/skills_extraction_service.py | 2 +- backend/app/utils/__init__.py | 2 +- backend/app/utils/errors.py | 2 +- backend/app/utils/hash.py | 2 +- backend/app/utils/response.py | 2 +- backend/app/utils/security.py | 2 +- 41 files changed, 261 insertions(+), 174 deletions(-) create mode 100644 backend/.flake8 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/app/__init__.py b/backend/app/__init__.py index 242d6dc..fff0d04 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -59,30 +59,45 @@ def _configure_jwt_errors() -> None: @jwt.expired_token_loader def expired_token(_header, _payload): - return jsonify({ - "error": { - "code": "TOKEN_EXPIRED", - "message": "El token de acceso ha expirado. Inicia sesión de nuevo.", - } - }), 401 + return ( + jsonify( + { + "error": { + "code": "TOKEN_EXPIRED", + "message": "El token de acceso ha expirado. Inicia sesión de nuevo.", + } + } + ), + 401, + ) @jwt.invalid_token_loader def invalid_token(_error): - return jsonify({ - "error": { - "code": "TOKEN_INVALID", - "message": "El token de acceso no es válido.", - } - }), 401 + return ( + jsonify( + { + "error": { + "code": "TOKEN_INVALID", + "message": "El token de acceso no es válido.", + } + } + ), + 401, + ) @jwt.unauthorized_loader def missing_token(_error): - return jsonify({ - "error": { - "code": "UNAUTHORIZED", - "message": "Se requiere un token de acceso para usar este recurso.", - } - }), 401 + return ( + jsonify( + { + "error": { + "code": "UNAUTHORIZED", + "message": "Se requiere un token de acceso para usar este recurso.", + } + } + ), + 401, + ) def _register_blueprints(app: Flask) -> None: @@ -94,10 +109,10 @@ def _register_blueprints(app: Flask) -> None: from app.controllers.alerts_bp import alerts_bp from app.controllers.admin_bp import admin_bp - app.register_blueprint(auth_bp, url_prefix="/api/auth") + 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(alerts_bp, url_prefix="/api/alerts") + app.register_blueprint(admin_bp, url_prefix="/api/admin") def _register_error_handlers(app: Flask) -> None: @@ -106,75 +121,115 @@ def _register_error_handlers(app: Flask) -> None: @app.errorhandler(400) def bad_request(_error): - return jsonify({ - "error": { - "code": "BAD_REQUEST", - "message": "La solicitud no tiene el formato correcto.", - } - }), 400 + return ( + jsonify( + { + "error": { + "code": "BAD_REQUEST", + "message": "La solicitud no tiene el formato correcto.", + } + } + ), + 400, + ) @app.errorhandler(401) def unauthorized(_error): - return jsonify({ - "error": { - "code": "UNAUTHORIZED", - "message": "Se requiere autenticación para acceder a este recurso.", - } - }), 401 + return ( + jsonify( + { + "error": { + "code": "UNAUTHORIZED", + "message": "Se requiere autenticación para acceder a este recurso.", + } + } + ), + 401, + ) @app.errorhandler(403) def forbidden(_error): - return jsonify({ - "error": { - "code": "FORBIDDEN", - "message": "No tienes permiso para realizar esta acción.", - } - }), 403 + return ( + jsonify( + { + "error": { + "code": "FORBIDDEN", + "message": "No tienes permiso para realizar esta acción.", + } + } + ), + 403, + ) @app.errorhandler(404) def not_found(_error): - return jsonify({ - "error": { - "code": "NOT_FOUND", - "message": "El recurso solicitado no existe.", - } - }), 404 + return ( + jsonify( + { + "error": { + "code": "NOT_FOUND", + "message": "El recurso solicitado no existe.", + } + } + ), + 404, + ) @app.errorhandler(405) def method_not_allowed(_error): - return jsonify({ - "error": { - "code": "METHOD_NOT_ALLOWED", - "message": "El método HTTP no está permitido para este recurso.", - } - }), 405 + return ( + jsonify( + { + "error": { + "code": "METHOD_NOT_ALLOWED", + "message": "El método HTTP no está permitido para este recurso.", + } + } + ), + 405, + ) @app.errorhandler(409) def conflict(_error): - return jsonify({ - "error": { - "code": "CONFLICT", - "message": "El recurso ya existe o hay un conflicto con el estado actual.", - } - }), 409 + return ( + jsonify( + { + "error": { + "code": "CONFLICT", + "message": "El recurso ya existe o hay un conflicto con el estado actual.", + } + } + ), + 409, + ) @app.errorhandler(422) def unprocessable_entity(_error): - return jsonify({ - "error": { - "code": "VALIDATION_ERROR", - "message": "Los datos enviados no pasaron la validación.", - } - }), 422 + return ( + jsonify( + { + "error": { + "code": "VALIDATION_ERROR", + "message": "Los datos enviados no pasaron la validación.", + } + } + ), + 422, + ) @app.errorhandler(500) def internal_error(_error): - return jsonify({ - "error": { - "code": "INTERNAL_ERROR", - "message": "Ocurrió un error interno. Por favor intenta de nuevo.", - } - }), 500 + return ( + jsonify( + { + "error": { + "code": "INTERNAL_ERROR", + "message": "Ocurrió un error interno. Por favor intenta de nuevo.", + } + } + ), + 500, + ) def _register_health_check(app: Flask) -> None: @@ -182,7 +237,12 @@ def _register_health_check(app: Flask) -> None: @app.route("/api/health") def health_check(): - return jsonify({ - "status": "ok", - "service": "SkillStat API", - }), 200 \ No newline at end of file + return ( + jsonify( + { + "status": "ok", + "service": "SkillStat API", + } + ), + 200, + ) diff --git a/backend/app/config.py b/backend/app/config.py index 3526bcc..f2e4372 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -5,9 +5,7 @@ class BaseConfig: """Configuración base compartida por todos los entornos""" - SECRET_KEY = os.environ.get( - "SECRET_KEY", "dev-insecure-key-change-in-production" - ) + SECRET_KEY = os.environ.get("SECRET_KEY", "dev-insecure-key-change-in-production") SQLALCHEMY_TRACK_MODIFICATIONS = False @@ -24,10 +22,8 @@ class BaseConfig: ) JWT_ERROR_MESSAGE_KEY = "error" - - CORS_ORIGINS = os.environ.get( - "CORS_ORIGINS", "http://localhost:5500" - ).split(",") + + CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "http://localhost:5500").split(",") # APIs externas ADZUNA_APP_ID = os.environ.get("ADZUNA_APP_ID") @@ -39,15 +35,9 @@ class BaseConfig: BACKUP_STORAGE_KEY = os.environ.get("BACKUP_STORAGE_KEY") # Scheduler - 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) - ) + 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)) class DevelopmentConfig(BaseConfig): diff --git a/backend/app/controllers/__init__.py b/backend/app/controllers/__init__.py index 653787e..0ab7975 100644 --- a/backend/app/controllers/__init__.py +++ b/backend/app/controllers/__init__.py @@ -1 +1 @@ -# controllers/__init__ — SkillStat \ No newline at end of file +# controllers/__init__ — SkillStat diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 927b57a..f99b495 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,13 +9,13 @@ from .backup import Backup __all__ = [ - 'Category', - 'City', - 'Skill', - 'Job', - 'JobSkill', - 'User', - 'Alert', - 'TrendSnapshot', - 'Backup' -] \ No newline at end of file + "Category", + "City", + "Skill", + "Job", + "JobSkill", + "User", + "Alert", + "TrendSnapshot", + "Backup", +] diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py index 89973ae..c415b43 100644 --- a/backend/app/models/alert.py +++ b/backend/app/models/alert.py @@ -1,12 +1,13 @@ from app.extensions import db from datetime import datetime, timezone + class Alert(db.Model): - __tablename__ = 'user_alerts' + __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) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + skill_id = db.Column(db.Integer, db.ForeignKey("skills.id"), nullable=False) threshold = db.Column(db.Integer, nullable=False) active = db.Column(db.Boolean, default=True, nullable=False) - created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) \ No newline at end of file + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/backup.py b/backend/app/models/backup.py index 1dc4ca6..ed10e4f 100644 --- a/backend/app/models/backup.py +++ b/backend/app/models/backup.py @@ -1,17 +1,20 @@ from app.extensions import db from datetime import datetime, timezone + class Backup(db.Model): - __tablename__ = 'backups' + __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) + 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) __table_args__ = ( - db.CheckConstraint("status IN ('PENDING', 'COMPLETED', 'FAILED')", name='chk_backups_status'), - ) \ No newline at end of file + 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 index e5197d2..3c0cd28 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -1,9 +1,10 @@ from app.extensions import db + class Category(db.Model): - __tablename__ = 'categories' + __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) \ No newline at end of file + skills = db.relationship("Skill", backref="category", lazy=True) diff --git a/backend/app/models/city.py b/backend/app/models/city.py index 65414c0..8dee8fe 100644 --- a/backend/app/models/city.py +++ b/backend/app/models/city.py @@ -1,15 +1,16 @@ from app.extensions import db + class City(db.Model): - __tablename__ = 'cities' + __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) + 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) \ No newline at end of file + jobs = db.relationship("Job", backref="city", lazy=True) + trend_snapshots = db.relationship("TrendSnapshot", backref="city", lazy=True) diff --git a/backend/app/models/job.py b/backend/app/models/job.py index d429322..f6cfdab 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -1,24 +1,29 @@ from app.extensions import db from datetime import datetime, timezone + class Job(db.Model): - __tablename__ = 'jobs' + __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) + 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) processed = db.Column(db.Boolean, default=False, nullable=False) - + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) - updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=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) \ No newline at end of file + 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 index 009ebaa..efed216 100644 --- a/backend/app/models/job_skill.py +++ b/backend/app/models/job_skill.py @@ -1,12 +1,13 @@ from app.extensions import db from datetime import datetime, timezone + class JobSkill(db.Model): - __tablename__ = 'job_skills' + __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) - + 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)) \ No newline at end of file + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/skill.py b/backend/app/models/skill.py index 1a7f586..8142b1f 100644 --- a/backend/app/models/skill.py +++ b/backend/app/models/skill.py @@ -1,14 +1,15 @@ from app.extensions import db + class Skill(db.Model): - __tablename__ = 'skills' + __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) + 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) \ No newline at end of file + 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) diff --git a/backend/app/models/trend_snapshot.py b/backend/app/models/trend_snapshot.py index 0603189..7e6c818 100644 --- a/backend/app/models/trend_snapshot.py +++ b/backend/app/models/trend_snapshot.py @@ -1,11 +1,12 @@ from app.extensions import db + class TrendSnapshot(db.Model): - __tablename__ = 'trend_snapshots' + __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=False) + skill_id = db.Column(db.Integer, db.ForeignKey("skills.id"), nullable=False) + city_id = db.Column(db.Integer, db.ForeignKey("cities.id"), nullable=False) 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) @@ -13,5 +14,7 @@ class TrendSnapshot(db.Model): # 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'), - ) \ No newline at end of file + 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 index 65f7e47..c911cbd 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,8 +1,9 @@ from app.extensions import db from datetime import datetime, timezone + class User(db.Model): - __tablename__ = 'users' + __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(255), unique=True, nullable=False) @@ -12,13 +13,15 @@ class User(db.Model): role = db.Column(db.String(20), nullable=False) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) - alerts = db.relationship('Alert', backref='user', lazy=True) - backups = db.relationship('Backup', backref='user', lazy=True) + alerts = db.relationship("Alert", backref="user", lazy=True) + backups = db.relationship("Backup", backref="user", lazy=True) # Restringimos los roles permitidos directamente en la base de datos por seguridad __table_args__ = ( - db.CheckConstraint("role IN ('GUEST', 'REGISTERED', 'ADMIN')", name='chk_users_role'), + db.CheckConstraint( + "role IN ('GUEST', 'REGISTERED', 'ADMIN')", name="chk_users_role" + ), ) def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py index ee78759..5a5e1a9 100644 --- a/backend/app/repositories/__init__.py +++ b/backend/app/repositories/__init__.py @@ -9,13 +9,13 @@ from .backup_repository import BackupRepository __all__ = [ - 'CategoryRepository', - 'CityRepository', - 'SkillRepository', - 'JobRepository', - 'JobSkillRepository', - 'UserRepository', - 'AlertRepository', - 'TrendSnapshotRepository', - 'BackupRepository', -] \ No newline at end of file + "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 index bd0e190..7814106 100644 --- a/backend/app/repositories/alert_repository.py +++ b/backend/app/repositories/alert_repository.py @@ -1,6 +1,7 @@ from app.repositories.base_repository import BaseRepository from app.models import Alert + class AlertRepository(BaseRepository): def __init__(self): - super().__init__(Alert) \ No newline at end of file + super().__init__(Alert) diff --git a/backend/app/repositories/backup_repository.py b/backend/app/repositories/backup_repository.py index cff6149..bff9aff 100644 --- a/backend/app/repositories/backup_repository.py +++ b/backend/app/repositories/backup_repository.py @@ -1,6 +1,7 @@ from app.repositories.base_repository import BaseRepository from app.models import Backup + class BackupRepository(BaseRepository): def __init__(self): - super().__init__(Backup) \ No newline at end of file + super().__init__(Backup) diff --git a/backend/app/repositories/base_repository.py b/backend/app/repositories/base_repository.py index 4f8feca..9e9e34d 100644 --- a/backend/app/repositories/base_repository.py +++ b/backend/app/repositories/base_repository.py @@ -1,5 +1,6 @@ from app.extensions import db + class BaseRepository: def __init__(self, model): self.model = model @@ -43,4 +44,4 @@ def delete(self, id): return False except Exception as e: db.session.rollback() - raise e \ No newline at end of file + raise e diff --git a/backend/app/repositories/category_repository.py b/backend/app/repositories/category_repository.py index 7fc292e..c98e55d 100644 --- a/backend/app/repositories/category_repository.py +++ b/backend/app/repositories/category_repository.py @@ -1,6 +1,7 @@ from app.repositories.base_repository import BaseRepository from app.models import Category + class CategoryRepository(BaseRepository): def __init__(self): - super().__init__(Category) \ No newline at end of file + super().__init__(Category) diff --git a/backend/app/repositories/city_repository.py b/backend/app/repositories/city_repository.py index 9d9d569..46ae0fb 100644 --- a/backend/app/repositories/city_repository.py +++ b/backend/app/repositories/city_repository.py @@ -1,6 +1,7 @@ from app.repositories.base_repository import BaseRepository from app.models import City + class CityRepository(BaseRepository): def __init__(self): - super().__init__(City) \ No newline at end of file + super().__init__(City) diff --git a/backend/app/repositories/job_repository.py b/backend/app/repositories/job_repository.py index 551109e..a18e316 100644 --- a/backend/app/repositories/job_repository.py +++ b/backend/app/repositories/job_repository.py @@ -1,6 +1,7 @@ from app.repositories.base_repository import BaseRepository from app.models import Job + class JobRepository(BaseRepository): def __init__(self): - super().__init__(Job) \ No newline at end of file + super().__init__(Job) diff --git a/backend/app/repositories/job_skill_repository.py b/backend/app/repositories/job_skill_repository.py index 2f0dbb9..4f90714 100644 --- a/backend/app/repositories/job_skill_repository.py +++ b/backend/app/repositories/job_skill_repository.py @@ -1,6 +1,7 @@ from app.repositories.base_repository import BaseRepository from app.models import JobSkill + class JobSkillRepository(BaseRepository): def __init__(self): super().__init__(JobSkill) diff --git a/backend/app/repositories/skill_repository.py b/backend/app/repositories/skill_repository.py index 6ba7033..94405a3 100644 --- a/backend/app/repositories/skill_repository.py +++ b/backend/app/repositories/skill_repository.py @@ -1,6 +1,7 @@ from app.repositories.base_repository import BaseRepository from app.models import Skill + class SkillRepository(BaseRepository): def __init__(self): - super().__init__(Skill) \ No newline at end of file + super().__init__(Skill) diff --git a/backend/app/repositories/trend_repository.py b/backend/app/repositories/trend_repository.py index 43a9d0c..0f4831d 100644 --- a/backend/app/repositories/trend_repository.py +++ b/backend/app/repositories/trend_repository.py @@ -1 +1 @@ -# trend_repository — SkillStat \ No newline at end of file +# trend_repository — SkillStat diff --git a/backend/app/repositories/trend_snapshot_repository.py b/backend/app/repositories/trend_snapshot_repository.py index 983be7f..3effd5f 100644 --- a/backend/app/repositories/trend_snapshot_repository.py +++ b/backend/app/repositories/trend_snapshot_repository.py @@ -1,6 +1,7 @@ from app.repositories.base_repository import BaseRepository from app.models import TrendSnapshot + class TrendSnapshotRepository(BaseRepository): def __init__(self): super().__init__(TrendSnapshot) diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index 33cb248..9cdeb5c 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -1,6 +1,7 @@ from app.repositories.base_repository import BaseRepository from app.models import User + class UserRepository(BaseRepository): def __init__(self): - super().__init__(User) \ No newline at end of file + super().__init__(User) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 555aee5..bf0ce38 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1 +1 @@ -# schemas/__init__ — SkillStat \ No newline at end of file +# schemas/__init__ — SkillStat diff --git a/backend/app/schemas/alert_schema.py b/backend/app/schemas/alert_schema.py index 5f1077b..82a6bab 100644 --- a/backend/app/schemas/alert_schema.py +++ b/backend/app/schemas/alert_schema.py @@ -1 +1 @@ -# alert_schema — SkillStat \ No newline at end of file +# alert_schema — SkillStat diff --git a/backend/app/schemas/auth_schema.py b/backend/app/schemas/auth_schema.py index 038d6ef..910c389 100644 --- a/backend/app/schemas/auth_schema.py +++ b/backend/app/schemas/auth_schema.py @@ -1 +1 @@ -# auth_schema — SkillStat \ No newline at end of file +# auth_schema — SkillStat diff --git a/backend/app/schemas/skill_schema.py b/backend/app/schemas/skill_schema.py index 9e5533d..e4e5a3c 100644 --- a/backend/app/schemas/skill_schema.py +++ b/backend/app/schemas/skill_schema.py @@ -1 +1 @@ -# skill_schema — SkillStat \ No newline at end of file +# skill_schema — SkillStat diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 8c95b67..4619a7b 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1 +1 @@ -# services/__init__ — SkillStat \ No newline at end of file +# services/__init__ — SkillStat diff --git a/backend/app/services/alerts_service.py b/backend/app/services/alerts_service.py index 46d7ea1..a269a5b 100644 --- a/backend/app/services/alerts_service.py +++ b/backend/app/services/alerts_service.py @@ -1 +1 @@ -# alerts_service — SkillStat \ No newline at end of file +# alerts_service — SkillStat diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index c32068f..40f0ad5 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -1 +1 @@ -# backup_service — SkillStat \ No newline at end of file +# backup_service — SkillStat diff --git a/backend/app/services/ingestion_service.py b/backend/app/services/ingestion_service.py index a6e5b53..fc85475 100644 --- a/backend/app/services/ingestion_service.py +++ b/backend/app/services/ingestion_service.py @@ -1 +1 @@ -# ingestion_service — SkillStat \ No newline at end of file +# ingestion_service — SkillStat diff --git a/backend/app/services/market_trends_service.py b/backend/app/services/market_trends_service.py index 15f1045..6c0c058 100644 --- a/backend/app/services/market_trends_service.py +++ b/backend/app/services/market_trends_service.py @@ -1 +1 @@ -# market_trends_service — SkillStat \ No newline at end of file +# market_trends_service — SkillStat diff --git a/backend/app/services/skills_extraction_service.py b/backend/app/services/skills_extraction_service.py index 3b89d95..dff0eeb 100644 --- a/backend/app/services/skills_extraction_service.py +++ b/backend/app/services/skills_extraction_service.py @@ -1 +1 @@ -# skills_extraction_service — SkillStat \ No newline at end of file +# skills_extraction_service — SkillStat diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py index 63fd2f4..c67c224 100644 --- a/backend/app/utils/__init__.py +++ b/backend/app/utils/__init__.py @@ -1 +1 @@ -# utils/__init__ — SkillStat \ No newline at end of file +# utils/__init__ — SkillStat diff --git a/backend/app/utils/errors.py b/backend/app/utils/errors.py index 1f765b9..4ab21da 100644 --- a/backend/app/utils/errors.py +++ b/backend/app/utils/errors.py @@ -1 +1 @@ -# errors — SkillStat \ No newline at end of file +# errors — SkillStat diff --git a/backend/app/utils/hash.py b/backend/app/utils/hash.py index b6f6fef..a3a0a01 100644 --- a/backend/app/utils/hash.py +++ b/backend/app/utils/hash.py @@ -1 +1 @@ -# hash — SkillStat \ No newline at end of file +# hash — SkillStat diff --git a/backend/app/utils/response.py b/backend/app/utils/response.py index ae043e4..14f2588 100644 --- a/backend/app/utils/response.py +++ b/backend/app/utils/response.py @@ -1 +1 @@ -# response — SkillStat \ No newline at end of file +# response — SkillStat diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py index d187075..e72afd4 100644 --- a/backend/app/utils/security.py +++ b/backend/app/utils/security.py @@ -1 +1 @@ -# security — SkillStat \ No newline at end of file +# security — SkillStat From 6d13ea131368845dc059abe2495f69feefb70463 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Wed, 10 Jun 2026 07:29:30 -0600 Subject: [PATCH 021/277] refactor(audit): deep clean of codebase comments, enforce WHY over WHAT convention --- CONTRIBUTING.md | 166 +++--------------- GUIA_ENTORNO.md | 73 ++++++++ INDUCCION_EQUIPO.md | 142 +++++++++++++++ backend/app/__init__.py | 5 - backend/app/config.py | 7 - backend/app/controllers/__init__.py | 1 - backend/app/controllers/admin_bp.py | 2 - backend/app/controllers/alerts_bp.py | 2 - backend/app/controllers/auth_bp.py | 2 - backend/app/controllers/panorama_bp.py | 2 - backend/app/repositories/trend_repository.py | 1 - backend/app/schemas/__init__.py | 1 - backend/app/schemas/alert_schema.py | 1 - backend/app/schemas/auth_schema.py | 1 - backend/app/schemas/skill_schema.py | 1 - backend/app/services/__init__.py | 1 - backend/app/services/alerts_service.py | 1 - backend/app/services/backup_service.py | 1 - backend/app/services/ingestion_service.py | 1 - backend/app/services/market_trends_service.py | 1 - .../app/services/skills_extraction_service.py | 1 - backend/app/utils/__init__.py | 1 - backend/app/utils/errors.py | 1 - backend/app/utils/hash.py | 1 - backend/app/utils/response.py | 1 - backend/app/utils/security.py | 1 - backend/clean.py | 80 +++++++++ backend/clean2.py | 79 +++++++++ backend/clean3.py | 78 ++++++++ backend/test_read.py | 13 ++ docs/ARQUITECTURA.md | 71 +++----- 31 files changed, 514 insertions(+), 225 deletions(-) create mode 100644 GUIA_ENTORNO.md create mode 100644 INDUCCION_EQUIPO.md create mode 100644 backend/clean.py create mode 100644 backend/clean2.py create mode 100644 backend/clean3.py create mode 100644 backend/test_read.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c823edd..fb737e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,142 +1,24 @@ -# ¿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. +# 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/GUIA_ENTORNO.md b/GUIA_ENTORNO.md new file mode 100644 index 0000000..ee7438a --- /dev/null +++ b/GUIA_ENTORNO.md @@ -0,0 +1,73 @@ +# Guía de Entorno de Desarrollo Local + +Este documento detalla los pasos exactos para levantar el proyecto SkillStat en un entorno local. Sigue las instrucciones en orden. + +## 1. Requisitos Previos +* **Python:** Versión 3.13.x o superior. +* **Base de Datos:** PostgreSQL 15. +* **Git:** Para clonar y manejar el flujo de ramas. + +## 2. Configuración de la Base de Datos +Antes de ejecutar la aplicación, debes preparar el motor de base de datos local: +1. Abre tu cliente de PostgreSQL (pgAdmin o psql). +2. Crea una base de datos vacía llamada exactamente: `skillstat_dev`. +3. Asegúrate de tener a la mano tu usuario (ej. `postgres`) y contraseña locales. + +## 3. Entorno Virtual y Dependencias +Posiciónate en la raíz del proyecto y ejecuta: + +```bash +# Entrar al directorio del backend +cd backend + +# Crear el entorno virtual +python -m venv .venv + +# Activar el entorno virtual +# En Windows: +.venv\Scripts\activate +# En macOS/Linux: +source .venv/bin/activate + +# Instalar dependencias base y de desarrollo +pip install -r requirements.txt +pip install -r requirements-dev.txt + +``` + +## 4. Variables de Entorno + +Crea un archivo llamado `.env` dentro de la carpeta `backend/` y define estrictamente las siguientes variables: + +```env +FLASK_APP=app +FLASK_ENV=development +# Reemplaza 'tu_usuario' y 'tu_contraseña' con tus credenciales locales de Postgres: +DATABASE_URL=postgresql://tu_usuario:tu_contraseña@localhost:5432/skillstat_dev + +``` + +*Nota:* El archivo `.env` está ignorado por Git por razones de seguridad. Nunca lo subas al repositorio. + +## 5. Migraciones (Alembic) + +Para que las tablas físicas de la base de datos se alineen con los modelos de SQLAlchemy, ejecuta: + +```bash +# Estando dentro de la carpeta backend/ y con el .venv activado: +flask db upgrade + +``` + +*Esto aplicará el historial de migraciones y construirá las tablas respetando la 3FN.* + +## 6. Ejecución del Servidor + +Finalmente, levanta el servicio con: + +```bash +flask run + +``` + +El backend estará disponible en `http://127.0.0.1:5000`. 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/backend/app/__init__.py b/backend/app/__init__.py index fff0d04..cb4a40a 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -9,19 +9,16 @@ def create_app(env: str = None) -> Flask: - """Crea y configura una instancia de la aplicación Flask""" # Cargamos las variables de entorno antes de leer cualquier configuración para que estén disponibles cuando se instancian las clases de config load_dotenv() app = Flask(__name__, instance_relative_config=False) - # Seleccionamos la configuración según el entorno env = env or os.environ.get("FLASK_ENV", "development") config_class = config_map.get(env, config_map["development"]) app.config.from_object(config_class) - # Verificamos que la base de datos esté configurada antes de continuar # Hacemos esta validación aquí y no en la clase de configuración para que ocurra en tiempo de ejecución real y solo cuando el entorno es producción if env == "production" and not app.config.get("SQLALCHEMY_DATABASE_URI"): raise RuntimeError( @@ -42,7 +39,6 @@ def create_app(env: str = None) -> Flask: def _init_extensions(app: Flask) -> None: - """Conecta las extensiones con la instancia de la aplicación""" db.init_app(app) jwt.init_app(app) cors.init_app( @@ -233,7 +229,6 @@ def internal_error(_error): def _register_health_check(app: Flask) -> None: - """Registra el endpoint de salud para verificar que el servicio está activo""" @app.route("/api/health") def health_check(): diff --git a/backend/app/config.py b/backend/app/config.py index f2e4372..7a00663 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -3,7 +3,6 @@ class BaseConfig: - """Configuración base compartida por todos los entornos""" SECRET_KEY = os.environ.get("SECRET_KEY", "dev-insecure-key-change-in-production") @@ -25,23 +24,19 @@ class BaseConfig: CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "http://localhost:5500").split(",") - # APIs externas ADZUNA_APP_ID = os.environ.get("ADZUNA_APP_ID") ADZUNA_APP_KEY = os.environ.get("ADZUNA_APP_KEY") SENDGRID_API_KEY = os.environ.get("SENDGRID_API_KEY") - # Almacenamiento de respaldos BACKUP_STORAGE_URL = os.environ.get("BACKUP_STORAGE_URL") BACKUP_STORAGE_KEY = os.environ.get("BACKUP_STORAGE_KEY") - # Scheduler 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)) class DevelopmentConfig(BaseConfig): - """Configuración para el entorno de desarrollo local""" DEBUG = True SQLALCHEMY_DATABASE_URI = os.environ.get( @@ -51,7 +46,6 @@ class DevelopmentConfig(BaseConfig): class ProductionConfig(BaseConfig): - """Configuración para el entorno de producción""" DEBUG = False TESTING = False @@ -66,7 +60,6 @@ class ProductionConfig(BaseConfig): class TestingConfig(BaseConfig): - """Configuración para el entorno de pruebas automatizadas""" TESTING = True DEBUG = True diff --git a/backend/app/controllers/__init__.py b/backend/app/controllers/__init__.py index 0ab7975..e69de29 100644 --- a/backend/app/controllers/__init__.py +++ b/backend/app/controllers/__init__.py @@ -1 +0,0 @@ -# controllers/__init__ — SkillStat diff --git a/backend/app/controllers/admin_bp.py b/backend/app/controllers/admin_bp.py index 32c1d60..994eacd 100644 --- a/backend/app/controllers/admin_bp.py +++ b/backend/app/controllers/admin_bp.py @@ -1,5 +1,3 @@ -# Registramos aquí todas las rutas de administración del sistema: -# gestión de usuarios, ejecución de respaldos y restauración de datos from flask import Blueprint admin_bp = Blueprint("admin", __name__) diff --git a/backend/app/controllers/alerts_bp.py b/backend/app/controllers/alerts_bp.py index 86f17ab..fafd4b8 100644 --- a/backend/app/controllers/alerts_bp.py +++ b/backend/app/controllers/alerts_bp.py @@ -1,5 +1,3 @@ -# Registramos aquí todas las rutas de gestión de alertas: -# crear, listar, actualizar y eliminar alertas del usuario autenticado from flask import Blueprint alerts_bp = Blueprint("alerts", __name__) diff --git a/backend/app/controllers/auth_bp.py b/backend/app/controllers/auth_bp.py index 989b1f7..c345e56 100644 --- a/backend/app/controllers/auth_bp.py +++ b/backend/app/controllers/auth_bp.py @@ -1,5 +1,3 @@ -# Registramos aquí todas las rutas relacionadas con autenticación: -# registro de cuenta, inicio de sesión y cierre de sesión from flask import Blueprint auth_bp = Blueprint("auth", __name__) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py index fa6ec8d..b8f2f75 100644 --- a/backend/app/controllers/panorama_bp.py +++ b/backend/app/controllers/panorama_bp.py @@ -1,5 +1,3 @@ -# Registramos aquí todas las rutas del Panorama: rankings de habilidades, -# distribución geográfica, evolución temporal y comparación entre tecnologías from flask import Blueprint panorama_bp = Blueprint("panorama", __name__) diff --git a/backend/app/repositories/trend_repository.py b/backend/app/repositories/trend_repository.py index 0f4831d..e69de29 100644 --- a/backend/app/repositories/trend_repository.py +++ b/backend/app/repositories/trend_repository.py @@ -1 +0,0 @@ -# trend_repository — SkillStat diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index bf0ce38..e69de29 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1 +0,0 @@ -# schemas/__init__ — SkillStat diff --git a/backend/app/schemas/alert_schema.py b/backend/app/schemas/alert_schema.py index 82a6bab..e69de29 100644 --- a/backend/app/schemas/alert_schema.py +++ b/backend/app/schemas/alert_schema.py @@ -1 +0,0 @@ -# alert_schema — SkillStat diff --git a/backend/app/schemas/auth_schema.py b/backend/app/schemas/auth_schema.py index 910c389..e69de29 100644 --- a/backend/app/schemas/auth_schema.py +++ b/backend/app/schemas/auth_schema.py @@ -1 +0,0 @@ -# auth_schema — SkillStat diff --git a/backend/app/schemas/skill_schema.py b/backend/app/schemas/skill_schema.py index e4e5a3c..e69de29 100644 --- a/backend/app/schemas/skill_schema.py +++ b/backend/app/schemas/skill_schema.py @@ -1 +0,0 @@ -# skill_schema — SkillStat diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 4619a7b..e69de29 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1 +0,0 @@ -# services/__init__ — SkillStat diff --git a/backend/app/services/alerts_service.py b/backend/app/services/alerts_service.py index a269a5b..e69de29 100644 --- a/backend/app/services/alerts_service.py +++ b/backend/app/services/alerts_service.py @@ -1 +0,0 @@ -# alerts_service — SkillStat diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index 40f0ad5..e69de29 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -1 +0,0 @@ -# backup_service — SkillStat diff --git a/backend/app/services/ingestion_service.py b/backend/app/services/ingestion_service.py index fc85475..e69de29 100644 --- a/backend/app/services/ingestion_service.py +++ b/backend/app/services/ingestion_service.py @@ -1 +0,0 @@ -# ingestion_service — SkillStat diff --git a/backend/app/services/market_trends_service.py b/backend/app/services/market_trends_service.py index 6c0c058..e69de29 100644 --- a/backend/app/services/market_trends_service.py +++ b/backend/app/services/market_trends_service.py @@ -1 +0,0 @@ -# market_trends_service — SkillStat diff --git a/backend/app/services/skills_extraction_service.py b/backend/app/services/skills_extraction_service.py index dff0eeb..e69de29 100644 --- a/backend/app/services/skills_extraction_service.py +++ b/backend/app/services/skills_extraction_service.py @@ -1 +0,0 @@ -# skills_extraction_service — SkillStat diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py index c67c224..e69de29 100644 --- a/backend/app/utils/__init__.py +++ b/backend/app/utils/__init__.py @@ -1 +0,0 @@ -# utils/__init__ — SkillStat diff --git a/backend/app/utils/errors.py b/backend/app/utils/errors.py index 4ab21da..e69de29 100644 --- a/backend/app/utils/errors.py +++ b/backend/app/utils/errors.py @@ -1 +0,0 @@ -# errors — SkillStat diff --git a/backend/app/utils/hash.py b/backend/app/utils/hash.py index a3a0a01..e69de29 100644 --- a/backend/app/utils/hash.py +++ b/backend/app/utils/hash.py @@ -1 +0,0 @@ -# hash — SkillStat diff --git a/backend/app/utils/response.py b/backend/app/utils/response.py index 14f2588..e69de29 100644 --- a/backend/app/utils/response.py +++ b/backend/app/utils/response.py @@ -1 +0,0 @@ -# response — SkillStat diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py index e72afd4..e69de29 100644 --- a/backend/app/utils/security.py +++ b/backend/app/utils/security.py @@ -1 +0,0 @@ -# security — SkillStat diff --git a/backend/clean.py b/backend/clean.py new file mode 100644 index 0000000..8a71cc6 --- /dev/null +++ b/backend/clean.py @@ -0,0 +1,80 @@ +import os +import re + +KEEP_PHRASES = [ + "Habilitamos nulos para", + "Establecemos México como default", + "Permitimos nulos en city_id", + "Guardamos el hash de la", + "Utilizamos llave primaria compuesta", + "Relacionamos bidireccionalmente", + "Forzamos unicidad combinada", + "Restringimos los roles permitidos", + "Protegemos la transacción con un", + "Construimos la aplicación usando el patrón application factory", + "crear instancias independientes según el", + "Cargamos las variables de entorno antes de", + "Hacemos esta validación aquí y no en la clase", + "Importamos los modelos para que SQLAlchemy", + "noqa", +] + +def should_keep(line): + for phrase in KEEP_PHRASES: + if phrase in line: + return True + return False + +def clean_file(filepath): + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + lines = content.split('\n') + new_lines = [] + modified = False + + for line in lines: + stripped = line.strip() + if stripped.startswith('#'): + if not should_keep(line): + modified = True + continue + new_lines.append(line) + + new_content = '\n'.join(new_lines) + + # Also clean up known unwanted docstrings + unwanted_docstrings = [ + ' \"\"\"Configuración base compartida por todos los entornos\"\"\"\n', + ' \"\"\"Configuración para el entorno de desarrollo local\"\"\"\n', + ' \"\"\"Configuración para el entorno de producción\"\"\"\n', + ' \"\"\"Configuración para el entorno de pruebas automatizadas\"\"\"\n', + ' \"\"\"Crea y configura una instancia de la aplicación Flask\"\"\"\n', + ' \"\"\"Conecta las extensiones con la instancia de la aplicación\"\"\"\n', + ' \"\"\"Registra el endpoint de salud para verificar que el servicio está activo\"\"\"\n' + ] + for ud in unwanted_docstrings: + if ud in new_content: + new_content = new_content.replace(ud, "") + modified = True + + # Remove consecutive blank lines + new_content = re.sub(r'\n{3,}', '\n\n', new_content) + + if modified: + with open(filepath, 'w', encoding='utf-8') as f: + f.write(new_content) + return True + return False + +app_dir = os.path.join(os.getcwd(), 'app') +modified_files = [] + +for root, _, files in os.walk(app_dir): + for file in files: + if file.endswith('.py'): + if clean_file(os.path.join(root, file)): + modified_files.append(os.path.relpath(os.path.join(root, file), os.getcwd())) + +for f in modified_files: + print(f) diff --git a/backend/clean2.py b/backend/clean2.py new file mode 100644 index 0000000..d3448c9 --- /dev/null +++ b/backend/clean2.py @@ -0,0 +1,79 @@ +import os +import re + +KEEP_PHRASES = [ + "Habilitamos nulos para", + "Establecemos México como default", + "Permitimos nulos en city_id", + "Guardamos el hash de la", + "Utilizamos llave primaria compuesta", + "Relacionamos bidireccionalmente", + "Forzamos unicidad combinada", + "Restringimos los roles permitidos", + "Protegemos la transacción con un", + "Construimos la aplicación usando el patrón application factory", + "crear instancias independientes según el", + "Cargamos las variables de entorno antes de", + "Hacemos esta validación aquí y no en la clase", + "Importamos los modelos para que SQLAlchemy", + "noqa", +] + +def clean_file(filepath): + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + lines = content.split('\n') + new_lines = [] + modified = False + + for line in lines: + stripped = line.strip() + if stripped.startswith('#'): + # Check if we should keep it + keep = False + for phrase in KEEP_PHRASES: + if phrase in line: + keep = True + break + if not keep: + modified = True + continue + new_lines.append(line) + + new_content = '\n'.join(new_lines) + + unwanted_docstrings = [ + ' \"\"\"Configuración base compartida por todos los entornos\"\"\"\n', + ' \"\"\"Configuración para el entorno de desarrollo local\"\"\"\n', + ' \"\"\"Configuración para el entorno de producción\"\"\"\n', + ' \"\"\"Configuración para el entorno de pruebas automatizadas\"\"\"\n', + ' \"\"\"Crea y configura una instancia de la aplicación Flask\"\"\"\n', + ' \"\"\"Conecta las extensiones con la instancia de la aplicación\"\"\"\n', + ' \"\"\"Registra el endpoint de salud para verificar que el servicio está activo\"\"\"\n' + ] + for ud in unwanted_docstrings: + if ud in new_content: + new_content = new_content.replace(ud, "") + modified = True + + new_content = re.sub(r'\n{3,}', '\n\n', new_content) + + if not new_content.strip(): + new_content = "" + + if modified: + with open(filepath, 'w', encoding='utf-8') as f: + f.write(new_content) + return True + return False + +app_dir = os.path.join(os.getcwd(), 'app') +for root, _, files in os.walk(app_dir): + for file in files: + if file.endswith('.py'): + filepath = os.path.join(root, file) + if clean_file(filepath): + print(f"MODIFIED: {filepath}") + else: + print(f"SKIPPED: {filepath}") diff --git a/backend/clean3.py b/backend/clean3.py new file mode 100644 index 0000000..48d41f7 --- /dev/null +++ b/backend/clean3.py @@ -0,0 +1,78 @@ +import os +import re + +KEEP_PHRASES = [ + "Habilitamos nulos para", + "Establecemos México como default", + "Permitimos nulos en city_id", + "Guardamos el hash de la", + "Utilizamos llave primaria compuesta", + "Relacionamos bidireccionalmente", + "Forzamos unicidad combinada", + "Restringimos los roles permitidos", + "Protegemos la transacción con un", + "Construimos la aplicación usando el patrón application factory", + "crear instancias independientes según el", + "Cargamos las variables de entorno antes de", + "Hacemos esta validación aquí y no en la clase", + "Importamos los modelos para que SQLAlchemy", + "noqa", +] + +def clean_file(filepath): + # Use utf-8-sig to handle files with or without BOM + with open(filepath, 'r', encoding='utf-8-sig') as f: + content = f.read() + + lines = content.split('\n') + new_lines = [] + modified = False + + for line in lines: + stripped = line.strip() + if stripped.startswith('#'): + # Check if we should keep it + keep = False + for phrase in KEEP_PHRASES: + if phrase in line: + keep = True + break + if not keep: + modified = True + continue + new_lines.append(line) + + new_content = '\n'.join(new_lines) + + unwanted_docstrings = [ + ' \"\"\"Configuración base compartida por todos los entornos\"\"\"\n', + ' \"\"\"Configuración para el entorno de desarrollo local\"\"\"\n', + ' \"\"\"Configuración para el entorno de producción\"\"\"\n', + ' \"\"\"Configuración para el entorno de pruebas automatizadas\"\"\"\n', + ' \"\"\"Crea y configura una instancia de la aplicación Flask\"\"\"\n', + ' \"\"\"Conecta las extensiones con la instancia de la aplicación\"\"\"\n', + ' \"\"\"Registra el endpoint de salud para verificar que el servicio está activo\"\"\"\n' + ] + for ud in unwanted_docstrings: + if ud in new_content: + new_content = new_content.replace(ud, "") + modified = True + + new_content = re.sub(r'\n{3,}', '\n\n', new_content) + + if not new_content.strip(): + new_content = "" + + if modified: + with open(filepath, 'w', encoding='utf-8') as f: + f.write(new_content) + return True + return False + +app_dir = os.path.join(os.getcwd(), 'app') +for root, _, files in os.walk(app_dir): + for file in files: + if file.endswith('.py'): + filepath = os.path.join(root, file) + if clean_file(filepath): + print(f"MODIFIED: {os.path.relpath(filepath, os.getcwd())}") diff --git a/backend/test_read.py b/backend/test_read.py new file mode 100644 index 0000000..14ee8d6 --- /dev/null +++ b/backend/test_read.py @@ -0,0 +1,13 @@ +import os +import traceback + +app_dir = os.path.join(os.getcwd(), 'app') +for root, _, files in os.walk(app_dir): + for file in files: + if file.endswith('.py'): + filepath = os.path.join(root, file) + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + except Exception as e: + print(f"Error reading {filepath}: {e}") diff --git a/docs/ARQUITECTURA.md b/docs/ARQUITECTURA.md index bcb4340..1a4c315 100644 --- a/docs/ARQUITECTURA.md +++ b/docs/ARQUITECTURA.md @@ -1,46 +1,25 @@ -# Arquitectura de SkillStat - -SkillStat es una plataforma de inteligencia de mercado laboral que -recopila vacantes tecnologicas, extrae habilidades con procesamiento -de lenguaje natural y presenta los resultados en un dashboard analitico -llamado Panorama. - -## Como esta organizado el repositorio - -``` -SkillStat/ -├── frontend/ # Interfaz web: vistas, estilos y logica del cliente -├── backend/ # API REST, servicios, modelos y acceso a datos -├── data/ # Activos de datos para el procesamiento NLP -├── scripts/ # Herramientas de uso manual para el equipo -└── docs/ # Documentacion interna del proyecto -``` - -## Las capas del backend - -El backend sigue una arquitectura en capas donde cada capa tiene una -responsabilidad clara y no invade la del resto: - -| Capa | Donde vive | Que hace | -|------|-----------|----------| -| Controladores | `backend/app/controllers/` | Recibe peticiones HTTP y devuelve respuestas | -| Servicios | `backend/app/services/` | Contiene la logica de negocio del sistema | -| Repositorios | `backend/app/repositories/` | Habla con la base de datos | -| Modelos | `backend/app/models/` | Define la estructura de los datos | -| Clientes | `backend/clients/` | Se comunica con APIs externas | - -Los controladores llaman a los servicios. Los servicios llaman a los -repositorios. Los repositorios hablan con la base de datos. Los clientes -son usados por los servicios para comunicarse con el exterior. - -## Donde encontrar cada cosa - -- Las rutas de la API viven en `backend/app/controllers/` -- La logica que procesa vacantes y extrae habilidades vive en `backend/app/services/` -- Las tablas de la base de datos estan definidas en `backend/app/models/` -- Las consultas a la base de datos viven en `backend/app/repositories/` -- Las conexiones con Adzuna, Nominatim y SendGrid viven en `backend/clients/` -- Las vistas HTML del Panorama y el resto de pantallas viven en `frontend/views/` -- Los archivos de estilos estan en `frontend/assets/css/` -- La logica del cliente esta en `frontend/assets/js/` -- El diccionario de habilidades tecnologicas vive en `data/dictionaries/` +# 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. From 3067f3fa19e72d75a5888fcb2005d0943d174028 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Wed, 10 Jun 2026 20:51:26 -0600 Subject: [PATCH 022/277] feat(utils): implement core cryptography and jwt token generation --- backend/app/utils/hash.py | 11 +++++++++++ backend/app/utils/security.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/backend/app/utils/hash.py b/backend/app/utils/hash.py index e69de29..0e12b5e 100644 --- a/backend/app/utils/hash.py +++ 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/security.py b/backend/app/utils/security.py index e69de29..0599a28 100644 --- a/backend/app/utils/security.py +++ b/backend/app/utils/security.py @@ -0,0 +1,17 @@ +from datetime import timedelta +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) + access_token = create_access_token( + identity=str(user_id), + additional_claims={"role": role}, + expires_delta=timedelta(hours=2) + ) + + return { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": 7200 + } From 80f9549ec3812f0eb6ee150c495d568452a67195 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Wed, 10 Jun 2026 20:57:10 -0600 Subject: [PATCH 023/277] feat(utils): implement uniform json responses and custom business exceptions --- backend/app/utils/errors.py | 24 ++++++++++++++++++++++++ backend/app/utils/response.py | 22 ++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/backend/app/utils/errors.py b/backend/app/utils/errors.py index e69de29..bc45b02 100644 --- a/backend/app/utils/errors.py +++ b/backend/app/utils/errors.py @@ -0,0 +1,24 @@ +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): + super().__init__(message) + self.message = message + self.code = code + self.status_code = status_code + +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/response.py b/backend/app/utils/response.py index e69de29..c6f1f36 100644 --- a/backend/app/utils/response.py +++ 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 From 88ac2b7fa2b8602423b036b1f6944180954e7e60 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 11 Jun 2026 12:31:56 -0600 Subject: [PATCH 024/277] feat(clients): implement adzuna and nominatim api wrappers --- backend/app/clients/adzuna_client.py | 33 ++++++++++++++++++++++++ backend/app/clients/nominatim_client.py | 34 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 backend/app/clients/adzuna_client.py create mode 100644 backend/app/clients/nominatim_client.py 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..7d98cbc --- /dev/null +++ b/backend/app/clients/nominatim_client.py @@ -0,0 +1,34 @@ +import requests +from app.utils.errors import AppError + +class NominatimClient: + # Aislamos el servicio de geocodificación. Nominatim exige un User-Agent válido por sus políticas de uso libre, de lo contrario bloquea la petición. + BASE_URL = "https://nominatim.openstreetmap.org/search" + + @classmethod + def geocode(cls, city_name: str, country: str = "Mexico") -> dict: + params = { + "city": city_name, + "country": country, + "format": "json", + "limit": 1 + } + headers = { + "User-Agent": "SkillStat/1.0 (Student Project UTCJ)" + } + + try: + response = requests.get(cls.BASE_URL, params=params, headers=headers, timeout=10) + response.raise_for_status() + data = response.json() + + if not data: + return None + + # Devolvemos solo latitud y longitud para mantener el contrato de datos simple + return { + "lat": float(data[0]["lat"]), + "lon": float(data[0]["lon"]) + } + except requests.RequestException as e: + raise AppError(f"Error de geocodificación en Nominatim: {str(e)}", code="EXTERNAL_API_ERROR") From 45b85d2c13e8498bd1aad305cc54a7940b60ca2d Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 11 Jun 2026 12:36:09 -0600 Subject: [PATCH 025/277] feat(clients): implement sendgrid email wrapper and finalize api clients --- backend/app/clients/__init__.py | 1 + backend/app/clients/sendgrid_client.py | 31 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 backend/app/clients/__init__.py create mode 100644 backend/app/clients/sendgrid_client.py 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/sendgrid_client.py b/backend/app/clients/sendgrid_client.py new file mode 100644 index 0000000..23b74e5 --- /dev/null +++ b/backend/app/clients/sendgrid_client.py @@ -0,0 +1,31 @@ +from sendgrid import SendGridAPIClient +from sendgrid.helpers.mail import Mail +from flask import current_app +from app.utils.errors import AppError + +class EmailClient: + # Envolvemos el SDK de SendGrid. Si en el futuro cambiamos a AWS SES o Mailgun, + # los servicios del sistema no tendrán que ser modificados, limitando el impacto a esta clase. + + @classmethod + def send_alert_email(cls, to_email: str, subject: str, html_content: str) -> bool: + api_key = current_app.config.get("SENDGRID_API_KEY") + from_email = current_app.config.get("MAIL_DEFAULT_SENDER", "noreply@skillstat.com") + + if not api_key: + raise AppError("Clave de API de SendGrid no configurada en el entorno.", status_code=500) + + message = Mail( + from_email=from_email, + to_emails=to_email, + subject=subject, + html_content=html_content + ) + + try: + sg = SendGridAPIClient(api_key) + response = sg.send(message) + # SendGrid devuelve 202 (Accepted) cuando encola el correo correctamente para su envío + return str(response.status_code).startswith("20") + except Exception as e: + raise AppError(f"Fallo de comunicación con el proveedor de correo: {str(e)}", code="EXTERNAL_API_ERROR") From 2f2d3f056066a3c872f2229d132f242debd979ea Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 11 Jun 2026 19:14:57 -0600 Subject: [PATCH 026/277] feat(data): implement script to build esco skills dictionary for nlp --- data/dictionaries/skills_esco.jsonl | 62 ++++++++++++++++++++++++++++- scripts/build_dictionary.py | 40 ++++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/data/dictionaries/skills_esco.jsonl b/data/dictionaries/skills_esco.jsonl index ad47dbb..5e854bb 100644 --- a/data/dictionaries/skills_esco.jsonl +++ b/data/dictionaries/skills_esco.jsonl @@ -1 +1,61 @@ -[] \ No newline at end of file +{"label": "SKILL", "pattern": "Python"} +{"label": "SKILL", "pattern": "JavaScript"} +{"label": "SKILL", "pattern": "Java"} +{"label": "SKILL", "pattern": "C++"} +{"label": "SKILL", "pattern": "C#"} +{"label": "SKILL", "pattern": "Ruby"} +{"label": "SKILL", "pattern": "PHP"} +{"label": "SKILL", "pattern": "Go"} +{"label": "SKILL", "pattern": "Swift"} +{"label": "SKILL", "pattern": "Kotlin"} +{"label": "SKILL", "pattern": "TypeScript"} +{"label": "SKILL", "pattern": "Rust"} +{"label": "SKILL", "pattern": "SQL"} +{"label": "SKILL", "pattern": "NoSQL"} +{"label": "SKILL", "pattern": "PostgreSQL"} +{"label": "SKILL", "pattern": "MySQL"} +{"label": "SKILL", "pattern": "MongoDB"} +{"label": "SKILL", "pattern": "Redis"} +{"label": "SKILL", "pattern": "React"} +{"label": "SKILL", "pattern": "Angular"} +{"label": "SKILL", "pattern": "Vue"} +{"label": "SKILL", "pattern": "Node.js"} +{"label": "SKILL", "pattern": "Express"} +{"label": "SKILL", "pattern": "Django"} +{"label": "SKILL", "pattern": "Flask"} +{"label": "SKILL", "pattern": "FastAPI"} +{"label": "SKILL", "pattern": "Spring Boot"} +{"label": "SKILL", "pattern": "Laravel"} +{"label": "SKILL", "pattern": "Docker"} +{"label": "SKILL", "pattern": "Kubernetes"} +{"label": "SKILL", "pattern": "AWS"} +{"label": "SKILL", "pattern": "Azure"} +{"label": "SKILL", "pattern": "GCP"} +{"label": "SKILL", "pattern": "Google Cloud"} +{"label": "SKILL", "pattern": "Linux"} +{"label": "SKILL", "pattern": "Git"} +{"label": "SKILL", "pattern": "CI/CD"} +{"label": "SKILL", "pattern": "Jenkins"} +{"label": "SKILL", "pattern": "Terraform"} +{"label": "SKILL", "pattern": "Ansible"} +{"label": "SKILL", "pattern": "Machine Learning"} +{"label": "SKILL", "pattern": "Data Science"} +{"label": "SKILL", "pattern": "Artificial Intelligence"} +{"label": "SKILL", "pattern": "Deep Learning"} +{"label": "SKILL", "pattern": "NLP"} +{"label": "SKILL", "pattern": "TensorFlow"} +{"label": "SKILL", "pattern": "PyTorch"} +{"label": "SKILL", "pattern": "Pandas"} +{"label": "SKILL", "pattern": "NumPy"} +{"label": "SKILL", "pattern": "Scikit-learn"} +{"label": "SKILL", "pattern": "HTML"} +{"label": "SKILL", "pattern": "CSS"} +{"label": "SKILL", "pattern": "Tailwind"} +{"label": "SKILL", "pattern": "Bootstrap"} +{"label": "SKILL", "pattern": "Sass"} +{"label": "SKILL", "pattern": "GraphQL"} +{"label": "SKILL", "pattern": "REST API"} +{"label": "SKILL", "pattern": "Microservices"} +{"label": "SKILL", "pattern": "Agile"} +{"label": "SKILL", "pattern": "Scrum"} +{"label": "SKILL", "pattern": "Kanban"} diff --git a/scripts/build_dictionary.py b/scripts/build_dictionary.py index a2c5ab9..99a351e 100644 --- a/scripts/build_dictionary.py +++ b/scripts/build_dictionary.py @@ -1 +1,39 @@ -# build_dictionary — SkillStat \ No newline at end of file +import json +import os + +# Resoluciones de rutas absolutas para garantizar que el script pueda +# ejecutarse desde cualquier directorio sin romper los paths relativos. +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") + +# Semilla de conocimientos tecnológicos +CORE_SKILLS = [ + "Python", "JavaScript", "Java", "C++", "C#", "Ruby", "PHP", "Go", "Swift", "Kotlin", + "TypeScript", "Rust", "SQL", "NoSQL", "PostgreSQL", "MySQL", "MongoDB", "Redis", + "React", "Angular", "Vue", "Node.js", "Express", "Django", "Flask", "FastAPI", + "Spring Boot", "Laravel", "Docker", "Kubernetes", "AWS", "Azure", "GCP", + "Google Cloud", "Linux", "Git", "CI/CD", "Jenkins", "Terraform", "Ansible", + "Machine Learning", "Data Science", "Artificial Intelligence", "Deep Learning", + "NLP", "TensorFlow", "PyTorch", "Pandas", "NumPy", "Scikit-learn", + "HTML", "CSS", "Tailwind", "Bootstrap", "Sass", "GraphQL", "REST API", + "Microservices", "Agile", "Scrum", "Kanban" +] + +def build_dictionary(): + os.makedirs(DICT_DIR, exist_ok=True) + print(f"Construyendo diccionario de conocimientos NLP en: {OUTPUT_FILE}") + + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: + for skill in CORE_SKILLS: + # Formateamos estrictamente bajo el estándar JSONL que spaCy requiere para inyectar reglas deterministas en el EntityRuler. + entry = { + "label": "SKILL", + "pattern": skill + } + f.write(json.dumps(entry) + "\n") + + print(f"Éxito: {len(CORE_SKILLS)} habilidades exportadas y listas para EntityRuler.") + +if __name__ == "__main__": + build_dictionary() \ No newline at end of file From 4e03bdfedbe3443ec733c0033eceff3ab8e9f9e7 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 11 Jun 2026 19:29:21 -0600 Subject: [PATCH 027/277] feat(nlp): implement spacy singleton service with pre-loaded esco dictionary --- .../app/services/skills_extraction_service.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/backend/app/services/skills_extraction_service.py b/backend/app/services/skills_extraction_service.py index e69de29..608f107 100644 --- a/backend/app/services/skills_extraction_service.py +++ b/backend/app/services/skills_extraction_service.py @@ -0,0 +1,43 @@ +import os +import spacy +from app.utils.errors import AppError + +BASE_DIR = 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: + # Desactivamos los componentes pesados (ner, parser, tagger) que no usamos para acelerar masivamente el procesamiento de las descripciones. + 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}") + + # Inyectamos nuestro propio EntityRuler con los datos extraídos de ESCO + ruler = nlp.add_pipe("entity_ruler") + ruler.from_disk(DICT_PATH) +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) From d452de2ab6afa89847b49218e0627d47abd05eb8 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 11 Jun 2026 19:35:13 -0600 Subject: [PATCH 028/277] feat(services): implement core ingestion pipeline with n+1 caching and idempotency --- backend/app/services/ingestion_service.py | 95 +++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/backend/app/services/ingestion_service.py b/backend/app/services/ingestion_service.py index e69de29..d439b0a 100644 --- a/backend/app/services/ingestion_service.py +++ b/backend/app/services/ingestion_service.py @@ -0,0 +1,95 @@ +import hashlib +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.utils.errors import AppError + +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 = "IT", pages: int = 1) -> dict: + stats = {"fetched": 0, "processed": 0, "skipped_or_failed": 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) + except AppError: + # Detenemos paginación si la API externa falla, preservando lo que ya se haya procesado en iteraciones anteriores. + break + + for item in results: + cls._process_job(item, known_skills, stats) + + return stats + + @classmethod + def _process_job(cls, item: dict, known_skills: dict, stats: dict) -> None: + description = item.get("description", "") + if not description: + stats["skipped_or_failed"] += 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() + + title = item.get("title", "Desconocido") + company = item.get("company", {}).get("display_name", "Confidencial") + url = item.get("redirect_url", "") + + # Guardamos sin location estricta hasta integrar Nominatim, determinando la bandera remote de forma aislada. + is_remote = "remote" in str(item).lower() or "remoto" in str(item).lower() + + job_data = { + "title": title[:200], + "company": company[:200], + "description": description, + "url": url, + "description_hash": desc_hash, + "remote": is_remote + } + + job = JobRepository.create(job_data) + if not job: + # El repositorio atrapó un error SQL (generalmente violación de UNIQUE del hash) + stats["skipped_or_failed"] += 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 + JobSkillRepository.create({ + "job_id": job.id, + "skill_id": skill_id, + "confidence_score": 0.95 + }) + + stats["processed"] += 1 + + @classmethod + def _get_or_create_skill(cls, skill_name: str, known_skills: dict): + # Mantenemos una única fuente de verdad 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() + }) + + if new_skill: + known_skills[skill_key] = new_skill.id + return new_skill.id + + return None From 6e6dcdaff7d23ab6aa3d6a1fb6b5dee598348046 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 11 Jun 2026 19:37:56 -0600 Subject: [PATCH 029/277] feat(services): implement pandas vectorial engine for market trends aggregation --- backend/app/services/market_trends_service.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/backend/app/services/market_trends_service.py b/backend/app/services/market_trends_service.py index e69de29..205180b 100644 --- a/backend/app/services/market_trends_service.py +++ b/backend/app/services/market_trends_service.py @@ -0,0 +1,45 @@ +import pandas as pd +from datetime import datetime, timezone +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_all() + + if not raw_data: + return 0 + + # Transformación a DataFrame para aprovechar las rutinas en C subyacentes de pandas, erradicando la necesidad de bucles for anidados en Python puro. + df = pd.DataFrame([{ + "skill_id": item.skill_id, + "job_id": item.job_id, + "confidence": item.confidence_score + } for item in raw_data]) + + # Agrupación y conteo vectorial. + # Extraemos el volumen de demanda absoluto por habilidad tecnológica. + trends = df.groupby("skill_id").size().reset_index(name="demand_count") + + today = datetime.now(timezone.utc).date() + snapshots_created = 0 + + for _, row in trends.iterrows(): + # Volcamos las métricas agregadas a la tabla de snapshots para que el endpoint del Panorama realice lecturas directas en lugar de recalcular. + snapshot_data = { + "skill_id": int(row["skill_id"]), + "city_id": None, + "date": today, + "demand_count": int(row["demand_count"]) + } + + # Delegamos al Repositorio el manejo de la restricción UNIQUE(skill_id, city_id, date) mediante los bloques try/except con rollback previamente configurados. + result = TrendSnapshotRepository.create(snapshot_data) + if result: + snapshots_created += 1 + + return snapshots_created From f737f15cd07c87dd6f813a4a2d5d2e016611de7f Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 11 Jun 2026 19:43:12 -0600 Subject: [PATCH 030/277] feat(services): implement operational alerts and secure backup routines --- backend/app/services/alerts_service.py | 49 +++++++++++++++++ backend/app/services/backup_service.py | 74 ++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/backend/app/services/alerts_service.py b/backend/app/services/alerts_service.py index e69de29..835de77 100644 --- a/backend/app/services/alerts_service.py +++ b/backend/app/services/alerts_service.py @@ -0,0 +1,49 @@ +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.clients.sendgrid_client import EmailClient +from app.utils.errors import AppError + +class AlertsService: + # Aísla la lógica de evaluación de notificaciones. Su diseño permite ser ejecutado de forma asíncrona (ej. Celery/APScheduler) sin bloquear la ingesta principal. + + @classmethod + def evaluate_and_notify(cls) -> int: + active_alerts = AlertRepository.get_all() + if not active_alerts: + return 0 + + notifications_sent = 0 + + for alert in active_alerts: + # Evaluamos contra el snapshot más reciente para determinar si se superó el umbral + latest_trend = TrendSnapshotRepository.get_latest_by_skill(alert.skill_id) + + if not latest_trend or latest_trend.demand_count < alert.threshold_value: + 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 = 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

+ """ + + try: + # Delegamos el envío al cliente Wrapper. + # Atrapamos errores para evitar que el fallo de un email cancele el ciclo entero. + success = EmailClient.send_alert_email(user.email, subject, html_content) + if success: + notifications_sent += 1 + except AppError: + continue + + return notifications_sent diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index e69de29..8f8bf55 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -0,0 +1,74 @@ +import os +import subprocess +from datetime import datetime +from flask import current_app +from app.repositories.backup_repository import BackupRepository +from app.utils.errors import AppError + +class BackupService: + # Encapsula la ejecución de comandos del sistema operativo (pg_dump). + # Requisito obligatorio de infraestructura y recuperación. + + @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) + + # Generamos un nombre de archivo unívoco por estampa de tiempo + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"skillstat_backup_{timestamp}.sql" + + # Resolvemos ruta absoluta para evitar que el dump caiga en un directorio volátil + 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) + + # Inyectamos los metadatos de la operación en estado 'pending' + backup_record = BackupRepository.create({ + "filename": filename, + "filepath": filepath, + "status": "pending", + "requested_by": requested_by + }) + + try: + # Parseamos la URL asumiendo formato estandar SQLAlchemy: postgresql://user:pass@host:port/db + credentials, location = db_url.replace("postgresql://", "").split("@") + user, password = credentials.split(":") + host_port, db_name = location.split("/") + + host = host_port.split(":")[0] + port = host_port.split(":")[1] if ":" in host_port else "5432" + + # Inyectar PGPASSWORD en el entorno es la única forma segura de autenticar pg_dump sin exponer credenciales en el historial de comandos del sistema operativo. + env = os.environ.copy() + env["PGPASSWORD"] = password + + command = [ + "pg_dump", + "-h", host, + "-p", port, + "-U", user, + "-F", "c", # Formato custom (comprimido binario) para optimizar I/O + "-f", filepath, + db_name + ] + + process = subprocess.run(command, env=env, capture_output=True, text=True, check=True) + + # Actualizamos registro a 'completed' con peso real del archivo + file_size = os.path.getsize(filepath) + 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: + BackupRepository.update(backup_record.id, {"status": "failed"}) + raise AppError(f"Fallo en ejecución de pg_dump: {e.stderr}", code="BACKUP_ERROR") + except Exception as e: + BackupRepository.update(backup_record.id, {"status": "failed"}) + raise AppError(f"Error interno durante respaldo: {str(e)}", code="BACKUP_ERROR") From caedea39fc78158dfa72df1f96dbe64e70e8532d Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 12 Jun 2026 18:21:42 -0600 Subject: [PATCH 031/277] feat(schemas): implement auth schemas for input validation and secure serialization --- backend/app/schemas/__init__.py | 1 + backend/app/schemas/auth_schema.py | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index e69de29..a6131c1 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +# init diff --git a/backend/app/schemas/auth_schema.py b/backend/app/schemas/auth_schema.py index e69de29..c862ea1 100644 --- a/backend/app/schemas/auth_schema.py +++ b/backend/app/schemas/auth_schema.py @@ -0,0 +1,27 @@ +from marshmallow import Schema, fields, validate, pre_load + +class UserRegistrationSchema(Schema): + # Establecemos restricciones estrictas de longitud y formato desde el borde del sistema para rechazar ataques de inyección o payloads masivos antes de que consuman ciclos de procesamiento o interactúen con la BD. + 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), error_messages={"required": "La contraseña es obligatoria."}) + name = fields.String(required=True, validate=validate.Length(min=2, max=100), error_messages={"required": "El nombre es obligatorio."}) + + @pre_load + def format_input(self, data, **kwargs): + # Normalizamos el correo a minúsculas para evitar colisiones de unicidad en PostgreSQL provocadas por capitalización inconsistente del usuario. + if "email" in data and isinstance(data["email"], str): + data["email"] = data["email"].lower().strip() + return data + +class UserLoginSchema(Schema): + # Para el login solo requerimos presencia, no validamos longitud de contraseña aquí para no dar pistas a posibles atacantes sobre nuestras políticas internas. + 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): + # Plantilla de serialización de salida. Omitimos explícitamente el campo 'password' en la definición para garantizar que el hash criptográfico jamás se filtre al frontend. + id = fields.Integer(dump_only=True) + email = fields.Email(dump_only=True) + name = fields.String(dump_only=True) + role = fields.String(dump_only=True) + created_at = fields.DateTime(dump_only=True) From 68ad17952f788e732b7139898933e31dee839e2b Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 12 Jun 2026 18:40:06 -0600 Subject: [PATCH 032/277] feat(schemas): implement strict validation contracts for alerts and read-only skill schemas --- backend/app/schemas/alert_schema.py | 23 +++++++++++++++++++++++ backend/app/schemas/skill_schema.py | 8 ++++++++ 2 files changed, 31 insertions(+) diff --git a/backend/app/schemas/alert_schema.py b/backend/app/schemas/alert_schema.py index e69de29..4037d7c 100644 --- a/backend/app/schemas/alert_schema.py +++ b/backend/app/schemas/alert_schema.py @@ -0,0 +1,23 @@ +from marshmallow import Schema, fields, validate + +class AlertRequestSchema(Schema): + # Validamos estrictamente que el threshold sea un número positivo. + # Evitamos que un usuario malintencionado o un error de UI envíe valores negativos que corrompan la lógica de evaluación en el servicio. + 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."}) + threshold_value = fields.Integer( + required=True, + strict=True, + validate=validate.Range(min=1), + error_messages={ + "required": "El umbral es obligatorio.", + "validator_failed": "El umbral debe ser mayor a 0.", + "invalid": "El umbral debe ser un número entero." + } + ) + +class AlertResponseSchema(Schema): + # Exponemos la estructura de la alerta al frontend. + id = fields.Integer(dump_only=True) + skill_id = fields.Integer(dump_only=True) + threshold_value = fields.Integer(dump_only=True) + created_at = fields.DateTime(dump_only=True) diff --git a/backend/app/schemas/skill_schema.py b/backend/app/schemas/skill_schema.py index e69de29..84a47d9 100644 --- a/backend/app/schemas/skill_schema.py +++ 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) From 2314128c6a3e0854f2401cedb6a3b2e27ca9d4ee Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 12 Jun 2026 19:00:40 -0600 Subject: [PATCH 033/277] feat(controllers): implement auth blueprint with schema validation and role injection --- backend/app/controllers/auth_bp.py | 62 +++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/backend/app/controllers/auth_bp.py b/backend/app/controllers/auth_bp.py index c345e56..8cf80dd 100644 --- a/backend/app/controllers/auth_bp.py +++ b/backend/app/controllers/auth_bp.py @@ -1,3 +1,61 @@ -from flask import Blueprint +from flask import Blueprint, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from marshmallow import ValidationError -auth_bp = Blueprint("auth", __name__) +from app.schemas.auth_schema import UserRegistrationSchema, UserLoginSchema, UserResponseSchema +from app.repositories.user_repository import UserRepository +from app.utils.hash import hash_password, verify_password +from app.utils.security import generate_tokens +from app.utils.response import success_response, error_response + +auth_bp = Blueprint("auth_bp", __name__) + +@auth_bp.route("/register", methods=["POST"]) +def register(): + # Validación de Entrada + try: + data = UserRegistrationSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + + # Verificación de conflictos + if UserRepository.get_by_email(data["email"]): + return error_response(code="CONFLICT", message="El correo ya está registrado.", status_code=409) + + # Preparación y persistencia + data["password"] = hash_password(data["password"]) + data["role"] = "REGISTERED" + + user = UserRepository.create(data) + + # Validación de Salida + result = UserResponseSchema().dump(user) + return success_response(data=result, status_code=201) + +@auth_bp.route("/login", methods=["POST"]) +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) + + user = UserRepository.get_by_email(data["email"]) + if not user or not verify_password(data["password"], user.password): + return error_response(code="UNAUTHORIZED", message="Credenciales incorrectas.", status_code=401) + + # Generación de token JWT con rol inyectado + tokens = generate_tokens(user_id=user.id, role=user.role) + return success_response(data=tokens, status_code=200) + +@auth_bp.route("/me", methods=["GET"]) +@jwt_required() +def get_profile(): + # Extraemos el ID del usuario del token JWT validado por la extensión + user_id = get_jwt_identity() + user = UserRepository.get_by_id(int(user_id)) + + if not user: + return error_response(code="NOT_FOUND", message="Usuario no encontrado.", status_code=404) + + result = UserResponseSchema().dump(user) + return success_response(data=result, status_code=200) From fd2710e49940ee0d3f37d5f4633cfdfd2c391c1b Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 12 Jun 2026 19:28:46 -0600 Subject: [PATCH 034/277] feat(controllers): implement panorama and alerts endpoints with strict resource ownership via jwt --- backend/app/controllers/alerts_bp.py | 52 +++++++++++++++++++++++++- backend/app/controllers/panorama_bp.py | 12 +++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/backend/app/controllers/alerts_bp.py b/backend/app/controllers/alerts_bp.py index fafd4b8..d22bb45 100644 --- a/backend/app/controllers/alerts_bp.py +++ b/backend/app/controllers/alerts_bp.py @@ -1,3 +1,51 @@ -from flask import Blueprint +from flask import Blueprint, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from marshmallow import ValidationError -alerts_bp = Blueprint("alerts", __name__) +from app.schemas.alert_schema import AlertRequestSchema, AlertResponseSchema +from app.repositories.alert_repository import AlertRepository +from app.utils.response import success_response, error_response + +alerts_bp = Blueprint("alerts_bp", __name__) + +@alerts_bp.route("/", methods=["POST"]) +@jwt_required() +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() + + # 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()) + + # Filtramos en memoria para garantizar que el usuario actual no tenga visibilidad sobre configuraciones ajenas. + all_alerts = AlertRepository.get_all() + user_alerts = [a for a in all_alerts if a.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) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py index b8f2f75..49b561c 100644 --- a/backend/app/controllers/panorama_bp.py +++ b/backend/app/controllers/panorama_bp.py @@ -1,3 +1,13 @@ from flask import Blueprint +from app.repositories.skill_repository import SkillRepository +from app.schemas.skill_schema import SkillResponseSchema +from app.utils.response import success_response -panorama_bp = Blueprint("panorama", __name__) +panorama_bp = Blueprint("panorama_bp", __name__) + +@panorama_bp.route("/skills", methods=["GET"]) +def get_skills(): + # Exponemos el catálogo estático aplicando el esquema de solo lectura para alimentar los selectores de la interfaz sin filtrar metadatos internos. + skills = SkillRepository.get_all() + result = SkillResponseSchema(many=True).dump(skills) + return success_response(data=result, status_code=200) From 62d11f6882f8bd01d6c2d43e6c6fc3b00cd12d61 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 12 Jun 2026 19:48:25 -0600 Subject: [PATCH 035/277] feat(controllers): implement admin blueprint with strict rbac and operational triggers --- backend/app/controllers/admin_bp.py | 42 +++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/backend/app/controllers/admin_bp.py b/backend/app/controllers/admin_bp.py index 994eacd..6bd4ddd 100644 --- a/backend/app/controllers/admin_bp.py +++ b/backend/app/controllers/admin_bp.py @@ -1,3 +1,41 @@ -from flask import Blueprint +from flask import Blueprint, request +from flask_jwt_extended import jwt_required, get_jwt, get_jwt_identity -admin_bp = Blueprint("admin", __name__) +from app.services.ingestion_service import IngestionService +from app.services.backup_service import BackupService +from app.utils.response import success_response, error_response + +admin_bp = Blueprint("admin_bp", __name__) + +def admin_required(): + # Helper local para evaluar los claims del JWT inyectados durante el login. + # Garantiza que incluso un token válido sea rechazado si carece del privilegio necesario. + claims = get_jwt() + return claims.get("role") == "ADMIN" + +@admin_bp.route("/ingest", methods=["POST"]) +@jwt_required() +def trigger_ingestion(): + if not admin_required(): + return error_response(code="FORBIDDEN", message="Privilegios de administrador requeridos.", status_code=403) + + payload = request.get_json() or {} + pages = payload.get("pages", 1) + + # Invocamos el 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() +def trigger_backup(): + if not admin_required(): + return error_response(code="FORBIDDEN", message="Privilegios de administrador requeridos.", status_code=403) + + 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) From 07e648fd9c4bc76c9996f0ce1a2ede8c46a7c051 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 12 Jun 2026 20:19:41 -0600 Subject: [PATCH 036/277] feat(core): implement application factory and initialize extensions and blueprints --- backend/app/__init__.py | 231 ++++---------------------------------- backend/app/extensions.py | 6 +- 2 files changed, 26 insertions(+), 211 deletions(-) diff --git a/backend/app/__init__.py b/backend/app/__init__.py index cb4a40a..e0601a4 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,105 +1,35 @@ -# Construimos la aplicación usando el patrón application factory para poder -# crear instancias independientes según el entorno: desarrollo, producción o pruebas import os -from flask import Flask, jsonify +from flask import Flask from dotenv import load_dotenv from app.config import config_map -from app.extensions import db, jwt, cors, migrate - +from app.extensions import db, jwt, cors, migrate, scheduler def create_app(env: str = None) -> Flask: - - # Cargamos las variables de entorno antes de leer cualquier configuración para que estén disponibles cuando se instancian las clases de config + # Patrón Application Factory. Aísla la inicialización para permitir múltiples instancias durante pruebas automatizadas y evita variables globales. load_dotenv() - - app = Flask(__name__, instance_relative_config=False) - + + 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) - # Hacemos esta validación aquí y no en la clase de configuración para que ocurra en tiempo de ejecución real y solo cuando el entorno es producción - if env == "production" and not app.config.get("SQLALCHEMY_DATABASE_URI"): - raise RuntimeError( - "DATABASE_URL no está definida. " - "La aplicación no puede iniciar en producción sin una base de datos configurada." - ) - _init_extensions(app) - - # Importamos los modelos para que SQLAlchemy registre sus tablas en el metadata antes de que Alembic las lea durante la generación de migraciones - from app import models as _models # noqa: F401 - _register_blueprints(app) - _register_error_handlers(app) - _register_health_check(app) + _register_schedulers(app) return app - def _init_extensions(app: Flask) -> None: db.init_app(app) jwt.init_app(app) - cors.init_app( - app, - resources={r"/api/*": {"origins": app.config["CORS_ORIGINS"]}}, - ) migrate.init_app(app, db) - _configure_jwt_errors() - - -def _configure_jwt_errors() -> None: - """Registra los manejadores de error de JWT para que sigan el formato - uniforme de la API en lugar del formato por defecto de la librería""" - - @jwt.expired_token_loader - def expired_token(_header, _payload): - return ( - jsonify( - { - "error": { - "code": "TOKEN_EXPIRED", - "message": "El token de acceso ha expirado. Inicia sesión de nuevo.", - } - } - ), - 401, - ) - - @jwt.invalid_token_loader - def invalid_token(_error): - return ( - jsonify( - { - "error": { - "code": "TOKEN_INVALID", - "message": "El token de acceso no es válido.", - } - } - ), - 401, - ) - - @jwt.unauthorized_loader - def missing_token(_error): - return ( - jsonify( - { - "error": { - "code": "UNAUTHORIZED", - "message": "Se requiere un token de acceso para usar este recurso.", - } - } - ), - 401, - ) - + # Habilitamos CORS estrictamente para la ruta de la API para permitir el consumo desde el Single Page Application de React en el Frontend. + cors.init_app(app, resources={r"/api/*": {"origins": app.config.get("CORS_ORIGINS", "*")}}) def _register_blueprints(app: Flask) -> None: - """Registra los Blueprints con sus prefijos de ruta correspondientes. - Importamos dentro de la función para evitar importaciones circulares - durante la inicialización de las extensiones""" + # 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 @@ -110,134 +40,17 @@ def _register_blueprints(app: Flask) -> None: app.register_blueprint(alerts_bp, url_prefix="/api/alerts") app.register_blueprint(admin_bp, url_prefix="/api/admin") +def _register_schedulers(app: Flask) -> None: + # Programación de tareas en segundo plano. Cumple con el requisito de automatización. + from app.services.market_trends_service import MarketTrendsService + from app.services.alerts_service import AlertsService -def _register_error_handlers(app: Flask) -> None: - """Registra los manejadores de error HTTP para devolver respuestas JSON - con el formato uniforme de la API en lugar de páginas HTML por defecto""" - - @app.errorhandler(400) - def bad_request(_error): - return ( - jsonify( - { - "error": { - "code": "BAD_REQUEST", - "message": "La solicitud no tiene el formato correcto.", - } - } - ), - 400, - ) - - @app.errorhandler(401) - def unauthorized(_error): - return ( - jsonify( - { - "error": { - "code": "UNAUTHORIZED", - "message": "Se requiere autenticación para acceder a este recurso.", - } - } - ), - 401, - ) - - @app.errorhandler(403) - def forbidden(_error): - return ( - jsonify( - { - "error": { - "code": "FORBIDDEN", - "message": "No tienes permiso para realizar esta acción.", - } - } - ), - 403, - ) - - @app.errorhandler(404) - def not_found(_error): - return ( - jsonify( - { - "error": { - "code": "NOT_FOUND", - "message": "El recurso solicitado no existe.", - } - } - ), - 404, - ) - - @app.errorhandler(405) - def method_not_allowed(_error): - return ( - jsonify( - { - "error": { - "code": "METHOD_NOT_ALLOWED", - "message": "El método HTTP no está permitido para este recurso.", - } - } - ), - 405, - ) - - @app.errorhandler(409) - def conflict(_error): - return ( - jsonify( - { - "error": { - "code": "CONFLICT", - "message": "El recurso ya existe o hay un conflicto con el estado actual.", - } - } - ), - 409, - ) - - @app.errorhandler(422) - def unprocessable_entity(_error): - return ( - jsonify( - { - "error": { - "code": "VALIDATION_ERROR", - "message": "Los datos enviados no pasaron la validación.", - } - } - ), - 422, - ) - - @app.errorhandler(500) - def internal_error(_error): - return ( - jsonify( - { - "error": { - "code": "INTERNAL_ERROR", - "message": "Ocurrió un error interno. Por favor intenta de nuevo.", - } - } - ), - 500, - ) - - -def _register_health_check(app: Flask) -> None: + def daily_pipeline(): + with app.app_context(): + MarketTrendsService.generate_snapshots() + AlertsService.evaluate_and_notify() - @app.route("/api/health") - def health_check(): - return ( - jsonify( - { - "status": "ok", - "service": "SkillStat API", - } - ), - 200, - ) + # Si el scheduler no está corriendo, lo iniciamos y programamos el pipeline + if not scheduler.running: + scheduler.add_job(func=daily_pipeline, trigger="cron", hour=0, minute=0, id="daily_pipeline", replace_existing=True) + scheduler.start() diff --git a/backend/app/extensions.py b/backend/app/extensions.py index 602a1cb..89d2cbb 100644 --- a/backend/app/extensions.py +++ b/backend/app/extensions.py @@ -1,9 +1,11 @@ from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate from flask_jwt_extended import JWTManager from flask_cors import CORS -from flask_migrate import Migrate +from apscheduler.schedulers.background import BackgroundScheduler db = SQLAlchemy() +migrate = Migrate() jwt = JWTManager() cors = CORS() -migrate = Migrate() +scheduler = BackgroundScheduler() From e62a46054c99bbe2bc59b0091844d273f7b14771 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 12 Jun 2026 20:26:58 -0600 Subject: [PATCH 037/277] feat(core): update entry point to use application factory --- backend/run.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/backend/run.py b/backend/run.py index 0c5ba84..94dc806 100644 --- a/backend/run.py +++ b/backend/run.py @@ -1,14 +1,12 @@ -# Iniciamos el servidor de desarrollo desde aquí. -# En producción usamos gunicorn directamente sin pasar por este archivo: -# gunicorn --bind 0.0.0.0:8000 "app:create_app()" +import os from app import create_app -app = create_app() +app = create_app(os.getenv("FLASK_ENV", "development")) if __name__ == "__main__": - app.run( - host="0.0.0.0", - port=5000, - debug=app.config.get("DEBUG", False), - ) - \ No newline at end of file + 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) \ No newline at end of file From 55a05f393d0d50d215bca96ffa4415e7e9b88bda Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 13 Jun 2026 14:24:46 -0600 Subject: [PATCH 038/277] fix(auth): align schema, controller, and repository to resolve registration crashes --- backend/app/controllers/auth_bp.py | 14 ++++----- backend/app/repositories/user_repository.py | 33 +++++++++++++++++---- backend/app/schemas/auth_schema.py | 12 ++++---- 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/backend/app/controllers/auth_bp.py b/backend/app/controllers/auth_bp.py index 8cf80dd..8b2acc4 100644 --- a/backend/app/controllers/auth_bp.py +++ b/backend/app/controllers/auth_bp.py @@ -12,23 +12,21 @@ @auth_bp.route("/register", methods=["POST"]) def register(): - # Validación de Entrada try: data = UserRegistrationSchema().load(request.get_json() or {}) except ValidationError as err: return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) - # Verificación de conflictos if UserRepository.get_by_email(data["email"]): return error_response(code="CONFLICT", message="El correo ya está registrado.", status_code=409) - # Preparación y persistencia - data["password"] = hash_password(data["password"]) + # Traducimos el DTO de entrada al modelo de dominio. + # Extraemos 'password' y lo inyectamos como 'password_hash' para que SQLAlchemy lo acepte. + data["password_hash"] = hash_password(data.pop("password")) data["role"] = "REGISTERED" user = UserRepository.create(data) - # Validación de Salida result = UserResponseSchema().dump(user) return success_response(data=result, status_code=201) @@ -40,17 +38,17 @@ def login(): return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) user = UserRepository.get_by_email(data["email"]) - if not user or not verify_password(data["password"], user.password): + + # Comparamos contra el atributo real del modelo de base de datos (password_hash) + if not user or not verify_password(data["password"], user.password_hash): return error_response(code="UNAUTHORIZED", message="Credenciales incorrectas.", status_code=401) - # Generación de token JWT con rol inyectado tokens = generate_tokens(user_id=user.id, role=user.role) return success_response(data=tokens, status_code=200) @auth_bp.route("/me", methods=["GET"]) @jwt_required() def get_profile(): - # Extraemos el ID del usuario del token JWT validado por la extensión user_id = get_jwt_identity() user = UserRepository.get_by_id(int(user_id)) diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index 9cdeb5c..19b63c8 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -1,7 +1,30 @@ -from app.repositories.base_repository import BaseRepository -from app.models import User +from app.models.user import User +from app.extensions import db +class UserRepository: + # Encapsula el acceso a datos para la entidad User. + # Aísla las consultas SQLAlchemy de la lógica de negocio. -class UserRepository(BaseRepository): - def __init__(self): - super().__init__(User) + @classmethod + def create(cls, user_data: dict) -> User: + user = User(**user_data) + db.session.add(user) + try: + db.session.commit() + return user + except Exception: + db.session.rollback() + return None + + @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() diff --git a/backend/app/schemas/auth_schema.py b/backend/app/schemas/auth_schema.py index c862ea1..0f1f655 100644 --- a/backend/app/schemas/auth_schema.py +++ b/backend/app/schemas/auth_schema.py @@ -1,27 +1,27 @@ from marshmallow import Schema, fields, validate, pre_load class UserRegistrationSchema(Schema): - # Establecemos restricciones estrictas de longitud y formato desde el borde del sistema para rechazar ataques de inyección o payloads masivos antes de que consuman ciclos de procesamiento o interactúen con la BD. + # 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), error_messages={"required": "La contraseña es obligatoria."}) - name = fields.String(required=True, validate=validate.Length(min=2, max=100), error_messages={"required": "El nombre es obligatorio."}) + 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): - # Normalizamos el correo a minúsculas para evitar colisiones de unicidad en PostgreSQL provocadas por capitalización inconsistente del usuario. if "email" in data and isinstance(data["email"], str): data["email"] = data["email"].lower().strip() return data class UserLoginSchema(Schema): - # Para el login solo requerimos presencia, no validamos longitud de contraseña aquí para no dar pistas a posibles atacantes sobre nuestras políticas internas. 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): - # Plantilla de serialización de salida. Omitimos explícitamente el campo 'password' en la definición para garantizar que el hash criptográfico jamás se filtre al frontend. + # Exponemos la estructura desagregada del nombre y mantenemos la censura de la contraseña id = fields.Integer(dump_only=True) email = fields.Email(dump_only=True) - name = fields.String(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) From 2a8c412b73e016412cba5a28f68421d1f9c791ab Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 13 Jun 2026 15:32:24 -0600 Subject: [PATCH 039/277] fix(repositories): implement dynamic model instantiation via base repository create method --- backend/app/repositories/base_repository.py | 69 +++++++++---------- backend/app/repositories/job_repository.py | 6 +- .../app/repositories/job_skill_repository.py | 6 +- 3 files changed, 36 insertions(+), 45 deletions(-) diff --git a/backend/app/repositories/base_repository.py b/backend/app/repositories/base_repository.py index 9e9e34d..7f58f50 100644 --- a/backend/app/repositories/base_repository.py +++ b/backend/app/repositories/base_repository.py @@ -1,47 +1,42 @@ from app.extensions import db - class BaseRepository: - def __init__(self, model): - self.model = model - - def get_all(self): - return db.session.query(self.model).all() + # Repositorio genérico que implementa operaciones CRUD estándar + # para cualquier modelo SQLAlchemy. Utiliza classmethods para evitar + # la sobrecarga de instanciación en la capa de servicios. + + model = None - def get_by_id(self, id): - return db.session.get(self.model, id) + @classmethod + def create(cls, data: dict): + # Transforma un diccionario DTO en una entidad SQLAlchemy y la persiste. + # Evita que la capa de Servicios tenga que importar los Modelos de la BD. + entity = cls.model(**data) + return cls.save(entity) - def create(self, data): - # Protegemos la transacción con un bloque de manejo de errores para garantizar que una falla en la escritura no deje bloqueada la sesión de la base de datos + @classmethod + def save(cls, entity): + db.session.add(entity) try: - instance = self.model(**data) - db.session.add(instance) db.session.commit() - return instance - except Exception as e: + return entity + except Exception: db.session.rollback() - raise e + return None - def update(self, id, data): - try: - instance = self.get_by_id(id) - if instance: - for key, value in data.items(): - setattr(instance, key, value) - db.session.commit() - return instance - except Exception as e: - db.session.rollback() - raise e + @classmethod + def get_by_id(cls, entity_id: int): + return db.session.get(cls.model, entity_id) - def delete(self, id): - try: - instance = self.get_by_id(id) - if instance: - db.session.delete(instance) - db.session.commit() - return True - return False - except Exception as e: - db.session.rollback() - raise e + @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 diff --git a/backend/app/repositories/job_repository.py b/backend/app/repositories/job_repository.py index a18e316..828272e 100644 --- a/backend/app/repositories/job_repository.py +++ b/backend/app/repositories/job_repository.py @@ -1,7 +1,5 @@ +from app.models.job import Job from app.repositories.base_repository import BaseRepository -from app.models import Job - class JobRepository(BaseRepository): - def __init__(self): - super().__init__(Job) + model = Job diff --git a/backend/app/repositories/job_skill_repository.py b/backend/app/repositories/job_skill_repository.py index 4f90714..096c31d 100644 --- a/backend/app/repositories/job_skill_repository.py +++ b/backend/app/repositories/job_skill_repository.py @@ -1,7 +1,5 @@ +from app.models.job_skill import JobSkill from app.repositories.base_repository import BaseRepository -from app.models import JobSkill - class JobSkillRepository(BaseRepository): - def __init__(self): - super().__init__(JobSkill) + model = JobSkill From 63408919cfa74133411c633c50d728d33b4bc70e Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 13 Jun 2026 20:02:26 -0600 Subject: [PATCH 040/277] fix(repositories): implement dynamic inspection for safe DTO mapping and align classmethods --- backend/app/repositories/base_repository.py | 16 ++++++++------- backend/app/repositories/job_repository.py | 1 + backend/app/repositories/skill_repository.py | 21 ++++++++++++++++---- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/backend/app/repositories/base_repository.py b/backend/app/repositories/base_repository.py index 7f58f50..48975ef 100644 --- a/backend/app/repositories/base_repository.py +++ b/backend/app/repositories/base_repository.py @@ -1,17 +1,19 @@ +from sqlalchemy import inspect from app.extensions import db class BaseRepository: - # Repositorio genérico que implementa operaciones CRUD estándar - # para cualquier modelo SQLAlchemy. Utiliza classmethods para evitar - # la sobrecarga de instanciación en la capa de servicios. - + # Repositorio genérico con soporte de instanciación dinámica y segura. model = None @classmethod def create(cls, data: dict): - # Transforma un diccionario DTO en una entidad SQLAlchemy y la persiste. - # Evita que la capa de Servicios tenga que importar los Modelos de la BD. - entity = cls.model(**data) + # 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 diff --git a/backend/app/repositories/job_repository.py b/backend/app/repositories/job_repository.py index 828272e..b4aade2 100644 --- a/backend/app/repositories/job_repository.py +++ b/backend/app/repositories/job_repository.py @@ -1,5 +1,6 @@ from app.models.job import Job from app.repositories.base_repository import BaseRepository +# La sanitización es manejada dinámicamente por BaseRepository.create class JobRepository(BaseRepository): model = Job diff --git a/backend/app/repositories/skill_repository.py b/backend/app/repositories/skill_repository.py index 94405a3..8013126 100644 --- a/backend/app/repositories/skill_repository.py +++ b/backend/app/repositories/skill_repository.py @@ -1,7 +1,20 @@ +from app.models.skill import Skill from app.repositories.base_repository import BaseRepository -from app.models import Skill - +from app.extensions import db class SkillRepository(BaseRepository): - def __init__(self): - super().__init__(Skill) + 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() From 9fe61ac31cf02f169c77b0d6373f704f507891ff Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 13 Jun 2026 20:43:02 -0600 Subject: [PATCH 041/277] fix(repositories): add error logging to base repository for observability --- backend/app/repositories/base_repository.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/app/repositories/base_repository.py b/backend/app/repositories/base_repository.py index 48975ef..43c09c2 100644 --- a/backend/app/repositories/base_repository.py +++ b/backend/app/repositories/base_repository.py @@ -22,8 +22,9 @@ def save(cls, entity): try: db.session.commit() return entity - except Exception: + except Exception as e: db.session.rollback() + print(f"\n[ERROR DE PERSISTENCIA] Fallo al guardar en BD: {str(e)}\n") return None @classmethod From 8e6692e17f5be5c05b20b6e640440b66604e1ae5 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 13 Jun 2026 20:51:29 -0600 Subject: [PATCH 042/277] fix(repositories): implement data truncation and accurate ACL mapping for external APIs --- backend/app/repositories/job_repository.py | 24 +++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/backend/app/repositories/job_repository.py b/backend/app/repositories/job_repository.py index b4aade2..6cefb20 100644 --- a/backend/app/repositories/job_repository.py +++ b/backend/app/repositories/job_repository.py @@ -1,6 +1,28 @@ from app.models.job import Job from app.repositories.base_repository import BaseRepository -# La sanitización es manejada dinámicamente por BaseRepository.create 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"] + + # Protección contra StringDataRightTruncation + mapped_data["source"] = "Adzuna" + + if mapped_data.get("title"): + mapped_data["title"] = str(mapped_data["title"])[:150] + if mapped_data.get("company"): + mapped_data["company"] = str(mapped_data["company"])[:100] + + return super().create(mapped_data) From dcb0cada479a5dafe94c10e7b3deab35106ef0c9 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 13 Jun 2026 20:51:40 -0600 Subject: [PATCH 043/277] feat(nlp): build core tech skills dictionary to enable spacy extraction --- backend/data/dictionaries/skills_esco.jsonl | 62 +++++++++++++++++++++ backend/scripts/build_dictionary.py | 33 +++++++++++ 2 files changed, 95 insertions(+) create mode 100644 backend/data/dictionaries/skills_esco.jsonl create mode 100644 backend/scripts/build_dictionary.py diff --git a/backend/data/dictionaries/skills_esco.jsonl b/backend/data/dictionaries/skills_esco.jsonl new file mode 100644 index 0000000..0e934f7 --- /dev/null +++ b/backend/data/dictionaries/skills_esco.jsonl @@ -0,0 +1,62 @@ +{"label": "SKILL", "pattern": "Python"} +{"label": "SKILL", "pattern": "JavaScript"} +{"label": "SKILL", "pattern": "TypeScript"} +{"label": "SKILL", "pattern": "Java"} +{"label": "SKILL", "pattern": "C#"} +{"label": "SKILL", "pattern": "C++"} +{"label": "SKILL", "pattern": "Ruby"} +{"label": "SKILL", "pattern": "PHP"} +{"label": "SKILL", "pattern": "Go"} +{"label": "SKILL", "pattern": "Rust"} +{"label": "SKILL", "pattern": "Swift"} +{"label": "SKILL", "pattern": "Kotlin"} +{"label": "SKILL", "pattern": "React"} +{"label": "SKILL", "pattern": "Angular"} +{"label": "SKILL", "pattern": "Vue.js"} +{"label": "SKILL", "pattern": "Node.js"} +{"label": "SKILL", "pattern": "Express"} +{"label": "SKILL", "pattern": "Django"} +{"label": "SKILL", "pattern": "Flask"} +{"label": "SKILL", "pattern": "FastAPI"} +{"label": "SKILL", "pattern": "Spring Boot"} +{"label": "SKILL", "pattern": ".NET"} +{"label": "SKILL", "pattern": "SQL"} +{"label": "SKILL", "pattern": "MySQL"} +{"label": "SKILL", "pattern": "PostgreSQL"} +{"label": "SKILL", "pattern": "MongoDB"} +{"label": "SKILL", "pattern": "SQLite"} +{"label": "SKILL", "pattern": "NoSQL"} +{"label": "SKILL", "pattern": "Redis"} +{"label": "SKILL", "pattern": "Cassandra"} +{"label": "SKILL", "pattern": "Elasticsearch"} +{"label": "SKILL", "pattern": "AWS"} +{"label": "SKILL", "pattern": "Azure"} +{"label": "SKILL", "pattern": "Google Cloud"} +{"label": "SKILL", "pattern": "GCP"} +{"label": "SKILL", "pattern": "Docker"} +{"label": "SKILL", "pattern": "Kubernetes"} +{"label": "SKILL", "pattern": "Terraform"} +{"label": "SKILL", "pattern": "Jenkins"} +{"label": "SKILL", "pattern": "CI/CD"} +{"label": "SKILL", "pattern": "Linux"} +{"label": "SKILL", "pattern": "Machine Learning"} +{"label": "SKILL", "pattern": "Data Science"} +{"label": "SKILL", "pattern": "Artificial Intelligence"} +{"label": "SKILL", "pattern": "NLP"} +{"label": "SKILL", "pattern": "Deep Learning"} +{"label": "SKILL", "pattern": "TensorFlow"} +{"label": "SKILL", "pattern": "PyTorch"} +{"label": "SKILL", "pattern": "Pandas"} +{"label": "SKILL", "pattern": "NumPy"} +{"label": "SKILL", "pattern": "Git"} +{"label": "SKILL", "pattern": "GitHub"} +{"label": "SKILL", "pattern": "GitLab"} +{"label": "SKILL", "pattern": "Bitbucket"} +{"label": "SKILL", "pattern": "Agile"} +{"label": "SKILL", "pattern": "Scrum"} +{"label": "SKILL", "pattern": "Jira"} +{"label": "SKILL", "pattern": "Figma"} +{"label": "SKILL", "pattern": "HTML"} +{"label": "SKILL", "pattern": "CSS"} +{"label": "SKILL", "pattern": "Sass"} +{"label": "SKILL", "pattern": "Tailwind"} diff --git a/backend/scripts/build_dictionary.py b/backend/scripts/build_dictionary.py new file mode 100644 index 0000000..767a89f --- /dev/null +++ b/backend/scripts/build_dictionary.py @@ -0,0 +1,33 @@ +import json +import os + +def build_tech_dictionary(): + # Resolución de ruta absoluta para garantizar ejecución desde cualquier directorio + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + dict_dir = os.path.join(base_dir, "data", "dictionaries") + os.makedirs(dict_dir, exist_ok=True) + + output_file = os.path.join(dict_dir, "skills_esco.jsonl") + + # Línea base de habilidades tecnológicas (Sustituto inicial y focalizado de la taxonomía cruda) + 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", + "Git", "GitHub", "GitLab", "Bitbucket", "Agile", "Scrum", "Jira", "Figma", "HTML", "CSS", "Sass", "Tailwind" + ] + + print(f"Construyendo diccionario NLP con {len(core_skills)} habilidades base...") + + with open(output_file, "w", encoding="utf-8") as f: + for skill in core_skills: + # spaCy EntityRuler format: {"label": "ENT_TYPE", "pattern": "exact_string_or_list"} + entry = {"label": "SKILL", "pattern": skill} + f.write(json.dumps(entry) + "\n") + + print(f"Diccionario generado exitosamente en: {output_file}") + +if __name__ == "__main__": + build_tech_dictionary() From 9b4cd9b3d515101237ae09025944d6b144600c47 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 14 Jun 2026 14:26:18 -0600 Subject: [PATCH 044/277] chore(cleanup): remove debug residuals and consolidate build_dictionary --- backend/clean.py | 80 ----------------------------- backend/clean2.py | 79 ---------------------------- backend/clean3.py | 78 ---------------------------- backend/scripts/build_dictionary.py | 33 ------------ backend/test_read.py | 13 ----- docs/LIMPIEZA_RESIDUALES.md | 29 +++++++++++ scripts/build_dictionary.py | 45 ++++++++-------- 7 files changed, 53 insertions(+), 304 deletions(-) delete mode 100644 backend/clean.py delete mode 100644 backend/clean2.py delete mode 100644 backend/clean3.py delete mode 100644 backend/scripts/build_dictionary.py delete mode 100644 backend/test_read.py create mode 100644 docs/LIMPIEZA_RESIDUALES.md diff --git a/backend/clean.py b/backend/clean.py deleted file mode 100644 index 8a71cc6..0000000 --- a/backend/clean.py +++ /dev/null @@ -1,80 +0,0 @@ -import os -import re - -KEEP_PHRASES = [ - "Habilitamos nulos para", - "Establecemos México como default", - "Permitimos nulos en city_id", - "Guardamos el hash de la", - "Utilizamos llave primaria compuesta", - "Relacionamos bidireccionalmente", - "Forzamos unicidad combinada", - "Restringimos los roles permitidos", - "Protegemos la transacción con un", - "Construimos la aplicación usando el patrón application factory", - "crear instancias independientes según el", - "Cargamos las variables de entorno antes de", - "Hacemos esta validación aquí y no en la clase", - "Importamos los modelos para que SQLAlchemy", - "noqa", -] - -def should_keep(line): - for phrase in KEEP_PHRASES: - if phrase in line: - return True - return False - -def clean_file(filepath): - with open(filepath, 'r', encoding='utf-8') as f: - content = f.read() - - lines = content.split('\n') - new_lines = [] - modified = False - - for line in lines: - stripped = line.strip() - if stripped.startswith('#'): - if not should_keep(line): - modified = True - continue - new_lines.append(line) - - new_content = '\n'.join(new_lines) - - # Also clean up known unwanted docstrings - unwanted_docstrings = [ - ' \"\"\"Configuración base compartida por todos los entornos\"\"\"\n', - ' \"\"\"Configuración para el entorno de desarrollo local\"\"\"\n', - ' \"\"\"Configuración para el entorno de producción\"\"\"\n', - ' \"\"\"Configuración para el entorno de pruebas automatizadas\"\"\"\n', - ' \"\"\"Crea y configura una instancia de la aplicación Flask\"\"\"\n', - ' \"\"\"Conecta las extensiones con la instancia de la aplicación\"\"\"\n', - ' \"\"\"Registra el endpoint de salud para verificar que el servicio está activo\"\"\"\n' - ] - for ud in unwanted_docstrings: - if ud in new_content: - new_content = new_content.replace(ud, "") - modified = True - - # Remove consecutive blank lines - new_content = re.sub(r'\n{3,}', '\n\n', new_content) - - if modified: - with open(filepath, 'w', encoding='utf-8') as f: - f.write(new_content) - return True - return False - -app_dir = os.path.join(os.getcwd(), 'app') -modified_files = [] - -for root, _, files in os.walk(app_dir): - for file in files: - if file.endswith('.py'): - if clean_file(os.path.join(root, file)): - modified_files.append(os.path.relpath(os.path.join(root, file), os.getcwd())) - -for f in modified_files: - print(f) diff --git a/backend/clean2.py b/backend/clean2.py deleted file mode 100644 index d3448c9..0000000 --- a/backend/clean2.py +++ /dev/null @@ -1,79 +0,0 @@ -import os -import re - -KEEP_PHRASES = [ - "Habilitamos nulos para", - "Establecemos México como default", - "Permitimos nulos en city_id", - "Guardamos el hash de la", - "Utilizamos llave primaria compuesta", - "Relacionamos bidireccionalmente", - "Forzamos unicidad combinada", - "Restringimos los roles permitidos", - "Protegemos la transacción con un", - "Construimos la aplicación usando el patrón application factory", - "crear instancias independientes según el", - "Cargamos las variables de entorno antes de", - "Hacemos esta validación aquí y no en la clase", - "Importamos los modelos para que SQLAlchemy", - "noqa", -] - -def clean_file(filepath): - with open(filepath, 'r', encoding='utf-8') as f: - content = f.read() - - lines = content.split('\n') - new_lines = [] - modified = False - - for line in lines: - stripped = line.strip() - if stripped.startswith('#'): - # Check if we should keep it - keep = False - for phrase in KEEP_PHRASES: - if phrase in line: - keep = True - break - if not keep: - modified = True - continue - new_lines.append(line) - - new_content = '\n'.join(new_lines) - - unwanted_docstrings = [ - ' \"\"\"Configuración base compartida por todos los entornos\"\"\"\n', - ' \"\"\"Configuración para el entorno de desarrollo local\"\"\"\n', - ' \"\"\"Configuración para el entorno de producción\"\"\"\n', - ' \"\"\"Configuración para el entorno de pruebas automatizadas\"\"\"\n', - ' \"\"\"Crea y configura una instancia de la aplicación Flask\"\"\"\n', - ' \"\"\"Conecta las extensiones con la instancia de la aplicación\"\"\"\n', - ' \"\"\"Registra el endpoint de salud para verificar que el servicio está activo\"\"\"\n' - ] - for ud in unwanted_docstrings: - if ud in new_content: - new_content = new_content.replace(ud, "") - modified = True - - new_content = re.sub(r'\n{3,}', '\n\n', new_content) - - if not new_content.strip(): - new_content = "" - - if modified: - with open(filepath, 'w', encoding='utf-8') as f: - f.write(new_content) - return True - return False - -app_dir = os.path.join(os.getcwd(), 'app') -for root, _, files in os.walk(app_dir): - for file in files: - if file.endswith('.py'): - filepath = os.path.join(root, file) - if clean_file(filepath): - print(f"MODIFIED: {filepath}") - else: - print(f"SKIPPED: {filepath}") diff --git a/backend/clean3.py b/backend/clean3.py deleted file mode 100644 index 48d41f7..0000000 --- a/backend/clean3.py +++ /dev/null @@ -1,78 +0,0 @@ -import os -import re - -KEEP_PHRASES = [ - "Habilitamos nulos para", - "Establecemos México como default", - "Permitimos nulos en city_id", - "Guardamos el hash de la", - "Utilizamos llave primaria compuesta", - "Relacionamos bidireccionalmente", - "Forzamos unicidad combinada", - "Restringimos los roles permitidos", - "Protegemos la transacción con un", - "Construimos la aplicación usando el patrón application factory", - "crear instancias independientes según el", - "Cargamos las variables de entorno antes de", - "Hacemos esta validación aquí y no en la clase", - "Importamos los modelos para que SQLAlchemy", - "noqa", -] - -def clean_file(filepath): - # Use utf-8-sig to handle files with or without BOM - with open(filepath, 'r', encoding='utf-8-sig') as f: - content = f.read() - - lines = content.split('\n') - new_lines = [] - modified = False - - for line in lines: - stripped = line.strip() - if stripped.startswith('#'): - # Check if we should keep it - keep = False - for phrase in KEEP_PHRASES: - if phrase in line: - keep = True - break - if not keep: - modified = True - continue - new_lines.append(line) - - new_content = '\n'.join(new_lines) - - unwanted_docstrings = [ - ' \"\"\"Configuración base compartida por todos los entornos\"\"\"\n', - ' \"\"\"Configuración para el entorno de desarrollo local\"\"\"\n', - ' \"\"\"Configuración para el entorno de producción\"\"\"\n', - ' \"\"\"Configuración para el entorno de pruebas automatizadas\"\"\"\n', - ' \"\"\"Crea y configura una instancia de la aplicación Flask\"\"\"\n', - ' \"\"\"Conecta las extensiones con la instancia de la aplicación\"\"\"\n', - ' \"\"\"Registra el endpoint de salud para verificar que el servicio está activo\"\"\"\n' - ] - for ud in unwanted_docstrings: - if ud in new_content: - new_content = new_content.replace(ud, "") - modified = True - - new_content = re.sub(r'\n{3,}', '\n\n', new_content) - - if not new_content.strip(): - new_content = "" - - if modified: - with open(filepath, 'w', encoding='utf-8') as f: - f.write(new_content) - return True - return False - -app_dir = os.path.join(os.getcwd(), 'app') -for root, _, files in os.walk(app_dir): - for file in files: - if file.endswith('.py'): - filepath = os.path.join(root, file) - if clean_file(filepath): - print(f"MODIFIED: {os.path.relpath(filepath, os.getcwd())}") diff --git a/backend/scripts/build_dictionary.py b/backend/scripts/build_dictionary.py deleted file mode 100644 index 767a89f..0000000 --- a/backend/scripts/build_dictionary.py +++ /dev/null @@ -1,33 +0,0 @@ -import json -import os - -def build_tech_dictionary(): - # Resolución de ruta absoluta para garantizar ejecución desde cualquier directorio - base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - dict_dir = os.path.join(base_dir, "data", "dictionaries") - os.makedirs(dict_dir, exist_ok=True) - - output_file = os.path.join(dict_dir, "skills_esco.jsonl") - - # Línea base de habilidades tecnológicas (Sustituto inicial y focalizado de la taxonomía cruda) - 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", - "Git", "GitHub", "GitLab", "Bitbucket", "Agile", "Scrum", "Jira", "Figma", "HTML", "CSS", "Sass", "Tailwind" - ] - - print(f"Construyendo diccionario NLP con {len(core_skills)} habilidades base...") - - with open(output_file, "w", encoding="utf-8") as f: - for skill in core_skills: - # spaCy EntityRuler format: {"label": "ENT_TYPE", "pattern": "exact_string_or_list"} - entry = {"label": "SKILL", "pattern": skill} - f.write(json.dumps(entry) + "\n") - - print(f"Diccionario generado exitosamente en: {output_file}") - -if __name__ == "__main__": - build_tech_dictionary() diff --git a/backend/test_read.py b/backend/test_read.py deleted file mode 100644 index 14ee8d6..0000000 --- a/backend/test_read.py +++ /dev/null @@ -1,13 +0,0 @@ -import os -import traceback - -app_dir = os.path.join(os.getcwd(), 'app') -for root, _, files in os.walk(app_dir): - for file in files: - if file.endswith('.py'): - filepath = os.path.join(root, file) - try: - with open(filepath, 'r', encoding='utf-8') as f: - content = f.read() - except Exception as e: - print(f"Error reading {filepath}: {e}") diff --git a/docs/LIMPIEZA_RESIDUALES.md b/docs/LIMPIEZA_RESIDUALES.md new file mode 100644 index 0000000..46009e1 --- /dev/null +++ b/docs/LIMPIEZA_RESIDUALES.md @@ -0,0 +1,29 @@ +# 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/scripts/build_dictionary.py b/scripts/build_dictionary.py index 99a351e..da41f1d 100644 --- a/scripts/build_dictionary.py +++ b/scripts/build_dictionary.py @@ -1,39 +1,42 @@ import json import os -# Resoluciones de rutas absolutas para garantizar que el script pueda -# ejecutarse desde cualquier directorio sin romper los paths relativos. 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") -# Semilla de conocimientos tecnológicos CORE_SKILLS = [ - "Python", "JavaScript", "Java", "C++", "C#", "Ruby", "PHP", "Go", "Swift", "Kotlin", - "TypeScript", "Rust", "SQL", "NoSQL", "PostgreSQL", "MySQL", "MongoDB", "Redis", - "React", "Angular", "Vue", "Node.js", "Express", "Django", "Flask", "FastAPI", - "Spring Boot", "Laravel", "Docker", "Kubernetes", "AWS", "Azure", "GCP", - "Google Cloud", "Linux", "Git", "CI/CD", "Jenkins", "Terraform", "Ansible", - "Machine Learning", "Data Science", "Artificial Intelligence", "Deep Learning", - "NLP", "TensorFlow", "PyTorch", "Pandas", "NumPy", "Scikit-learn", - "HTML", "CSS", "Tailwind", "Bootstrap", "Sass", "GraphQL", "REST API", - "Microservices", "Agile", "Scrum", "Kanban" + "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 de conocimientos NLP en: {OUTPUT_FILE}") - + print(f"Construyendo diccionario NLP en: {OUTPUT_FILE}") + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: for skill in CORE_SKILLS: - # Formateamos estrictamente bajo el estándar JSONL que spaCy requiere para inyectar reglas deterministas en el EntityRuler. - entry = { - "label": "SKILL", - "pattern": skill - } + # Formateamos bajo el estandar JSONL que spaCy requiere para + # inyectar reglas deterministicas en el EntityRuler. + entry = {"label": "SKILL", "pattern": skill} f.write(json.dumps(entry) + "\n") - - print(f"Éxito: {len(CORE_SKILLS)} habilidades exportadas y listas para EntityRuler.") + + print(f"Exito: {len(CORE_SKILLS)} habilidades exportadas.") + if __name__ == "__main__": build_dictionary() \ No newline at end of file From 634672b4ea2821ab2223cd3d9baa7dc9cd9187f2 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 14 Jun 2026 14:56:45 -0600 Subject: [PATCH 045/277] fix(ingestion): capture salary_min and salary_max from Adzuna payload --- backend/app/services/ingestion_service.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/app/services/ingestion_service.py b/backend/app/services/ingestion_service.py index d439b0a..04da07a 100644 --- a/backend/app/services/ingestion_service.py +++ b/backend/app/services/ingestion_service.py @@ -47,13 +47,19 @@ def _process_job(cls, item: dict, known_skills: dict, stats: dict) -> None: # Guardamos sin location estricta hasta integrar Nominatim, determinando la bandera remote de forma aislada. is_remote = "remote" in str(item).lower() or "remoto" in str(item).lower() + salary_min = item.get("salary_min") + salary_max = item.get("salary_max") + job_data = { "title": title[:200], "company": company[:200], "description": description, "url": url, "description_hash": desc_hash, - "remote": is_remote + "remote": is_remote, + # 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, } job = JobRepository.create(job_data) From ebfdcee163e3bd92dd2d26c2f7a9269c248fd258 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 14 Jun 2026 18:07:21 -0600 Subject: [PATCH 046/277] fix(alerts): replace in-memory filter with repository query --- backend/app/controllers/alerts_bp.py | 9 ++++----- backend/app/repositories/alert_repository.py | 5 +++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/backend/app/controllers/alerts_bp.py b/backend/app/controllers/alerts_bp.py index d22bb45..81bd5b2 100644 --- a/backend/app/controllers/alerts_bp.py +++ b/backend/app/controllers/alerts_bp.py @@ -29,11 +29,10 @@ def create_alert(): @jwt_required() def get_alerts(): user_id = int(get_jwt_identity()) - - # Filtramos en memoria para garantizar que el usuario actual no tenga visibilidad sobre configuraciones ajenas. - all_alerts = AlertRepository.get_all() - user_alerts = [a for a in all_alerts if a.user_id == user_id] - + + # 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) diff --git a/backend/app/repositories/alert_repository.py b/backend/app/repositories/alert_repository.py index 7814106..34a604b 100644 --- a/backend/app/repositories/alert_repository.py +++ b/backend/app/repositories/alert_repository.py @@ -5,3 +5,8 @@ class AlertRepository(BaseRepository): def __init__(self): super().__init__(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() From 0c10f333bcd4389641204184b935599e0af56909 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 14 Jun 2026 18:13:43 -0600 Subject: [PATCH 047/277] refactor(scheduler): extract daily_pipeline to dedicated module --- backend/app/__init__.py | 21 ++++++++++++--------- backend/scheduler/jobs.py | 10 +++++++++- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/backend/app/__init__.py b/backend/app/__init__.py index e0601a4..7378d69 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -41,16 +41,19 @@ def _register_blueprints(app: Flask) -> None: app.register_blueprint(admin_bp, url_prefix="/api/admin") def _register_schedulers(app: Flask) -> None: - # Programación de tareas en segundo plano. Cumple con el requisito de automatización. - from app.services.market_trends_service import MarketTrendsService - from app.services.alerts_service import AlertsService + from scheduler.jobs import daily_pipeline + import functools - def daily_pipeline(): - with app.app_context(): - MarketTrendsService.generate_snapshots() - AlertsService.evaluate_and_notify() + # Vinculamos la instancia concreta de app al job para que APScheduler pueda ejecutarlo en su hilo sin depender del proxy. + bound_pipeline = functools.partial(daily_pipeline, app) - # Si el scheduler no está corriendo, lo iniciamos y programamos el pipeline if not scheduler.running: - scheduler.add_job(func=daily_pipeline, trigger="cron", hour=0, minute=0, id="daily_pipeline", replace_existing=True) + scheduler.add_job( + func=bound_pipeline, + trigger="cron", + hour=0, + minute=0, + id="daily_pipeline", + replace_existing=True, + ) scheduler.start() diff --git a/backend/scheduler/jobs.py b/backend/scheduler/jobs.py index 084e931..5248dd5 100644 --- a/backend/scheduler/jobs.py +++ b/backend/scheduler/jobs.py @@ -1 +1,9 @@ -# jobs — SkillStat \ No newline at end of file +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 From 4757756489714ec6178a42cdb3441146161c149e Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 14 Jun 2026 18:19:36 -0600 Subject: [PATCH 048/277] fix(quality): correct CORS comment and replace manual URL parsing --- backend/app/__init__.py | 2 +- backend/app/services/backup_service.py | 66 +++++++++++++++----------- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 7378d69..9d388c8 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -25,7 +25,7 @@ def _init_extensions(app: Flask) -> None: db.init_app(app) jwt.init_app(app) migrate.init_app(app, db) - # Habilitamos CORS estrictamente para la ruta de la API para permitir el consumo desde el Single Page Application de React en el Frontend. + # 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", "*")}}) def _register_blueprints(app: Flask) -> None: diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index 8f8bf55..d127d4a 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -6,45 +6,47 @@ from app.utils.errors import AppError class BackupService: - # Encapsula la ejecución de comandos del sistema operativo (pg_dump). - # Requisito obligatorio de infraestructura y recuperación. + # Encapsula la ejecución de comandos del sistema operativo (pg_dump). Requisito obligatorio de infraestructura y recuperación. @classmethod def execute_database_backup(cls, requested_by: int = None) -> dict: + from urllib.parse import urlparse + 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) + raise AppError( + "El servicio de respaldo solo soporta motores PostgreSQL nativos.", + status_code=500, + ) - # Generamos un nombre de archivo unívoco por estampa de tiempo timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"skillstat_backup_{timestamp}.sql" - - # Resolvemos ruta absoluta para evitar que el dump caiga en un directorio volátil - base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + 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) - # Inyectamos los metadatos de la operación en estado 'pending' backup_record = BackupRepository.create({ "filename": filename, "filepath": filepath, "status": "pending", - "requested_by": requested_by + "requested_by": requested_by, }) try: - # Parseamos la URL asumiendo formato estandar SQLAlchemy: postgresql://user:pass@host:port/db - credentials, location = db_url.replace("postgresql://", "").split("@") - user, password = credentials.split(":") - host_port, db_name = location.split("/") - - host = host_port.split(":")[0] - port = host_port.split(":")[1] if ":" in host_port else "5432" + # Usamos urlparse para manejar correctamente passwords con caracteres especiales que el split manual no puede resolver. + parsed = urlparse(db_url) + user = parsed.username + password = parsed.password or "" + host = parsed.hostname + port = str(parsed.port or 5432) + db_name = parsed.path.lstrip("/") - # Inyectar PGPASSWORD en el entorno es la única forma segura de autenticar pg_dump sin exponer credenciales en el historial de comandos del sistema operativo. env = os.environ.copy() env["PGPASSWORD"] = password @@ -53,22 +55,30 @@ def execute_database_backup(cls, requested_by: int = None) -> dict: "-h", host, "-p", port, "-U", user, - "-F", "c", # Formato custom (comprimido binario) para optimizar I/O + "-F", "c", "-f", filepath, - db_name + db_name, ] - process = subprocess.run(command, env=env, capture_output=True, text=True, check=True) - - # Actualizamos registro a 'completed' con peso real del archivo + subprocess.run(command, env=env, capture_output=True, text=True, check=True) + file_size = os.path.getsize(filepath) - BackupRepository.update(backup_record.id, {"status": "completed", "file_size_bytes": file_size}) - + 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: BackupRepository.update(backup_record.id, {"status": "failed"}) - raise AppError(f"Fallo en ejecución de pg_dump: {e.stderr}", code="BACKUP_ERROR") + raise AppError( + f"Fallo en ejecucion de pg_dump: {e.stderr}", + code="BACKUP_ERROR", + ) except Exception as e: BackupRepository.update(backup_record.id, {"status": "failed"}) - raise AppError(f"Error interno durante respaldo: {str(e)}", code="BACKUP_ERROR") + raise AppError( + f"Error interno durante respaldo: {str(e)}", + code="BACKUP_ERROR", + ) From 67e5fb539769035ec24ad247ad58c5fabce30192 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 14 Jun 2026 18:56:06 -0600 Subject: [PATCH 049/277] fix(models): add unique constraint to jobs.description_hash --- backend/app/models/job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/models/job.py b/backend/app/models/job.py index f6cfdab..2de1be5 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -16,7 +16,7 @@ class Job(db.Model): 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) + description_hash = db.Column(db.String(64), nullable=False, unique=True) processed = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) From 74bb44e36a891c801128d742a640c2e70ee28313 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 14 Jun 2026 23:25:24 -0600 Subject: [PATCH 050/277] fix(migrations): add unique index to jobs.description_hash across multiple ingestion runs --- ...b5f6ca10_add_unique_constraint_to_jobs_.py | 32 +++++++++++ data/dictionaries/skills_esco.jsonl | 54 ++++++++++--------- 2 files changed, 62 insertions(+), 24 deletions(-) create mode 100644 backend/migrations/versions/c431b5f6ca10_add_unique_constraint_to_jobs_.py 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/data/dictionaries/skills_esco.jsonl b/data/dictionaries/skills_esco.jsonl index 5e854bb..0612b86 100644 --- a/data/dictionaries/skills_esco.jsonl +++ b/data/dictionaries/skills_esco.jsonl @@ -1,61 +1,67 @@ {"label": "SKILL", "pattern": "Python"} {"label": "SKILL", "pattern": "JavaScript"} +{"label": "SKILL", "pattern": "TypeScript"} {"label": "SKILL", "pattern": "Java"} -{"label": "SKILL", "pattern": "C++"} {"label": "SKILL", "pattern": "C#"} +{"label": "SKILL", "pattern": "C++"} {"label": "SKILL", "pattern": "Ruby"} {"label": "SKILL", "pattern": "PHP"} {"label": "SKILL", "pattern": "Go"} +{"label": "SKILL", "pattern": "Rust"} {"label": "SKILL", "pattern": "Swift"} {"label": "SKILL", "pattern": "Kotlin"} -{"label": "SKILL", "pattern": "TypeScript"} -{"label": "SKILL", "pattern": "Rust"} -{"label": "SKILL", "pattern": "SQL"} -{"label": "SKILL", "pattern": "NoSQL"} -{"label": "SKILL", "pattern": "PostgreSQL"} -{"label": "SKILL", "pattern": "MySQL"} -{"label": "SKILL", "pattern": "MongoDB"} -{"label": "SKILL", "pattern": "Redis"} {"label": "SKILL", "pattern": "React"} {"label": "SKILL", "pattern": "Angular"} -{"label": "SKILL", "pattern": "Vue"} +{"label": "SKILL", "pattern": "Vue.js"} {"label": "SKILL", "pattern": "Node.js"} {"label": "SKILL", "pattern": "Express"} {"label": "SKILL", "pattern": "Django"} {"label": "SKILL", "pattern": "Flask"} {"label": "SKILL", "pattern": "FastAPI"} {"label": "SKILL", "pattern": "Spring Boot"} -{"label": "SKILL", "pattern": "Laravel"} -{"label": "SKILL", "pattern": "Docker"} -{"label": "SKILL", "pattern": "Kubernetes"} +{"label": "SKILL", "pattern": ".NET"} +{"label": "SKILL", "pattern": "SQL"} +{"label": "SKILL", "pattern": "MySQL"} +{"label": "SKILL", "pattern": "PostgreSQL"} +{"label": "SKILL", "pattern": "MongoDB"} +{"label": "SKILL", "pattern": "SQLite"} +{"label": "SKILL", "pattern": "NoSQL"} +{"label": "SKILL", "pattern": "Redis"} +{"label": "SKILL", "pattern": "Cassandra"} +{"label": "SKILL", "pattern": "Elasticsearch"} {"label": "SKILL", "pattern": "AWS"} {"label": "SKILL", "pattern": "Azure"} -{"label": "SKILL", "pattern": "GCP"} {"label": "SKILL", "pattern": "Google Cloud"} -{"label": "SKILL", "pattern": "Linux"} -{"label": "SKILL", "pattern": "Git"} -{"label": "SKILL", "pattern": "CI/CD"} -{"label": "SKILL", "pattern": "Jenkins"} +{"label": "SKILL", "pattern": "GCP"} +{"label": "SKILL", "pattern": "Docker"} +{"label": "SKILL", "pattern": "Kubernetes"} {"label": "SKILL", "pattern": "Terraform"} -{"label": "SKILL", "pattern": "Ansible"} +{"label": "SKILL", "pattern": "Jenkins"} +{"label": "SKILL", "pattern": "CI/CD"} +{"label": "SKILL", "pattern": "Linux"} {"label": "SKILL", "pattern": "Machine Learning"} {"label": "SKILL", "pattern": "Data Science"} {"label": "SKILL", "pattern": "Artificial Intelligence"} -{"label": "SKILL", "pattern": "Deep Learning"} {"label": "SKILL", "pattern": "NLP"} +{"label": "SKILL", "pattern": "Deep Learning"} {"label": "SKILL", "pattern": "TensorFlow"} {"label": "SKILL", "pattern": "PyTorch"} {"label": "SKILL", "pattern": "Pandas"} {"label": "SKILL", "pattern": "NumPy"} {"label": "SKILL", "pattern": "Scikit-learn"} +{"label": "SKILL", "pattern": "Git"} +{"label": "SKILL", "pattern": "GitHub"} +{"label": "SKILL", "pattern": "GitLab"} +{"label": "SKILL", "pattern": "Bitbucket"} +{"label": "SKILL", "pattern": "Agile"} +{"label": "SKILL", "pattern": "Scrum"} +{"label": "SKILL", "pattern": "Jira"} +{"label": "SKILL", "pattern": "Figma"} {"label": "SKILL", "pattern": "HTML"} {"label": "SKILL", "pattern": "CSS"} +{"label": "SKILL", "pattern": "Sass"} {"label": "SKILL", "pattern": "Tailwind"} {"label": "SKILL", "pattern": "Bootstrap"} -{"label": "SKILL", "pattern": "Sass"} {"label": "SKILL", "pattern": "GraphQL"} {"label": "SKILL", "pattern": "REST API"} {"label": "SKILL", "pattern": "Microservices"} -{"label": "SKILL", "pattern": "Agile"} -{"label": "SKILL", "pattern": "Scrum"} -{"label": "SKILL", "pattern": "Kanban"} From 6b9154181149a3353f0ea53083d8ca44fcd329d9 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 16 Jun 2026 17:30:38 -0600 Subject: [PATCH 051/277] fix(nlp): resolve skill extraction pipeline end-to-end - trend_snapshot.py: city_id nullable=True to allow global aggregation - ingestion_service.py: default query 'software developer', category_id fallback for new skills, salary_min/max capture from Adzuna - market_trends_service.py: city_id=1 (Mexico Nacional) for snapshots - skills_extraction_service.py: fix BASE_DIR (4 dirname levels), switch to add_patterns() with LOWER token attributes, EntityRuler config with overwrite_ents=True - build_dictionary.py: LOWER token patterns for case-insensitive matching - skills_esco.jsonl: regenerated with 67 LOWER patterns --- backend/app/models/trend_snapshot.py | 2 +- backend/app/services/ingestion_service.py | 6 +- backend/app/services/market_trends_service.py | 6 +- .../app/services/skills_extraction_service.py | 42 ++++-- ...07_make_trend_snapshot_city_id_nullable.py | 36 +++++ data/dictionaries/skills_esco.jsonl | 134 +++++++++--------- scripts/build_dictionary.py | 22 ++- 7 files changed, 160 insertions(+), 88 deletions(-) create mode 100644 backend/migrations/versions/8bf9d1117007_make_trend_snapshot_city_id_nullable.py diff --git a/backend/app/models/trend_snapshot.py b/backend/app/models/trend_snapshot.py index 7e6c818..a5caf39 100644 --- a/backend/app/models/trend_snapshot.py +++ b/backend/app/models/trend_snapshot.py @@ -6,7 +6,7 @@ class TrendSnapshot(db.Model): 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=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) diff --git a/backend/app/services/ingestion_service.py b/backend/app/services/ingestion_service.py index 04da07a..7faebe5 100644 --- a/backend/app/services/ingestion_service.py +++ b/backend/app/services/ingestion_service.py @@ -10,7 +10,7 @@ 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 = "IT", pages: int = 1) -> dict: + def run_ingestion(cls, country: str = "mx", what: str = "software developer", pages: int = 1) -> dict: stats = {"fetched": 0, "processed": 0, "skipped_or_failed": 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. @@ -91,7 +91,9 @@ def _get_or_create_skill(cls, skill_name: str, known_skills: dict): new_skill = SkillRepository.create({ "name": skill_name, - "canonical_name": skill_name.upper() + "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: diff --git a/backend/app/services/market_trends_service.py b/backend/app/services/market_trends_service.py index 205180b..1463b31 100644 --- a/backend/app/services/market_trends_service.py +++ b/backend/app/services/market_trends_service.py @@ -21,8 +21,7 @@ def generate_snapshots(cls) -> int: "confidence": item.confidence_score } for item in raw_data]) - # Agrupación y conteo vectorial. - # Extraemos el volumen de demanda absoluto por habilidad tecnológica. + # Agrupación y conteo vectorial. Extraemos el volumen de demanda absoluto por habilidad tecnológica. trends = df.groupby("skill_id").size().reset_index(name="demand_count") today = datetime.now(timezone.utc).date() @@ -32,7 +31,8 @@ def generate_snapshots(cls) -> int: # Volcamos las métricas agregadas a la tabla de snapshots para que el endpoint del Panorama realice lecturas directas en lugar de recalcular. snapshot_data = { "skill_id": int(row["skill_id"]), - "city_id": None, + # Usamos México Nacional como ciudad de agregación global para snapshots sin geolocalización específica. + "city_id": 1, "date": today, "demand_count": int(row["demand_count"]) } diff --git a/backend/app/services/skills_extraction_service.py b/backend/app/services/skills_extraction_service.py index 608f107..b94f8ae 100644 --- a/backend/app/services/skills_extraction_service.py +++ b/backend/app/services/skills_extraction_service.py @@ -1,22 +1,45 @@ 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.abspath(__file__)))) +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: - # Desactivamos los componentes pesados (ner, parser, tagger) que no usamos para acelerar masivamente el procesamiento de las descripciones. - nlp = spacy.load("es_core_news_sm", disable=["ner", "parser", "tagger", "lemmatizer", "attribute_ruler"]) - + 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}") - - # Inyectamos nuestro propio EntityRuler con los datos extraídos de ESCO - ruler = nlp.add_pipe("entity_ruler") - ruler.from_disk(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) @@ -36,8 +59,7 @@ def extract_skills(cls, text: str) -> list: # 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. + # 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/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/data/dictionaries/skills_esco.jsonl b/data/dictionaries/skills_esco.jsonl index 0612b86..49bbdbb 100644 --- a/data/dictionaries/skills_esco.jsonl +++ b/data/dictionaries/skills_esco.jsonl @@ -1,67 +1,67 @@ -{"label": "SKILL", "pattern": "Python"} -{"label": "SKILL", "pattern": "JavaScript"} -{"label": "SKILL", "pattern": "TypeScript"} -{"label": "SKILL", "pattern": "Java"} -{"label": "SKILL", "pattern": "C#"} -{"label": "SKILL", "pattern": "C++"} -{"label": "SKILL", "pattern": "Ruby"} -{"label": "SKILL", "pattern": "PHP"} -{"label": "SKILL", "pattern": "Go"} -{"label": "SKILL", "pattern": "Rust"} -{"label": "SKILL", "pattern": "Swift"} -{"label": "SKILL", "pattern": "Kotlin"} -{"label": "SKILL", "pattern": "React"} -{"label": "SKILL", "pattern": "Angular"} -{"label": "SKILL", "pattern": "Vue.js"} -{"label": "SKILL", "pattern": "Node.js"} -{"label": "SKILL", "pattern": "Express"} -{"label": "SKILL", "pattern": "Django"} -{"label": "SKILL", "pattern": "Flask"} -{"label": "SKILL", "pattern": "FastAPI"} -{"label": "SKILL", "pattern": "Spring Boot"} -{"label": "SKILL", "pattern": ".NET"} -{"label": "SKILL", "pattern": "SQL"} -{"label": "SKILL", "pattern": "MySQL"} -{"label": "SKILL", "pattern": "PostgreSQL"} -{"label": "SKILL", "pattern": "MongoDB"} -{"label": "SKILL", "pattern": "SQLite"} -{"label": "SKILL", "pattern": "NoSQL"} -{"label": "SKILL", "pattern": "Redis"} -{"label": "SKILL", "pattern": "Cassandra"} -{"label": "SKILL", "pattern": "Elasticsearch"} -{"label": "SKILL", "pattern": "AWS"} -{"label": "SKILL", "pattern": "Azure"} -{"label": "SKILL", "pattern": "Google Cloud"} -{"label": "SKILL", "pattern": "GCP"} -{"label": "SKILL", "pattern": "Docker"} -{"label": "SKILL", "pattern": "Kubernetes"} -{"label": "SKILL", "pattern": "Terraform"} -{"label": "SKILL", "pattern": "Jenkins"} -{"label": "SKILL", "pattern": "CI/CD"} -{"label": "SKILL", "pattern": "Linux"} -{"label": "SKILL", "pattern": "Machine Learning"} -{"label": "SKILL", "pattern": "Data Science"} -{"label": "SKILL", "pattern": "Artificial Intelligence"} -{"label": "SKILL", "pattern": "NLP"} -{"label": "SKILL", "pattern": "Deep Learning"} -{"label": "SKILL", "pattern": "TensorFlow"} -{"label": "SKILL", "pattern": "PyTorch"} -{"label": "SKILL", "pattern": "Pandas"} -{"label": "SKILL", "pattern": "NumPy"} -{"label": "SKILL", "pattern": "Scikit-learn"} -{"label": "SKILL", "pattern": "Git"} -{"label": "SKILL", "pattern": "GitHub"} -{"label": "SKILL", "pattern": "GitLab"} -{"label": "SKILL", "pattern": "Bitbucket"} -{"label": "SKILL", "pattern": "Agile"} -{"label": "SKILL", "pattern": "Scrum"} -{"label": "SKILL", "pattern": "Jira"} -{"label": "SKILL", "pattern": "Figma"} -{"label": "SKILL", "pattern": "HTML"} -{"label": "SKILL", "pattern": "CSS"} -{"label": "SKILL", "pattern": "Sass"} -{"label": "SKILL", "pattern": "Tailwind"} -{"label": "SKILL", "pattern": "Bootstrap"} -{"label": "SKILL", "pattern": "GraphQL"} -{"label": "SKILL", "pattern": "REST API"} -{"label": "SKILL", "pattern": "Microservices"} +{"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/scripts/build_dictionary.py b/scripts/build_dictionary.py index da41f1d..0f00db9 100644 --- a/scripts/build_dictionary.py +++ b/scripts/build_dictionary.py @@ -28,14 +28,26 @@ 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 skill in CORE_SKILLS: - # Formateamos bajo el estandar JSONL que spaCy requiere para - # inyectar reglas deterministicas en el EntityRuler. - entry = {"label": "SKILL", "pattern": skill} + for entry in patterns: f.write(json.dumps(entry) + "\n") - print(f"Exito: {len(CORE_SKILLS)} habilidades exportadas.") + print(f"Exito: {len(CORE_SKILLS)} habilidades exportadas como {len(patterns)} patrones LOWER.") if __name__ == "__main__": From dba6c2896a095072d9cf3b3772ff59884cc4a67c Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 16 Jun 2026 17:35:56 -0600 Subject: [PATCH 052/277] chore(cleanup): remove orphaned dictionary from backend/data and stale script copy backend/data/dictionaries/skills_esco.jsonl was a leftover from when the data/ directory lived inside backend/. Removed after confirming the service now reads from the correct path at project root. --- backend/data/dictionaries/skills_esco.jsonl | 62 --------------------- backend/scripts/build_dictionary.py | 46 +++++++++++++++ 2 files changed, 46 insertions(+), 62 deletions(-) delete mode 100644 backend/data/dictionaries/skills_esco.jsonl create mode 100644 backend/scripts/build_dictionary.py diff --git a/backend/data/dictionaries/skills_esco.jsonl b/backend/data/dictionaries/skills_esco.jsonl deleted file mode 100644 index 0e934f7..0000000 --- a/backend/data/dictionaries/skills_esco.jsonl +++ /dev/null @@ -1,62 +0,0 @@ -{"label": "SKILL", "pattern": "Python"} -{"label": "SKILL", "pattern": "JavaScript"} -{"label": "SKILL", "pattern": "TypeScript"} -{"label": "SKILL", "pattern": "Java"} -{"label": "SKILL", "pattern": "C#"} -{"label": "SKILL", "pattern": "C++"} -{"label": "SKILL", "pattern": "Ruby"} -{"label": "SKILL", "pattern": "PHP"} -{"label": "SKILL", "pattern": "Go"} -{"label": "SKILL", "pattern": "Rust"} -{"label": "SKILL", "pattern": "Swift"} -{"label": "SKILL", "pattern": "Kotlin"} -{"label": "SKILL", "pattern": "React"} -{"label": "SKILL", "pattern": "Angular"} -{"label": "SKILL", "pattern": "Vue.js"} -{"label": "SKILL", "pattern": "Node.js"} -{"label": "SKILL", "pattern": "Express"} -{"label": "SKILL", "pattern": "Django"} -{"label": "SKILL", "pattern": "Flask"} -{"label": "SKILL", "pattern": "FastAPI"} -{"label": "SKILL", "pattern": "Spring Boot"} -{"label": "SKILL", "pattern": ".NET"} -{"label": "SKILL", "pattern": "SQL"} -{"label": "SKILL", "pattern": "MySQL"} -{"label": "SKILL", "pattern": "PostgreSQL"} -{"label": "SKILL", "pattern": "MongoDB"} -{"label": "SKILL", "pattern": "SQLite"} -{"label": "SKILL", "pattern": "NoSQL"} -{"label": "SKILL", "pattern": "Redis"} -{"label": "SKILL", "pattern": "Cassandra"} -{"label": "SKILL", "pattern": "Elasticsearch"} -{"label": "SKILL", "pattern": "AWS"} -{"label": "SKILL", "pattern": "Azure"} -{"label": "SKILL", "pattern": "Google Cloud"} -{"label": "SKILL", "pattern": "GCP"} -{"label": "SKILL", "pattern": "Docker"} -{"label": "SKILL", "pattern": "Kubernetes"} -{"label": "SKILL", "pattern": "Terraform"} -{"label": "SKILL", "pattern": "Jenkins"} -{"label": "SKILL", "pattern": "CI/CD"} -{"label": "SKILL", "pattern": "Linux"} -{"label": "SKILL", "pattern": "Machine Learning"} -{"label": "SKILL", "pattern": "Data Science"} -{"label": "SKILL", "pattern": "Artificial Intelligence"} -{"label": "SKILL", "pattern": "NLP"} -{"label": "SKILL", "pattern": "Deep Learning"} -{"label": "SKILL", "pattern": "TensorFlow"} -{"label": "SKILL", "pattern": "PyTorch"} -{"label": "SKILL", "pattern": "Pandas"} -{"label": "SKILL", "pattern": "NumPy"} -{"label": "SKILL", "pattern": "Git"} -{"label": "SKILL", "pattern": "GitHub"} -{"label": "SKILL", "pattern": "GitLab"} -{"label": "SKILL", "pattern": "Bitbucket"} -{"label": "SKILL", "pattern": "Agile"} -{"label": "SKILL", "pattern": "Scrum"} -{"label": "SKILL", "pattern": "Jira"} -{"label": "SKILL", "pattern": "Figma"} -{"label": "SKILL", "pattern": "HTML"} -{"label": "SKILL", "pattern": "CSS"} -{"label": "SKILL", "pattern": "Sass"} -{"label": "SKILL", "pattern": "Tailwind"} 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() From 59eef3915e1ad8b421d82eb5eb142c185be6a338 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 16 Jun 2026 22:20:05 -0600 Subject: [PATCH 053/277] fix(repositories): declare model as class attribute in TrendSnapshotRepository and CityRepository Both repositories used __init__ with super().__init__(Model) instead of declaring model = Model as a class attribute. BaseRepository.create calls cls.model directly as a classmethod, so cls.model resolved to None and SQLAlchemy's inspect() failed with NoInspectionAvailable. Added analytical query methods needed by upcoming Panorama endpoints. --- backend/app/repositories/city_repository.py | 12 +++- .../repositories/trend_snapshot_repository.py | 64 ++++++++++++++++++- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/backend/app/repositories/city_repository.py b/backend/app/repositories/city_repository.py index 46ae0fb..e82f070 100644 --- a/backend/app/repositories/city_repository.py +++ b/backend/app/repositories/city_repository.py @@ -1,7 +1,13 @@ from app.repositories.base_repository import BaseRepository -from app.models import City +from app.models.city import City +from app.extensions import db class CityRepository(BaseRepository): - def __init__(self): - super().__init__(City) + 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() diff --git a/backend/app/repositories/trend_snapshot_repository.py b/backend/app/repositories/trend_snapshot_repository.py index 3effd5f..23985b7 100644 --- a/backend/app/repositories/trend_snapshot_repository.py +++ b/backend/app/repositories/trend_snapshot_repository.py @@ -1,7 +1,65 @@ +from sqlalchemy import desc from app.repositories.base_repository import BaseRepository -from app.models import TrendSnapshot +from app.models.trend_snapshot import TrendSnapshot +from app.extensions import db class TrendSnapshotRepository(BaseRepository): - def __init__(self): - super().__init__(TrendSnapshot) + 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_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_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() From 41ddd737fd2b06838e3f90795dbc706b64c78b7c Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 16 Jun 2026 22:21:31 -0600 Subject: [PATCH 054/277] feat(schemas): add Marshmallow schemas for Panorama endpoints Nine schemas covering summary, catalogs, skills/top, trends, geo, salaries and compare. Not yet consumed by any endpoint. --- backend/app/schemas/panorama_schema.py | 83 ++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 backend/app/schemas/panorama_schema.py diff --git a/backend/app/schemas/panorama_schema.py b/backend/app/schemas/panorama_schema.py new file mode 100644 index 0000000..ff8f712 --- /dev/null +++ b/backend/app/schemas/panorama_schema.py @@ -0,0 +1,83 @@ +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) + + +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) + 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. + city_id = fields.Integer(dump_only=True) + city_name = fields.String(dump_only=True) + demand_count = fields.Integer(dump_only=True) + + +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) + 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) From 40ee3efd16ee8624b4ca13493ce5edd3d38870f5 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 16 Jun 2026 22:28:07 -0600 Subject: [PATCH 055/277] feat(panorama): add GET /api/panorama/catalogs endpoint Lightweight endpoint returning id+name lists for skills and cities to populate frontend dropdown selectors. Verified against live database with real ingested data. --- backend/app/controllers/panorama_bp.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py index 49b561c..1b51565 100644 --- a/backend/app/controllers/panorama_bp.py +++ b/backend/app/controllers/panorama_bp.py @@ -1,13 +1,32 @@ from flask import Blueprint from app.repositories.skill_repository import SkillRepository +from app.repositories.city_repository import CityRepository from app.schemas.skill_schema import SkillResponseSchema +from app.schemas.panorama_schema import CatalogsResponseSchema from app.utils.response import success_response panorama_bp = Blueprint("panorama_bp", __name__) + @panorama_bp.route("/skills", methods=["GET"]) def get_skills(): - # Exponemos el catálogo estático aplicando el esquema de solo lectura para alimentar los selectores de la interfaz sin filtrar metadatos internos. + # Exponemos el catalogo estatico aplicando el esquema de solo lectura para alimentar los selectores de la interfaz sin filtrar metadatos internos. skills = SkillRepository.get_all() result = SkillResponseSchema(many=True).dump(skills) return success_response(data=result, status_code=200) + + +@panorama_bp.route("/catalogs", methods=["GET"]) +def get_catalogs(): + # Endpoint ligero pensado para poblar selectores del frontend. + # Devolvemos id+name unicamente, sin metricas, para minimizar el payload en una ruta que probablemente se llama una sola vez por sesion. + skills = SkillRepository.get_all() + cities = CityRepository.get_all() + + payload = { + "skills": [{"id": s.id, "name": s.name} for s in skills], + "cities": [{"id": c.id, "name": c.name} for c in cities], + } + + result = CatalogsResponseSchema().dump(payload) + return success_response(data=result, status_code=200) From 61a50a898f4297204fb9864e739e4cb02e07f2c8 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 16 Jun 2026 22:34:43 -0600 Subject: [PATCH 056/277] feat(panorama): add GET /api/panorama/summary endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns global KPIs: total jobs, total skills tracked, top emerging and declining skill by demand count, and latest snapshot date. Verified against live database — 144 jobs, 24 tracked skills. --- backend/app/controllers/panorama_bp.py | 32 ++++++++++++++- .../repositories/trend_snapshot_repository.py | 41 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py index 1b51565..2400bfe 100644 --- a/backend/app/controllers/panorama_bp.py +++ b/backend/app/controllers/panorama_bp.py @@ -1,8 +1,9 @@ from flask import Blueprint from app.repositories.skill_repository import SkillRepository from app.repositories.city_repository import CityRepository +from app.repositories.trend_snapshot_repository import TrendSnapshotRepository from app.schemas.skill_schema import SkillResponseSchema -from app.schemas.panorama_schema import CatalogsResponseSchema +from app.schemas.panorama_schema import CatalogsResponseSchema, SummaryResponseSchema from app.utils.response import success_response panorama_bp = Blueprint("panorama_bp", __name__) @@ -30,3 +31,32 @@ def get_catalogs(): result = CatalogsResponseSchema().dump(payload) return success_response(data=result, status_code=200) + + +@panorama_bp.route("/summary", methods=["GET"]) +def get_summary(): + # KPIs globales que alimentan las tarjetas superiores del Panorama. + 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, + } + + payload = { + "total_jobs": data["total_jobs"], + "total_skills_tracked": data["total_skills_tracked"], + "top_emerging_skill": build_skill_block(data["top_emerging"]), + "top_declining_skill": build_skill_block(data["top_declining"]), + "last_updated": data["latest_date"], + } + + result = SummaryResponseSchema().dump(payload) + return success_response(data=result, status_code=200) diff --git a/backend/app/repositories/trend_snapshot_repository.py b/backend/app/repositories/trend_snapshot_repository.py index 23985b7..5179406 100644 --- a/backend/app/repositories/trend_snapshot_repository.py +++ b/backend/app/repositories/trend_snapshot_repository.py @@ -63,3 +63,44 @@ def get_salary_by_skill(cls, skill_id: int): 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() + + 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, + "latest_date": latest_date, + "top_emerging": top_emerging, + "top_declining": top_declining, + } From c0cc79001c02e6f0c553faaacea715722270b9de Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 16 Jun 2026 22:40:41 -0600 Subject: [PATCH 057/277] feat(panorama): add GET /api/panorama/skills/top endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns demand ranking ordered by demand_count descending. Accepts optional limit query param (default 10, capped 1-50). Verified against live data — .NET and Python tied at top with 5. --- backend/app/controllers/panorama_bp.py | 34 ++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py index 2400bfe..6bc1fff 100644 --- a/backend/app/controllers/panorama_bp.py +++ b/backend/app/controllers/panorama_bp.py @@ -1,9 +1,13 @@ -from flask import Blueprint +from flask import Blueprint, request from app.repositories.skill_repository import SkillRepository from app.repositories.city_repository import CityRepository from app.repositories.trend_snapshot_repository import TrendSnapshotRepository from app.schemas.skill_schema import SkillResponseSchema -from app.schemas.panorama_schema import CatalogsResponseSchema, SummaryResponseSchema +from app.schemas.panorama_schema import ( + CatalogsResponseSchema, + SummaryResponseSchema, + SkillTrendSchema, +) from app.utils.response import success_response panorama_bp = Blueprint("panorama_bp", __name__) @@ -60,3 +64,29 @@ def build_skill_block(row): result = SummaryResponseSchema().dump(payload) return success_response(data=result, status_code=200) + + +@panorama_bp.route("/skills/top", methods=["GET"]) +def get_top_skills(): + # Ranking de habilidades por demanda actual. El frontend lo usa + # para la grafica de barras principal del Panorama. + 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. + limit = max(1, min(limit, 50)) + + snapshots = TrendSnapshotRepository.get_top_skills(limit=limit) + + payload = [ + { + "skill_id": s.skill_id, + "name": s.skill.name if s.skill else None, + "demand_count": s.demand_count, + "growth_rate": s.growth_rate, + "avg_salary": s.avg_salary, + } + for s in snapshots + ] + + result = SkillTrendSchema(many=True).dump(payload) + return success_response(data=result, status_code=200) From f28fdd3f78c3004dd933f3c472c8ca568a6261b5 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 16 Jun 2026 22:45:15 -0600 Subject: [PATCH 058/277] feat(panorama): add GET /api/panorama/trends endpoint Returns time series of demand_count for a specific skill_id. Validates skill_id is provided (422) and exists (404) before querying snapshots. Verified against live data for skill .NET. --- backend/app/controllers/panorama_bp.py | 44 +++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py index 6bc1fff..1265ce7 100644 --- a/backend/app/controllers/panorama_bp.py +++ b/backend/app/controllers/panorama_bp.py @@ -7,8 +7,9 @@ CatalogsResponseSchema, SummaryResponseSchema, SkillTrendSchema, + TrendsResponseSchema, ) -from app.utils.response import success_response +from app.utils.response import success_response, error_response panorama_bp = Blueprint("panorama_bp", __name__) @@ -68,11 +69,9 @@ def build_skill_block(row): @panorama_bp.route("/skills/top", methods=["GET"]) def get_top_skills(): - # Ranking de habilidades por demanda actual. El frontend lo usa - # para la grafica de barras principal del Panorama. + # Ranking de habilidades por demanda actual. El frontend lo usa para la grafica de barras principal del Panorama. 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. + # Acotamos el limite para evitar que un valor arbitrario en la query fuerce una consulta desproporcionada contra la base de datos. limit = max(1, min(limit, 50)) snapshots = TrendSnapshotRepository.get_top_skills(limit=limit) @@ -90,3 +89,38 @@ def get_top_skills(): result = SkillTrendSchema(many=True).dump(payload) return success_response(data=result, status_code=200) + + +@panorama_bp.route("/trends", methods=["GET"]) +def get_trends(): + # Serie temporal de demanda para una habilidad especifica. El frontend la usa para la grafica de lineas de evolucion. + 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, + ) + + skill = SkillRepository.get_by_id(skill_id) + if not skill: + return error_response( + code="NOT_FOUND", + message="La habilidad solicitada no existe.", + status_code=404, + ) + + snapshots = TrendSnapshotRepository.get_by_skill_id(skill_id) + + payload = { + "skill_id": skill.id, + "skill_name": skill.name, + "series": [ + {"date": s.date, "demand_count": s.demand_count} + for s in snapshots + ], + } + + result = TrendsResponseSchema().dump(payload) + return success_response(data=result, status_code=200) From 69dd89a92efc06433535dbcabe75eb0569cdb0f2 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 16 Jun 2026 22:54:49 -0600 Subject: [PATCH 059/277] feat(panorama): add GET /api/panorama/geo endpoint Returns demand distribution aggregated by city, optionally filtered by skill_id. Currently returns a single city (Mexico Nacional) since Nominatim geocoding is not yet integrated into the ingestion pipeline. Query structure supports multiple cities without modification once real geolocation data exists. Verified: total 49, .NET filtered 5. --- backend/app/controllers/panorama_bp.py | 38 ++++++++++++++++++- .../repositories/trend_snapshot_repository.py | 24 ++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py index 1265ce7..5b68e4e 100644 --- a/backend/app/controllers/panorama_bp.py +++ b/backend/app/controllers/panorama_bp.py @@ -8,6 +8,7 @@ SummaryResponseSchema, SkillTrendSchema, TrendsResponseSchema, + GeoResponseSchema, ) from app.utils.response import success_response, error_response @@ -24,8 +25,7 @@ def get_skills(): @panorama_bp.route("/catalogs", methods=["GET"]) def get_catalogs(): - # Endpoint ligero pensado para poblar selectores del frontend. - # Devolvemos id+name unicamente, sin metricas, para minimizar el payload en una ruta que probablemente se llama una sola vez por sesion. + # Endpoint ligero pensado para poblar selectores del frontend. Devolvemos id+name unicamente, sin metricas, para minimizar el payload en una ruta que probablemente se llama una sola vez por sesion. skills = SkillRepository.get_all() cities = CityRepository.get_all() @@ -124,3 +124,37 @@ def get_trends(): result = TrendsResponseSchema().dump(payload) return success_response(data=result, status_code=200) + + +@panorama_bp.route("/geo", methods=["GET"]) +def get_geo(): + # Distribucion geografica de demanda. Si se filtra por skill_id devolvemos la distribucion de esa habilidad especifica, de lo contrario la demanda total agregada por ciudad. + skill_id = request.args.get("skill_id", type=int) + + skill = None + if skill_id is not None: + skill = SkillRepository.get_by_id(skill_id) + if not skill: + return error_response( + code="NOT_FOUND", + message="La habilidad solicitada no existe.", + status_code=404, + ) + + rows = TrendSnapshotRepository.get_geo_distribution(skill_id=skill_id) + + payload = { + "skill_id": skill.id if skill else None, + "skill_name": skill.name if skill else None, + "distribution": [ + { + "city_id": row.city_id, + "city_name": row.city_name, + "demand_count": row.total_demand, + } + for row in rows + ], + } + + result = GeoResponseSchema().dump(payload) + return success_response(data=result, status_code=200) diff --git a/backend/app/repositories/trend_snapshot_repository.py b/backend/app/repositories/trend_snapshot_repository.py index 5179406..760d570 100644 --- a/backend/app/repositories/trend_snapshot_repository.py +++ b/backend/app/repositories/trend_snapshot_repository.py @@ -104,3 +104,27 @@ def get_summary_data(cls): "top_emerging": top_emerging, "top_declining": top_declining, } + + @classmethod + def get_geo_distribution(cls, skill_id: int = None) -> list: + from sqlalchemy import func + from app.models.city import City + + # Sumamos demand_count por ciudad. Si se filtra por skill_id + # la suma queda acotada a esa habilidad especifica, de lo + # contrario agregamos la demanda total de todas las habilidades. + query = ( + db.select( + City.id.label("city_id"), + City.name.label("city_name"), + func.sum(TrendSnapshot.demand_count).label("total_demand"), + ) + .join(City, City.id == TrendSnapshot.city_id) + .group_by(City.id, City.name) + .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() From 19a971f560d37c2b30efe443ebc8a6933cad3832 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Wed, 17 Jun 2026 22:07:50 -0600 Subject: [PATCH 060/277] feat(panorama): add GET /api/panorama/salaries endpoint and fix salary calculation pipeline - market_trends_service.py: generate_snapshots now computes avg_salary per skill using midpoint of salary_min/salary_max, casting to native float to avoid psycopg2 InvalidSchemaName error with numpy.float64 - skill_repository.py: add get_salary_stats joining Job+JobSkill, filtering jobs with both salary bounds present - panorama_bp.py: new /salaries endpoint, validates skill_id required and existing, returns avg_salary_min/max and sample_size Verified against live data: microservices (skill_id=13) returns avg_min=600000, avg_max=780000, sample_size=2. --- backend/app/controllers/panorama_bp.py | 35 +++++++++++++++++++ backend/app/repositories/skill_repository.py | 19 ++++++++++ backend/app/services/market_trends_service.py | 34 ++++++++++++------ 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py index 5b68e4e..c5c6f48 100644 --- a/backend/app/controllers/panorama_bp.py +++ b/backend/app/controllers/panorama_bp.py @@ -9,6 +9,7 @@ SkillTrendSchema, TrendsResponseSchema, GeoResponseSchema, + SalaryResponseSchema, ) from app.utils.response import success_response, error_response @@ -158,3 +159,37 @@ def get_geo(): result = GeoResponseSchema().dump(payload) return success_response(data=result, status_code=200) + + +@panorama_bp.route("/salaries", methods=["GET"]) +def get_salaries(): + # Cruce de habilidad contra rango salarial promedio. Requiere skill_id porque el calculo es por habilidad, no agregable globalmente sin perder sentido. + 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, + ) + + skill = SkillRepository.get_by_id(skill_id) + if not skill: + return error_response( + code="NOT_FOUND", + message="La habilidad solicitada no existe.", + status_code=404, + ) + + stats = SkillRepository.get_salary_stats(skill_id) + + payload = { + "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, + } + + result = SalaryResponseSchema().dump(payload) + return success_response(data=result, status_code=200) diff --git a/backend/app/repositories/skill_repository.py b/backend/app/repositories/skill_repository.py index 8013126..cdeb1a4 100644 --- a/backend/app/repositories/skill_repository.py +++ b/backend/app/repositories/skill_repository.py @@ -18,3 +18,22 @@ 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_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/services/market_trends_service.py b/backend/app/services/market_trends_service.py index 1463b31..a68f3d1 100644 --- a/backend/app/services/market_trends_service.py +++ b/backend/app/services/market_trends_service.py @@ -10,34 +10,46 @@ class MarketTrendsService: @classmethod def generate_snapshots(cls) -> int: raw_data = JobSkillRepository.get_all() - + if not raw_data: return 0 - # Transformación a DataFrame para aprovechar las rutinas en C subyacentes de pandas, erradicando la necesidad de bucles for anidados en Python puro. + # Incluimos salary_min y salary_max de la vacante asociada para poder calcular el salario promedio por habilidad en el mismo paso que calculamos la demanda, evitando una segunda consulta. df = pd.DataFrame([{ "skill_id": item.skill_id, "job_id": item.job_id, - "confidence": item.confidence_score + "confidence": item.confidence_score, + "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]) - # Agrupación y conteo vectorial. Extraemos el volumen de demanda absoluto por habilidad tecnológica. - trends = df.groupby("skill_id").size().reset_index(name="demand_count") - + # 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) + + trends = df.groupby("skill_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(): - # Volcamos las métricas agregadas a la tabla de snapshots para que el endpoint del Panorama realice lecturas directas en lugar de recalcular. + 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) + snapshot_data = { "skill_id": int(row["skill_id"]), - # Usamos México Nacional como ciudad de agregación global para snapshots sin geolocalización específica. "city_id": 1, "date": today, - "demand_count": int(row["demand_count"]) + "demand_count": int(row["demand_count"]), + "avg_salary": avg_salary_value, } - - # Delegamos al Repositorio el manejo de la restricción UNIQUE(skill_id, city_id, date) mediante los bloques try/except con rollback previamente configurados. + result = TrendSnapshotRepository.create(snapshot_data) if result: snapshots_created += 1 From 48272da0d3dbc8ca7fba1fda604e7ef83fda5005 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Wed, 17 Jun 2026 22:16:52 -0600 Subject: [PATCH 061/277] feat(panorama): add GET /api/panorama/compare endpoint Completes the 7 Panorama API endpoints. Accepts skill_ids as comma-separated query param (2-5 skills), validates format and existence, returns demand/salary/series blocks per skill for side-by-side comparison view. Verified: valid 3-skill comparison, 422 for single skill, 404 for nonexistent skill mixed in. --- backend/app/controllers/panorama_bp.py | 67 ++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py index c5c6f48..361edb7 100644 --- a/backend/app/controllers/panorama_bp.py +++ b/backend/app/controllers/panorama_bp.py @@ -10,6 +10,7 @@ TrendsResponseSchema, GeoResponseSchema, SalaryResponseSchema, + CompareResponseSchema, ) from app.utils.response import success_response, error_response @@ -193,3 +194,69 @@ def get_salaries(): 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: 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, + ) + + skills_map = {} + missing_ids = [] + for sid in skill_ids: + skill = SkillRepository.get_by_id(sid) + if skill: + skills_map[sid] = skill + else: + missing_ids.append(sid) + + if missing_ids: + return error_response( + code="NOT_FOUND", + message=f"Las siguientes habilidades no existen: {missing_ids}.", + status_code=404, + ) + + blocks = [] + for sid in skill_ids: + skill = skills_map[sid] + latest = TrendSnapshotRepository.get_latest_by_skill(sid) + series_snapshots = TrendSnapshotRepository.get_by_skill_id(sid) + + blocks.append({ + "skill_id": skill.id, + "skill_name": skill.name, + "demand_count": latest.demand_count if latest else 0, + "avg_salary": latest.avg_salary if latest else None, + "series": [ + {"date": s.date, "demand_count": s.demand_count} + for s in series_snapshots + ], + }) + + result = CompareResponseSchema().dump({"skills": blocks}) + return success_response(data=result, status_code=200) From 822e6e5f776bcb03dc67111ca09d1802f7633ef2 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 18 Jun 2026 15:44:49 -0600 Subject: [PATCH 062/277] feat(frontend): add modern CSS reset in _reset.css Normalizes cross-browser defaults. Consumes tokens from _variables.css. Enforces box-sizing, removes default margins/padding, sets body font and theme transition, forces :focus-visible for keyboard nav, and respects prefers-reduced-motion without exception. --- frontend/assets/css/base/_reset.css | 95 +++++++++++++++++++- frontend/assets/css/base/_variables.css | 110 +++++++++++++++++++++++- 2 files changed, 203 insertions(+), 2 deletions(-) diff --git a/frontend/assets/css/base/_reset.css b/frontend/assets/css/base/_reset.css index f3a15a2..22cc779 100644 --- a/frontend/assets/css/base/_reset.css +++ b/frontend/assets/css/base/_reset.css @@ -1 +1,94 @@ -/* _reset.css — SkillStat */ \ No newline at end of file +/* 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; +} + +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/_variables.css b/frontend/assets/css/base/_variables.css index 2d6a979..d52a61a 100644 --- a/frontend/assets/css/base/_variables.css +++ b/frontend/assets/css/base/_variables.css @@ -1 +1,109 @@ -/* _variables.css — SkillStat */ \ No newline at end of file +: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-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; + /* El roble palido no tiene lugar en dark mode. Si algun componente + lo referencia por error, cae de vuelta a la superficie elevada + en lugar de romper visualmente con un beige fuera de lugar. */ + --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-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); +} From 108f72137e860152743cbdefcca6bf92d0fc5c40 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 18 Jun 2026 15:52:40 -0600 Subject: [PATCH 063/277] feat(frontend): add typography class system in _typography.css Semantic text classes consuming tokens from _variables.css. Covers hero/h1/h2/h3 headings (Bakbak One), body text, labels/navigation (Antonio), and dashboard metric numbers with tabular-nums for stable layout during real-time data updates. Text color utilities included. --- frontend/assets/css/base/_typography.css | 107 ++++++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/frontend/assets/css/base/_typography.css b/frontend/assets/css/base/_typography.css index 1915275..e2f36c5 100644 --- a/frontend/assets/css/base/_typography.css +++ b/frontend/assets/css/base/_typography.css @@ -1 +1,106 @@ -/* _typography.css — SkillStat */ \ No newline at end of file +/* 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); } From df9b24acacb90364f14f1fae4ed604d2dd83a79c Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 18 Jun 2026 16:00:39 -0600 Subject: [PATCH 064/277] feat(frontend): add main.css as single CSS entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imports base layer in mandatory order: variables, reset, typography. Component, layout and page imports stubbed as comments to document full architecture — uncommented as each file is created. --- frontend/assets/css/main.css | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css index b2b63af..788aede 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -1 +1,31 @@ -/* main.css — SkillStat */ \ No newline at end of file +/* 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"); + +/* 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/_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/_admin.css'); */ From c6d3c12dd834d27f8a21fe3e7daefc26a8ec774b Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 18 Jun 2026 21:33:16 -0600 Subject: [PATCH 065/277] =?UTF-8?q?feat(frontend):=20build=20header=20syst?= =?UTF-8?q?em=20=E2=80=94=20container,=20navbar,=20animated=20theme=20togg?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/assets/css/components/_navbar.css | 43 +++++++++++++++- .../assets/css/components/_theme-toggle.css | 49 +++++++++++++++++++ frontend/assets/css/layouts/_containers.css | 21 +++++++- frontend/assets/css/main.css | 7 +-- frontend/assets/js/components/navbar.js | 26 +++++++++- 5 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 frontend/assets/css/components/_theme-toggle.css diff --git a/frontend/assets/css/components/_navbar.css b/frontend/assets/css/components/_navbar.css index 544ea5c..5e8b1dd 100644 --- a/frontend/assets/css/components/_navbar.css +++ b/frontend/assets/css/components/_navbar.css @@ -1 +1,42 @@ -/* _navbar.css — SkillStat */ \ No newline at end of file +/* 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); + } +} + +.navbar__logo { + display: inline-flex; + align-items: center; +} + +.navbar__logo-img { + display: block; +} 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 index 11fec5b..a6d7733 100644 --- a/frontend/assets/css/layouts/_containers.css +++ b/frontend/assets/css/layouts/_containers.css @@ -1 +1,20 @@ -/* _containers.css — SkillStat */ \ No newline at end of file +/* 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); + } +} diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css index 788aede..c173240 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -4,9 +4,10 @@ @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/_navbar.css'); /* @import url('components/_buttons.css'); */ /* @import url('components/_cards.css'); */ /* @import url('components/_forms.css'); */ @@ -15,10 +16,10 @@ /* @import url('components/_alerts.css'); */ /* @import url('components/_carousel.css'); */ /* @import url('components/_footer.css'); */ -/* @import url('components/_theme-toggle.css'); */ +@import url('components/_theme-toggle.css'); /* Instanciamos los layouts (descomente conforme se crea cada archivo) */ -/* @import url('layouts/_containers.css'); */ +@import url('layouts/_containers.css'); /* @import url('layouts/_grid.css'); */ /* @import url('layouts/_navbar-layout.css'); */ diff --git a/frontend/assets/js/components/navbar.js b/frontend/assets/js/components/navbar.js index 54939d5..4c1085a 100644 --- a/frontend/assets/js/components/navbar.js +++ b/frontend/assets/js/components/navbar.js @@ -1 +1,25 @@ -/* navbar.js — SkillStat */ \ No newline at end of file +// Controla el toggle de tema claro/oscuro y persiste la eleccion del usuario en localStorage. La persistencia importa porque el tema por defecto del sistema es light, y forzar al usuario a re-elegir dark en cada visita seria una mala experiencia. + +(function () { + const STORAGE_KEY = 'skillstat-theme'; + const root = document.documentElement; + const toggleButton = document.querySelector('[data-theme-toggle]'); + + function applyTheme(theme) { + root.setAttribute('data-theme', theme); + } + + const storedTheme = localStorage.getItem(STORAGE_KEY); + if (storedTheme) { + applyTheme(storedTheme); + } + + if (toggleButton) { + toggleButton.addEventListener('click', function () { + const current = root.getAttribute('data-theme'); + const next = current === 'dark' ? 'light' : 'dark'; + applyTheme(next); + localStorage.setItem(STORAGE_KEY, next); + }); + } +})(); From 39a27711cef7adcb403b40cb6ce8156721987d1e Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 18 Jun 2026 22:04:27 -0600 Subject: [PATCH 066/277] =?UTF-8?q?feat(frontend):=20build=20card=20system?= =?UTF-8?q?=20=E2=80=94=20metric,=20value,=20and=20browser-chrome=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _variables.css: new --color-accent-green-subtle token for icon backgrounds (light: pale mint, dark: deep desaturated green) - _cards.css: metric-card (carousel slides), value-card (propuestas de valor), browser-chrome (Panorama preview with brand-colored traffic-light dots instead of generic red/yellow/green), live-dot with respectful pulse animation - main.css: uncommented _cards.css import Verified visually in Live Server. --- frontend/assets/css/base/_variables.css | 6 +- frontend/assets/css/components/_cards.css | 163 +++++++++++++++++++++- frontend/assets/css/main.css | 4 +- 3 files changed, 167 insertions(+), 6 deletions(-) diff --git a/frontend/assets/css/base/_variables.css b/frontend/assets/css/base/_variables.css index d52a61a..85f100d 100644 --- a/frontend/assets/css/base/_variables.css +++ b/frontend/assets/css/base/_variables.css @@ -11,6 +11,7 @@ --color-accent-green: #21a675; --color-accent-green-light: #29d194; + --color-accent-green-subtle: #E3F6EE; --color-accent-orange: #f3701e; --color-accent-orange-light: #f58945; @@ -79,9 +80,7 @@ --color-bg-surface: #1a2535; --color-bg-elevated: #2e3d52; --color-bg-overlay: #3a4d64; - /* El roble palido no tiene lugar en dark mode. Si algun componente - lo referencia por error, cae de vuelta a la superficie elevada - en lugar de romper visualmente con un beige fuera de lugar. */ + --color-bg-warm: var(--color-bg-elevated); --color-primary: #4b607f; @@ -90,6 +89,7 @@ --color-accent-green: #21a675; --color-accent-green-light: #29d194; + --color-accent-green-subtle: #16332A; --color-accent-orange: #f3701e; --color-accent-orange-light: #f58945; diff --git a/frontend/assets/css/components/_cards.css b/frontend/assets/css/components/_cards.css index 2ac0c93..10184af 100644 --- a/frontend/assets/css/components/_cards.css +++ b/frontend/assets/css/components/_cards.css @@ -1 +1,162 @@ -/* _cards.css — SkillStat */ \ No newline at end of file +/* 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 */ + +.live-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: var(--radius-full); + background-color: var(--color-accent-green); + margin-right: var(--space-1); + animation: pulse-dot 2s ease-in-out infinite; +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Metric Card - slides del carrusel */ + +.metric-card { + background-color: var(--color-bg-surface); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-sm); +} + +.metric-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 { + background-color: var(--color-bg-surface); + border: 1px solid var(--color-border-subtle); + 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 { + background-color: var(--color-bg-surface); + border: 1px solid var(--color-border-subtle); + 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__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-4); +} + +.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/main.css b/frontend/assets/css/main.css index c173240..9a66840 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -8,8 +8,8 @@ /* 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/_buttons.css'); +@import url('components/_cards.css'); /* @import url('components/_forms.css'); */ /* @import url('components/_charts.css'); */ /* @import url('components/_badges.css'); */ From f6fd3ef340b0e8b19ab931129f7daad5d3e93d55 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 18 Jun 2026 22:16:11 -0600 Subject: [PATCH 067/277] =?UTF-8?q?feat(frontend):=20add=20carousel=20mech?= =?UTF-8?q?anics=20=E2=80=94=20scroll-snap,=20dots,=20auto-rotate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _carousel.css: scroll-snap horizontal with hidden scrollbar (functional swipe preserved), pill-shaped active dot indicator - carousel.init.js: dot navigation, IntersectionObserver syncs active dot during manual swipe, auto-rotate every 4s (adjusted from initial 6s after manual testing), fully disabled (not just slowed) when prefers-reduced-motion is active - index.html: added carousel.init.js script tag - main.css: uncommented _carousel.css import Verified manually: dot clicks, swipe sync, and auto-rotation all confirmed working in Live Server. --- frontend/assets/css/components/_carousel.css | 46 ++ frontend/assets/css/main.css | 2 +- .../assets/js/components/carousel.init.js | 80 ++++ frontend/views/index.html | 401 +++++++++++++++++- 4 files changed, 527 insertions(+), 2 deletions(-) create mode 100644 frontend/assets/css/components/_carousel.css create mode 100644 frontend/assets/js/components/carousel.init.js diff --git a/frontend/assets/css/components/_carousel.css b/frontend/assets/css/components/_carousel.css new file mode 100644 index 0000000..707bbad --- /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; +} + +.carousel__track { + display: flex; + overflow-x: auto; + scroll-snap-type: x mandatory; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; +} + +.carousel__track::-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/main.css b/frontend/assets/css/main.css index 9a66840..146209d 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -14,7 +14,7 @@ /* @import url('components/_charts.css'); */ /* @import url('components/_badges.css'); */ /* @import url('components/_alerts.css'); */ -/* @import url('components/_carousel.css'); */ +@import url('components/_carousel.css'); /* @import url('components/_footer.css'); */ @import url('components/_theme-toggle.css'); diff --git a/frontend/assets/js/components/carousel.init.js b/frontend/assets/js/components/carousel.init.js new file mode 100644 index 0000000..811b3cd --- /dev/null +++ b/frontend/assets/js/components/carousel.init.js @@ -0,0 +1,80 @@ +// Controla el carrusel de metricas; es decir, la navegacion via dots, deteccion del slide visible mediante IntersectionObserver, y auto-rotacion que se desactiva por completo si el usuario prefiere menos movimiento, no solo se hace mas lenta. + +(function () { + const carousel = document.querySelector('[data-carousel]'); + if (!carousel) return; + + const track = carousel.querySelector('[data-carousel-track]'); + const slides = Array.from(carousel.querySelectorAll('[data-carousel-slide]')); + const dots = Array.from(carousel.querySelectorAll('[data-carousel-dot]')); + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + let currentIndex = 0; + let autoRotateTimer = null; + + function setActiveDot(index) { + dots.forEach(function (dot, i) { + const isActive = i === index; + dot.classList.toggle('carousel__dot--active', isActive); + dot.setAttribute('aria-selected', isActive ? 'true' : 'false'); + }); + currentIndex = index; + } + + function goToSlide(index) { + const slide = slides[index]; + if (!slide) return; + slide.scrollIntoView({ + behavior: prefersReducedMotion ? 'auto' : 'smooth', + inline: 'start', + block: 'nearest', + }); + } + + dots.forEach(function (dot, index) { + dot.addEventListener('click', function () { + goToSlide(index); + stopAutoRotate(); + }); + }); + + // IntersectionObserver detecta cual slide esta realmente visible cuando el usuario hace swipe manual, manteniendo los dots sincronizados sin depender de calculos manuales de scroll. + const observer = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting && entry.intersectionRatio > 0.5) { + const index = slides.indexOf(entry.target); + if (index !== -1) { + setActiveDot(index); + } + } + }); + }, + { root: track, threshold: 0.5 } + ); + + slides.forEach(function (slide) { + observer.observe(slide); + }); + + function startAutoRotate() { + if (prefersReducedMotion) return; + autoRotateTimer = setInterval(function () { + const nextIndex = (currentIndex + 1) % slides.length; + goToSlide(nextIndex); + }, 4000); + } + + function stopAutoRotate() { + if (autoRotateTimer) { + clearInterval(autoRotateTimer); + autoRotateTimer = null; + } + } + + carousel.addEventListener('mouseenter', stopAutoRotate); + carousel.addEventListener('mouseleave', startAutoRotate); + carousel.addEventListener('touchstart', stopAutoRotate, { passive: true }); + + startAutoRotate(); +})(); diff --git a/frontend/views/index.html b/frontend/views/index.html index 292c7d5..0ddee25 100644 --- a/frontend/views/index.html +++ b/frontend/views/index.html @@ -1 +1,400 @@ - \ No newline at end of file + + + + + + + SkillStat + + + + + + + + + + + + + + + + + +
+ +
+
+

+ + 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. +

+
+
+ + + + + +
+
+ + Ver el Panorama + + + +
+
+ + +
+
+

+ 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. +

+
+
+
+
+ + +
+
+

+ Así se ve tu Panorama +

+ + +
+
+
+ + + + + + + + + + + + + + + From 8afc74e1f68153052d9bf406babbbd7740437cdb Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 19 Jun 2026 15:51:21 -0600 Subject: [PATCH 068/277] fix(frontend): remove all green dot indicators per design preference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed live-dot (pulsing) from carousel and panorama preview, renamed 'en vivo' to 'Actualizado' for accuracy (data updates daily via pipeline, not continuously live). Also removed the decorative eyebrow-dot from the hero badge. Fixed cta-section centering bug — flex rules were targeting the outer
instead of the inner .container, which is the actual parent of the CTA buttons. --- frontend/assets/css/pages/_index.css | 84 ++++++++++++++++++++++++++++ frontend/views/index.html | 30 +++++----- 2 files changed, 101 insertions(+), 13 deletions(-) create mode 100644 frontend/assets/css/pages/_index.css diff --git a/frontend/assets/css/pages/_index.css b/frontend/assets/css/pages/_index.css new file mode 100644 index 0000000..8dd8c69 --- /dev/null +++ b/frontend/assets/css/pages/_index.css @@ -0,0 +1,84 @@ +/* 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-full); + 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 { + padding-block: var(--space-8); +} + +.carousel-section .carousel { + max-width: 480px; + margin-inline: auto; +} + +.cta-section { + padding-block: var(--space-8); +} + +.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; + } +} + +.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; +} diff --git a/frontend/views/index.html b/frontend/views/index.html index 0ddee25..c09d100 100644 --- a/frontend/views/index.html +++ b/frontend/views/index.html @@ -75,7 +75,6 @@

- Inteligencia de mercado laboral Mexicano

@@ -92,7 +91,7 @@

- + From 85e02d7ae1501c07453120fb3485e913c91fd405 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 27 Jun 2026 23:28:37 -0600 Subject: [PATCH 095/277] feat(auth): add password reset request and confirmation endpoints Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- backend/.env.example | 3 + backend/app/config.py | 1 + backend/app/controllers/auth_bp.py | 103 +++++++++++++++++- backend/app/models/password_reset_token.py | 31 ++++++ .../password_reset_token_repository.py | 21 ++++ backend/app/repositories/user_repository.py | 14 ++- backend/app/schemas/auth_schema.py | 32 +++++- ...af1978c_add_password_reset_tokens_table.py | 44 ++++++++ 8 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 backend/app/models/password_reset_token.py create mode 100644 backend/app/repositories/password_reset_token_repository.py create mode 100644 backend/migrations/versions/3e1b8af1978c_add_password_reset_tokens_table.py diff --git a/backend/.env.example b/backend/.env.example index 3a27fe8..dc64c57 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -33,3 +33,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 + +# Base URL del frontend. Este valor debe coincidir con la raíz real que sirve los archivos de frontend (Live Server u otro), y cambiará por completo al desplegar a producción. +FRONTEND_BASE_URL=http://localhost:5500/frontend diff --git a/backend/app/config.py b/backend/app/config.py index 1d4c9dc..d4adfc7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -28,6 +28,7 @@ class BaseConfig: 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") diff --git a/backend/app/controllers/auth_bp.py b/backend/app/controllers/auth_bp.py index 9bd4c28..c88540c 100644 --- a/backend/app/controllers/auth_bp.py +++ b/backend/app/controllers/auth_bp.py @@ -1,16 +1,30 @@ +import secrets +import hashlib +import logging +from datetime import datetime, timezone, timedelta + from flask import Blueprint, request, current_app from flask_jwt_extended import jwt_required, get_jwt_identity, set_access_cookies, unset_jwt_cookies from marshmallow import ValidationError from google.oauth2 import id_token as google_id_token from google.auth.transport import requests as google_requests -from app.schemas.auth_schema import UserRegistrationSchema, UserLoginSchema, UserResponseSchema +from app.schemas.auth_schema import ( + UserRegistrationSchema, + UserLoginSchema, + UserResponseSchema, + ForgotPasswordSchema, + ResetPasswordSchema, +) from app.repositories.user_repository import UserRepository from app.repositories.oauth_account_repository import OAuthAccountRepository +from app.repositories.password_reset_token_repository import PasswordResetTokenRepository from app.utils.hash import hash_password, verify_password from app.utils.security import generate_tokens from app.utils.response import success_response, error_response +logger = logging.getLogger(__name__) + auth_bp = Blueprint("auth_bp", __name__) @auth_bp.route("/register", methods=["POST"]) @@ -26,6 +40,8 @@ def register(): # Traducimos el DTO de entrada al modelo de dominio. Extraemos 'password' y lo inyectamos como 'password_hash' para que SQLAlchemy lo acepte. data["password_hash"] = hash_password(data.pop("password")) 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. + data["password_changed_at"] = datetime.now(timezone.utc) user = UserRepository.create(data) result = UserResponseSchema().dump(user) @@ -45,8 +61,8 @@ def login(): user = UserRepository.get_by_email(data["email"]) - # Comparamos contra el atributo real del modelo de base de datos (password_hash) - if not user or not verify_password(data["password"], user.password_hash): + # Comparamos contra el atributo real del modelo de base de datos (password_hash). Guardia explícita para cuentas solo-OAuth (password_hash is None) antes de llamar a bcrypt. + if not user or user.password_hash is None or not verify_password(data["password"], user.password_hash): return error_response(code="UNAUTHORIZED", message="Credenciales incorrectas.", status_code=401) tokens = generate_tokens(user_id=user.id, role=user.role) @@ -104,7 +120,7 @@ def google_login(): 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. + # 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: return error_response( @@ -158,3 +174,82 @@ def google_login(): response, status_code = success_response(data=result, status_code=200) set_access_cookies(response, tokens["access_token"]) return response, status_code + + +@auth_bp.route("/forgot-password", methods=["POST"]) +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, + ) + + user = UserRepository.get_by_email(data["email"]) + if not user: + return generic_ok + + # 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, + }) + + # Stub de desarrollo. SendGrid se integra en ronda separada, ver continuidad del proyecto. + reset_url = f"{current_app.config['FRONTEND_BASE_URL']}/views/restablecer-contrasena.html?token={plain_token}" + logger.info("[DEV] Reset link para %s: %s", user.email, reset_url) + + return generic_ok + + +@auth_bp.route("/reset-password", methods=["POST"]) +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) + + token_hash = hashlib.sha256(data["token"].encode()).hexdigest() + token_row = PasswordResetTokenRepository.get_by_token_hash(token_hash) + + # Rechazamos si el token no existe, ya fue consumido, o expiró. + 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) + ): + return error_response( + code="INVALID_TOKEN", + message="El enlace de recuperación no es válido o ya expiró.", + status_code=400, + ) + + user = UserRepository.get_by_id(token_row.user_id) + if not user: + return error_response( + code="NOT_FOUND", + message="El usuario asociado a este token ya no existe.", + status_code=404, + ) + + # Actualizamos el hash de la contraseña con el mismo mecanismo bcrypt que usa el registro normal, no se reinventa ningun mecanismo de cifrado. 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. + user.password_hash = hash_password(data["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) + + return success_response(data={"message": "Contraseña actualizada correctamente."}, status_code=200) 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/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/user_repository.py b/backend/app/repositories/user_repository.py index 19b63c8..6fce6e9 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -2,8 +2,7 @@ from app.extensions import db class UserRepository: - # Encapsula el acceso a datos para la entidad User. - # Aísla las consultas SQLAlchemy de la lógica de negocio. + # Encapsula el acceso a datos para la entidad User. Aísla las consultas SQLAlchemy de la lógica de negocio. @classmethod def create(cls, user_data: dict) -> User: @@ -28,3 +27,14 @@ def get_by_email(cls, email: str) -> User: @classmethod def get_all(cls) -> list[User]: return db.session.execute(db.select(User)).scalars().all() + + @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) + try: + db.session.commit() + return user + except Exception: + db.session.rollback() + return None diff --git a/backend/app/schemas/auth_schema.py b/backend/app/schemas/auth_schema.py index 3c1b471..60e5b0c 100644 --- a/backend/app/schemas/auth_schema.py +++ b/backend/app/schemas/auth_schema.py @@ -3,8 +3,7 @@ def validate_password_strength(password): - # Reglas estandar de la industria: longitud minima ya la valida - # validate.Length por separado, aqui solo checamos composicion. + # Reglas estandar de la industria: 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): @@ -43,3 +42,32 @@ class UserResponseSchema(Schema): 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 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/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 ### From f2d05faa7edd381c5ba4ff97e272fc1c3d79e802 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 27 Jun 2026 23:32:36 -0600 Subject: [PATCH 096/277] fix(auth): reject login for OAuth-only accounts instead of raising Co-authored-by: Alejandro Balderrama --- backend/app/controllers/auth_bp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/controllers/auth_bp.py b/backend/app/controllers/auth_bp.py index c88540c..f094c0e 100644 --- a/backend/app/controllers/auth_bp.py +++ b/backend/app/controllers/auth_bp.py @@ -68,7 +68,7 @@ def login(): tokens = generate_tokens(user_id=user.id, role=user.role) user_data = UserResponseSchema().dump(user) - # El token nunca viaja en el cuerpo JSON: 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. + # 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 @@ -147,7 +147,7 @@ def google_login(): else: user = UserRepository.get_by_email(email) if not user: - # Primera vez que vemos este correo: la cuenta nace sin password_hash porque este usuario siempre entrara por Google. + # Si es la primera vez que vemos este correo, la cuenta nace sin password_hash porque este usuario siempre entrara por Google. user = UserRepository.create({ "email": email, "first_name": idinfo.get("given_name", "Usuario"), From 1547c04d3f633f3cf06a9346e58212038d2834f4 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 27 Jun 2026 23:36:31 -0600 Subject: [PATCH 097/277] feat(auth): invalidate active sessions on password change Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- backend/app/__init__.py | 28 ++++++++++++++++ backend/app/models/__init__.py | 2 ++ backend/app/models/user.py | 2 ++ backend/logging_config.py | 19 +++++++++++ ...3bea47_add_password_changed_at_to_users.py | 33 +++++++++++++++++++ 5 files changed, 84 insertions(+) create mode 100644 backend/logging_config.py create mode 100644 backend/migrations/versions/b048153bea47_add_password_changed_at_to_users.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 3115f8c..87bdc67 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,4 +1,5 @@ import os +import logging_config from flask import Flask from dotenv import load_dotenv @@ -57,6 +58,33 @@ def handle_expired_token(jwt_header, jwt_payload): 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 or user.password_changed_at is None: + # Usuario no encontrado o exclusivamente OAuth (sin contraseña), 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) + + # Truncar pca a precisión de segundos para alinear con iat + pca_floor = pca.replace(microsecond=0) + + token_issued_at = datetime.fromtimestamp(iat, tz=tz.utc) + return token_issued_at < pca_floor + def _register_blueprints(app: Flask) -> None: # Importaciones diferidas para prevenir dependencias circulares antes de inicializar Flask diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 48f55b9..0cccdcd 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,7 @@ from .job_skill import JobSkill from .user import User from .oauth_account import OAuthAccount +from .password_reset_token import PasswordResetToken from .alert import Alert from .trend_snapshot import TrendSnapshot from .backup import Backup @@ -17,6 +18,7 @@ "JobSkill", "User", "OAuthAccount", + "PasswordResetToken", "Alert", "TrendSnapshot", "Backup", diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 6088fb9..7fd9342 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -13,6 +13,8 @@ class User(db.Model): password_hash = db.Column(db.String(255), nullable=True) role = db.Column(db.String(20), nullable=False) created_at = db.Column(db.DateTime, 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) alerts = db.relationship("Alert", backref="user", lazy=True) backups = db.relationship("Backup", backref="user", lazy=True) 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/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') From 2679a92edd85b02932113f3b97442a9c07808a57 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 27 Jun 2026 23:37:31 -0600 Subject: [PATCH 098/277] feat(frontend): add forgot and reset password screens Co-authored-by: Aylin Chavira --- frontend/assets/js/pages/olvide-contrasena.js | 73 +++++++++ .../assets/js/pages/restablecer-contrasena.js | 134 +++++++++++++++ frontend/views/olvide-contrasena.html | 116 +++++++++++++ frontend/views/register.html | 7 + frontend/views/restablecer-contrasena.html | 153 ++++++++++++++++++ 5 files changed, 483 insertions(+) create mode 100644 frontend/assets/js/pages/olvide-contrasena.js create mode 100644 frontend/assets/js/pages/restablecer-contrasena.js create mode 100644 frontend/views/olvide-contrasena.html create mode 100644 frontend/views/restablecer-contrasena.html diff --git a/frontend/assets/js/pages/olvide-contrasena.js b/frontend/assets/js/pages/olvide-contrasena.js new file mode 100644 index 0000000..648b214 --- /dev/null +++ b/frontend/assets/js/pages/olvide-contrasena.js @@ -0,0 +1,73 @@ +async function handleForgotSubmit(event) { + event.preventDefault(); + + const form = event.target; + const submitButton = form.querySelector("[data-forgot-submit]"); + const messageBox = document.querySelector("[data-forgot-message]"); + const originalText = submitButton.textContent; + + const emailValue = form.email.value.trim(); + + if (!emailValue) { + messageBox.textContent = "Por favor ingresa tu correo."; + messageBox.hidden = false; + messageBox.style.color = ""; + messageBox.style.borderColor = ""; + return; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(emailValue)) { + messageBox.textContent = "Ingresa un formato de correo válido."; + messageBox.hidden = false; + messageBox.style.color = ""; + messageBox.style.borderColor = ""; + return; + } + + messageBox.hidden = true; + submitButton.disabled = true; + submitButton.textContent = "Enviando..."; + + try { + const response = await apiPost("/auth/forgot-password", { + email: emailValue, + }); + messageBox.textContent = + response.message || + "Si el correo existe, recibirás un enlace de recuperación."; + messageBox.style.color = "var(--color-accent-green)"; + messageBox.style.borderColor = "var(--color-accent-green)"; + messageBox.hidden = false; + form.reset(); + } catch (error) { + messageBox.textContent = error.message; + messageBox.style.color = ""; + messageBox.style.borderColor = ""; + messageBox.hidden = false; + } finally { + submitButton.disabled = false; + submitButton.textContent = originalText; + } +} + +function initAuthClose() { + const closeButton = document.querySelector("[data-auth-close]"); + if (!closeButton) return; + + closeButton.addEventListener("click", () => { + if (window.history.length > 1) { + window.history.back(); + } else { + window.location.href = "index.html"; + } + }); +} + +document.addEventListener("DOMContentLoaded", () => { + initAuthClose(); + const form = document.querySelector("[data-forgot-form]"); + if (form) { + form.addEventListener("submit", handleForgotSubmit); + } +}); diff --git a/frontend/assets/js/pages/restablecer-contrasena.js b/frontend/assets/js/pages/restablecer-contrasena.js new file mode 100644 index 0000000..edb2246 --- /dev/null +++ b/frontend/assets/js/pages/restablecer-contrasena.js @@ -0,0 +1,134 @@ +const PASSWORD_RULES = { + length: (value) => value.length >= 8, + upper: (value) => /[A-Z]/.test(value), + number: (value) => /\d/.test(value), + special: (value) => /[^A-Za-z0-9]/.test(value), +}; + +function updatePasswordChecklist(password) { + Object.entries(PASSWORD_RULES).forEach(([rule, check]) => { + const item = document.querySelector(`[data-rule="${rule}"]`); + if (!item) return; + item.classList.toggle("is-valid", check(password)); + }); +} + +function updateResetSubmitState() { + const form = document.querySelector("[data-reset-form]"); + if (!form) return; + + const password = form.password.value; + const allRulesPass = Object.values(PASSWORD_RULES).every((check) => + check(password), + ); + const passwordsMatch = + password.length > 0 && password === form.confirmPassword.value; + + document.querySelector("[data-reset-submit]").disabled = !( + allRulesPass && passwordsMatch + ); +} + +function showTerminalState(message, isSuccess = false) { + document.getElementById("reset-form-container").hidden = true; + + const statusContainer = document.getElementById("reset-status-container"); + const statusMessage = document.getElementById("reset-status-message"); + const actionButton = document.getElementById("reset-status-action"); + + statusContainer.hidden = false; + statusMessage.textContent = message; + statusMessage.hidden = false; + + if (isSuccess) { + statusMessage.style.color = "var(--color-accent-green)"; + statusMessage.style.borderColor = "var(--color-accent-green)"; + actionButton.textContent = "Iniciar sesión"; + actionButton.href = "register.html"; + actionButton.className = "btn btn--primary"; + } else { + statusMessage.style.color = ""; + statusMessage.style.borderColor = ""; + actionButton.textContent = "Solicitar nuevo enlace"; + actionButton.href = "olvide-contrasena.html"; + actionButton.className = "btn btn--secondary"; + } +} + +async function handleResetSubmit(event) { + event.preventDefault(); + + const form = event.target; + const submitButton = form.querySelector("[data-reset-submit]"); + const errorBox = form.querySelector("[data-reset-error]"); + const originalText = submitButton.textContent; + + const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get("token"); + + errorBox.hidden = true; + submitButton.disabled = true; + submitButton.textContent = "Actualizando..."; + + try { + const response = await apiPost("/auth/reset-password", { + token: token, + new_password: form.password.value, + }); + + // Si todo sale bien solo ocultamos form y mostramos link de login + showTerminalState( + response.message || "Contraseña actualizada correctamente.", + true, + ); + } catch (error) { + // Si el error es de token invalido o expirado, mostramos estado terminal + if (error.code === "INVALID_TOKEN") { + showTerminalState(error.message, false); + } else { + // Otros errores se muestran en el form + errorBox.textContent = error.message; + errorBox.hidden = false; + submitButton.disabled = false; + submitButton.textContent = originalText; + } + } +} + +function initResetForm() { + const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get("token"); + + if (!token) { + showTerminalState("Enlace inválido o incompleto", false); + return; + } + + const form = document.querySelector("[data-reset-form]"); + if (!form) return; + + form.addEventListener("submit", handleResetSubmit); + form.password.addEventListener("input", () => { + updatePasswordChecklist(form.password.value); + updateResetSubmitState(); + }); + form.confirmPassword.addEventListener("input", updateResetSubmitState); +} + +function initAuthClose() { + const closeButton = document.querySelector("[data-auth-close]"); + if (!closeButton) return; + + closeButton.addEventListener("click", () => { + if (window.history.length > 1) { + window.history.back(); + } else { + window.location.href = "index.html"; + } + }); +} + +document.addEventListener("DOMContentLoaded", () => { + initAuthClose(); + initResetForm(); +}); diff --git a/frontend/views/olvide-contrasena.html b/frontend/views/olvide-contrasena.html new file mode 100644 index 0000000..0237f55 --- /dev/null +++ b/frontend/views/olvide-contrasena.html @@ -0,0 +1,116 @@ + + + + + + + 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/register.html b/frontend/views/register.html index b6a5bd3..d19ec2c 100644 --- a/frontend/views/register.html +++ b/frontend/views/register.html @@ -106,6 +106,13 @@

Crear cuenta

autocomplete="current-password" required /> + + +
+ + +
+

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. +

+
+
+
+ + + + + + + + From fafa35096b115a7848b090c15211e7463072b3a4 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 28 Jun 2026 22:45:27 -0600 Subject: [PATCH 099/277] fix(auth): remove obsolete GUEST value from role constraint Co-authored-by: Alejandro Balderrama --- backend/app/models/user.py | 3 ++- ...remove_guest_from_users_role_constraint.py | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/versions/2dd31174c2d6_remove_guest_from_users_role_constraint.py diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 7fd9342..50fff6a 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -19,11 +19,12 @@ class User(db.Model): 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 permitidos directamente en la base de datos por seguridad __table_args__ = ( db.CheckConstraint( - "role IN ('GUEST', 'REGISTERED', 'ADMIN')", name="chk_users_role" + "role IN ('REGISTERED', 'ADMIN')", name="chk_users_role" ), ) 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')") From 5ccf1375b8d5acd99303a4699dd7bceb0b320b22 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 28 Jun 2026 22:46:50 -0600 Subject: [PATCH 100/277] feat(profile): add user_skills table for skill gap tracking Co-authored-by: Aylin Chavira --- backend/app/models/__init__.py | 2 ++ backend/app/models/skill.py | 1 + backend/app/models/user_skill.py | 12 +++++++ .../83e1f6043b93_add_user_skills_table.py | 31 +++++++++++++++++++ 4 files changed, 46 insertions(+) create mode 100644 backend/app/models/user_skill.py create mode 100644 backend/migrations/versions/83e1f6043b93_add_user_skills_table.py diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0cccdcd..332dc01 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,7 @@ from .alert import Alert from .trend_snapshot import TrendSnapshot from .backup import Backup +from .user_skill import UserSkill __all__ = [ "Category", @@ -22,4 +23,5 @@ "Alert", "TrendSnapshot", "Backup", + "UserSkill", ] diff --git a/backend/app/models/skill.py b/backend/app/models/skill.py index 8142b1f..c991256 100644 --- a/backend/app/models/skill.py +++ b/backend/app/models/skill.py @@ -13,3 +13,4 @@ class Skill(db.Model): 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/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/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') From 1d4b612b2b4d789c1a888fd2aafa598a0fd5bc1d Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Mon, 29 Jun 2026 20:36:25 -0600 Subject: [PATCH 101/277] feat(auth): add role-based access decorator Co-authored-by: Aylin Chavira --- backend/app/controllers/admin_bp.py | 5 +++-- backend/app/utils/decorators.py | 33 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 backend/app/utils/decorators.py diff --git a/backend/app/controllers/admin_bp.py b/backend/app/controllers/admin_bp.py index 6bd4ddd..df8aedd 100644 --- a/backend/app/controllers/admin_bp.py +++ b/backend/app/controllers/admin_bp.py @@ -4,12 +4,13 @@ from app.services.ingestion_service import IngestionService from app.services.backup_service import BackupService from app.utils.response import success_response, error_response +from app.utils.decorators import role_required admin_bp = Blueprint("admin_bp", __name__) + def admin_required(): - # Helper local para evaluar los claims del JWT inyectados durante el login. - # Garantiza que incluso un token válido sea rechazado si carece del privilegio necesario. + # Helper local para evaluar los claims del JWT inyectados durante el login. Garantiza que incluso un token válido sea rechazado si carece del privilegio necesario. claims = get_jwt() return claims.get("role") == "ADMIN" diff --git a/backend/app/utils/decorators.py b/backend/app/utils/decorators.py new file mode 100644 index 0000000..d2e3eb7 --- /dev/null +++ b/backend/app/utils/decorators.py @@ -0,0 +1,33 @@ +from functools import wraps +from flask_jwt_extended import get_jwt_identity +from app.utils.response import error_response + + +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 or user.role not in 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 From 0f0ad63af86b64fbed18fed283eea91d361338c5 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 2 Jul 2026 15:24:39 -0600 Subject: [PATCH 102/277] feat(profile): add profile endpoints with intent field and skill gap Co-authored-by: Alejandro Balderrama --- backend/app/__init__.py | 2 + backend/app/config.py | 1 + backend/app/controllers/profile_bp.py | 131 ++++++++++++++++++ backend/app/models/user.py | 9 +- .../app/repositories/user_skill_repository.py | 39 ++++++ backend/app/schemas/profile_schema.py | 49 +++++++ backend/app/services/profile_service.py | 72 ++++++++++ .../050a32090a02_add_intent_to_users.py | 37 +++++ 8 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 backend/app/controllers/profile_bp.py create mode 100644 backend/app/repositories/user_skill_repository.py create mode 100644 backend/app/schemas/profile_schema.py create mode 100644 backend/app/services/profile_service.py create mode 100644 backend/migrations/versions/050a32090a02_add_intent_to_users.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 87bdc67..4bbbc7f 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -92,11 +92,13 @@ def _register_blueprints(app: Flask) -> None: 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") def _register_schedulers(app: Flask) -> None: from scheduler.jobs import daily_pipeline diff --git a/backend/app/config.py b/backend/app/config.py index d4adfc7..0ed0bad 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -22,6 +22,7 @@ class BaseConfig: 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_SAMESITE = "Lax" JWT_COOKIE_CSRF_PROTECT = True 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/models/user.py b/backend/app/models/user.py index 50fff6a..7a80350 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -13,19 +13,24 @@ class User(db.Model): password_hash = db.Column(db.String(255), nullable=True) role = db.Column(db.String(20), nullable=False) created_at = db.Column(db.DateTime, 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. + # 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) 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 permitidos directamente en la base de datos por seguridad + # 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): 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/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/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/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 ### From 3ff553798eb0915b837016ec47604f13795a7fb8 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 2 Jul 2026 15:25:11 -0600 Subject: [PATCH 103/277] feat(frontend): add CSRF support and apiPatch/apiDelete to API client --- frontend/assets/js/api/client.js | 69 +++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/frontend/assets/js/api/client.js b/frontend/assets/js/api/client.js index 4ad0f00..cf128dd 100644 --- a/frontend/assets/js/api/client.js +++ b/frontend/assets/js/api/client.js @@ -44,10 +44,50 @@ async function apiGet(endpoint) { return json.data; } +function getCsrfToken() { + // Flask-JWT-Extended emite la cookie csrf_access_token sin la bandera httpOnly intencionalmente. Esto nos permite leerla desde JavaScript en el navegador y adjuntarla como el header X-CSRF-TOKEN en las peticiones que mutan estado, completando el patrón Double Submit Cookie para protegernos de ataques CSRF sin requerir que nuestro backend de API mantenga estado de sesiones. + const match = document.cookie.match( + new RegExp("(^| )csrf_access_token=([^;]+)"), + ); + if (match) { + return decodeURIComponent(match[2]); + } + return null; +} + async function apiPost(endpoint, body) { + const headers = { "Content-Type": "application/json" }; + const csrfToken = getCsrfToken(); + if (csrfToken) { + headers["X-CSRF-TOKEN"] = csrfToken; + } + const response = await fetch(`${API_BASE_URL}${endpoint}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: headers, + credentials: "include", + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorBody = await parseErrorBody(response); + throw buildApiError(errorBody, response, endpoint); + } + + const json = await response.json(); + return json.data; +} + +async function apiPatch(endpoint, body) { + const headers = { "Content-Type": "application/json" }; + const csrfToken = getCsrfToken(); + if (csrfToken) { + headers["X-CSRF-TOKEN"] = csrfToken; + } + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + method: "PATCH", + headers: headers, credentials: "include", body: JSON.stringify(body), }); @@ -60,3 +100,30 @@ async function apiPost(endpoint, body) { const json = await response.json(); return json.data; } + +async function apiDelete(endpoint) { + const headers = {}; + const csrfToken = getCsrfToken(); + if (csrfToken) { + headers["X-CSRF-TOKEN"] = csrfToken; + } + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + method: "DELETE", + headers: headers, + credentials: "include", + }); + + if (!response.ok) { + const errorBody = await parseErrorBody(response); + throw buildApiError(errorBody, response, endpoint); + } + + // DELETE podria devolver 204 No Content o un JSON con datos, lo manejamos sin fallar. + try { + const json = await response.json(); + return json.data || null; + } catch { + return null; + } +} From 1d7d3e265334cfdc6ee5c0b565444415fe5ed92b Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 2 Jul 2026 15:26:10 -0600 Subject: [PATCH 104/277] feat(frontend): add profile page with skill gap and security sections Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira --- frontend/assets/css/layouts/_grid.css | 14 +- frontend/assets/css/main.css | 3 +- frontend/assets/css/pages/_perfil.css | 115 +++++++ frontend/assets/js/pages/perfil.js | 311 ++++++++++++++++++ frontend/views/perfil.html | 435 ++++++++++++++++++++++++++ 5 files changed, 876 insertions(+), 2 deletions(-) create mode 100644 frontend/assets/css/pages/_perfil.css create mode 100644 frontend/assets/js/pages/perfil.js create mode 100644 frontend/views/perfil.html diff --git a/frontend/assets/css/layouts/_grid.css b/frontend/assets/css/layouts/_grid.css index f5e1c53..b00529c 100644 --- a/frontend/assets/css/layouts/_grid.css +++ b/frontend/assets/css/layouts/_grid.css @@ -1 +1,13 @@ -/* _grid.css — SkillStat */ \ No newline at end of file +/* 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 index 4015f54..1676a7e 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -21,7 +21,7 @@ /* Instanciamos los layouts (descomente conforme se crea cada archivo) */ @import url("layouts/_containers.css"); -/* @import url('layouts/_grid.css'); */ +@import url("layouts/_grid.css"); /* @import url('layouts/_navbar-layout.css'); */ /* Instanciamos las páginas (descomente conforme se crea cada archivo) */ @@ -30,4 +30,5 @@ @import url("pages/_auth.css"); /* @import url('pages/_alertas.css'); */ @import url("pages/_comparar.css"); +@import url("pages/_perfil.css"); /* @import url('pages/_admin.css'); */ 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/js/pages/perfil.js b/frontend/assets/js/pages/perfil.js new file mode 100644 index 0000000..dab80f7 --- /dev/null +++ b/frontend/assets/js/pages/perfil.js @@ -0,0 +1,311 @@ +// Datos de estado global para comparar si hay cambios en el formulario "Mis datos" +let originalProfileData = {}; + +const PASSWORD_RULES = { + length: (value) => value.length >= 8, + upper: (value) => /[A-Z]/.test(value), + number: (value) => /\d/.test(value), + special: (value) => /[^A-Za-z0-9]/.test(value), +}; + +function updatePasswordChecklist(password) { + Object.entries(PASSWORD_RULES).forEach(([rule, check]) => { + const item = document.querySelector(`[data-rule="${rule}"]`); + if (!item) return; + item.classList.toggle("is-valid", check(password)); + }); +} + +function updatePasswordSubmitState() { + const form = document.getElementById("seguridad-form"); + if (!form) return; + + const password = form.new_password.value; + const allRulesPass = Object.values(PASSWORD_RULES).every((check) => + check(password), + ); + const passwordsMatch = + password.length > 0 && password === form.confirm_password.value; + + const btn = document.getElementById("btn-save-password"); + if (btn) { + btn.disabled = !(allRulesPass && passwordsMatch); + } +} + +function showInlineMessage(msgElement, text, isSuccess) { + msgElement.textContent = text; + msgElement.hidden = false; + if (isSuccess) { + msgElement.style.color = "var(--color-accent-green)"; + msgElement.style.borderColor = "var(--color-accent-green)"; + msgElement.style.backgroundColor = "rgba(16, 185, 129, 0.1)"; + } else { + msgElement.style.color = ""; + msgElement.style.borderColor = ""; + msgElement.style.backgroundColor = ""; + } +} + +async function initProfilePage() { + try { + const profileData = await apiGet("/profile/me"); + originalProfileData = profileData; + + // Poblar Mis datos + const form = document.getElementById("datos-form"); + form.first_name.value = profileData.first_name || ""; + form.last_name.value = profileData.last_name || ""; + form.intent.value = profileData.intent || ""; + + // Poblar tarjeta contextual + const emailEl = document.querySelector("[data-profile-email]"); + if (emailEl) emailEl.textContent = profileData.email || "No disponible"; + + const roleEl = document.querySelector("[data-profile-role]"); + if (roleEl) { + const rolesMap = { REGISTERED: "Registrado", ADMIN: "Administrador" }; + roleEl.textContent = + rolesMap[profileData.role] || profileData.role || "Desconocido"; + } + + const sinceEl = document.querySelector("[data-profile-since]"); + if (sinceEl && profileData.created_at) { + const date = new Date(profileData.created_at); + sinceEl.textContent = date.toLocaleDateString("es-ES", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } else if (sinceEl) { + sinceEl.textContent = "No disponible"; + } + + // Ocultar loader y mostrar pagina principal + document.getElementById("profile-loading").style.display = "none"; + document.getElementById("profile-main").hidden = false; + + // Cargar habilidades + await loadSkills(); + } catch (error) { + if (error.status === 401) { + window.location.href = "register.html"; + } else { + console.error("Error cargando perfil:", error); + // Fallback + document.getElementById("profile-loading").innerHTML = + `

No se pudo cargar el perfil.

`; + } + } +} + +async function handleDatosSubmit(e) { + e.preventDefault(); + const form = e.target; + const btn = document.getElementById("btn-save-datos"); + const msg = document.getElementById("datos-msg"); + + const currentData = { + first_name: form.first_name.value, + last_name: form.last_name.value, + intent: form.intent.value || null, + }; + + const changes = {}; + for (const key in currentData) { + if (currentData[key] !== originalProfileData[key]) { + changes[key] = currentData[key]; + } + } + + if (Object.keys(changes).length === 0) { + showInlineMessage(msg, "No hay cambios que guardar.", true); + return; + } + + const originalText = btn.textContent; + btn.textContent = "Guardando..."; + btn.disabled = true; + msg.hidden = true; + + try { + const response = await apiPatch("/profile/me", changes); + originalProfileData = response; // Actualizar con nueva data (incluye los campos modificados) + showInlineMessage(msg, "Perfil actualizado correctamente.", true); + } catch (error) { + showInlineMessage(msg, error.message, false); + } finally { + btn.textContent = originalText; + btn.disabled = false; + } +} + +async function handlePasswordSubmit(e) { + e.preventDefault(); + const form = e.target; + const btn = document.getElementById("btn-save-password"); + const msg = document.getElementById("seguridad-msg"); + + const originalText = btn.textContent; + btn.textContent = "Actualizando..."; + btn.disabled = true; + msg.hidden = true; + + try { + await apiPost("/profile/change-password", { + current_password: form.current_password.value, + new_password: form.new_password.value, + }); + + showInlineMessage(msg, "Contraseña actualizada. Redirigiendo...", true); + + setTimeout(() => { + window.location.href = "register.html"; + }, 2000); + } catch (error) { + if (error.code === "INVALID_CREDENTIALS") { + showInlineMessage(msg, "La contraseña actual no es correcta", false); + } else { + showInlineMessage(msg, error.message, false); + } + btn.disabled = false; + btn.textContent = originalText; + } +} + +async function loadSkills() { + try { + const gapData = await apiGet("/profile/skill-gap"); + renderSkills(gapData.mis_habilidades, gapData.brechas); + } catch (error) { + console.error("Error cargando habilidades:", error); + } +} + +function renderSkills(misHabilidades, brechas) { + const misContainer = document.getElementById("mis-habilidades-container"); + const misEmpty = document.getElementById("mis-habilidades-empty"); + const brechasContainer = document.getElementById("brechas-container"); + const brechasEmpty = document.getElementById("brechas-empty"); + + misContainer.innerHTML = ""; + brechasContainer.innerHTML = ""; + + if (misHabilidades.length === 0) { + misEmpty.hidden = false; + } else { + misEmpty.hidden = true; + misHabilidades.forEach((skill) => { + const chip = document.createElement("div"); + chip.className = "chip"; + chip.innerHTML = ` + ${skill.name} + + `; + misContainer.appendChild(chip); + }); + } + + if (brechas.length === 0) { + brechasEmpty.hidden = false; + } else { + brechasEmpty.hidden = true; + brechas.forEach((skill) => { + const chip = document.createElement("div"); + chip.className = "chip"; + chip.innerHTML = ` + ${skill.name} + + `; + brechasContainer.appendChild(chip); + }); + } + + // Re-inicializar iconos despues de inyectar HTML si lucide existe globalmente + if (window.lucide && window.lucide.createIcons) { + window.lucide.createIcons(); + } + + // Bind events for buttons + misContainer + .querySelectorAll('[data-action="remove-skill"]') + .forEach((btn) => { + btn.addEventListener("click", () => + handleRemoveSkill(btn.dataset.id, btn), + ); + }); + + brechasContainer + .querySelectorAll('[data-action="add-skill"]') + .forEach((btn) => { + btn.addEventListener("click", () => handleAddSkill(btn.dataset.id, btn)); + }); +} + +async function handleAddSkill(skillId, btnElement) { + btnElement.disabled = true; + try { + await apiPost("/profile/skills", { skill_id: parseInt(skillId) }); + await loadSkills(); + } catch (error) { + console.error("Error agregando habilidad:", error); + btnElement.disabled = false; + } +} + +async function handleRemoveSkill(skillId, btnElement) { + btnElement.disabled = true; + try { + await apiDelete(`/profile/skills/${skillId}`); + await loadSkills(); + } catch (error) { + console.error("Error eliminando habilidad:", error); + btnElement.disabled = false; + } +} + +function bindNavigation() { + const tabs = document.querySelectorAll("[data-nav-section]"); + const sections = document.querySelectorAll("[data-section]"); + + tabs.forEach((tab) => { + tab.addEventListener("click", () => { + // Activar pill + tabs.forEach((t) => t.classList.remove("pill--active")); + tab.classList.add("pill--active"); + + // Mostrar seccion + const targetId = `section-${tab.dataset.navSection}`; + sections.forEach((sec) => { + sec.hidden = sec.id !== targetId; + }); + }); + }); +} + +document.addEventListener("DOMContentLoaded", () => { + bindNavigation(); + initProfilePage(); + + const datosForm = document.getElementById("datos-form"); + if (datosForm) { + datosForm.addEventListener("submit", handleDatosSubmit); + } + + const seguridadForm = document.getElementById("seguridad-form"); + if (seguridadForm) { + seguridadForm.addEventListener("submit", handlePasswordSubmit); + seguridadForm.new_password.addEventListener("input", () => { + updatePasswordChecklist(seguridadForm.new_password.value); + updatePasswordSubmitState(); + }); + seguridadForm.confirm_password.addEventListener( + "input", + updatePasswordSubmitState, + ); + } +}); diff --git a/frontend/views/perfil.html b/frontend/views/perfil.html new file mode 100644 index 0000000..6943358 --- /dev/null +++ b/frontend/views/perfil.html @@ -0,0 +1,435 @@ + + + + + + + SkillStat - Mi perfil + + + + + + + + + + + + +
+
+
+

Mi perfil

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

Mis datos

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

Información de cuenta

+
+ +

Cargando...

+
+
+ +

Cargando...

+
+
+ +

Cargando...

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

Cargando perfil...

+
+ + + + + + + + From e45e2bee85ff1b1cd2e94323a6e1a96246a2229c Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 2 Jul 2026 15:26:52 -0600 Subject: [PATCH 105/277] fix(frontend): fix carousel scroll bug and expand index layout for desktop Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira --- frontend/assets/css/pages/_index.css | 64 +++++++++++++++++++ .../assets/js/components/carousel.init.js | 41 +++++++++--- frontend/assets/js/pages/index.js | 38 ++++++++++- frontend/views/index.html | 12 ++++ 4 files changed, 144 insertions(+), 11 deletions(-) diff --git a/frontend/assets/css/pages/_index.css b/frontend/assets/css/pages/_index.css index afa88ce..39728f9 100644 --- a/frontend/assets/css/pages/_index.css +++ b/frontend/assets/css/pages/_index.css @@ -58,6 +58,30 @@ .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 { @@ -78,3 +102,43 @@ 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/js/components/carousel.init.js b/frontend/assets/js/components/carousel.init.js index b56a2a2..24ec04b 100644 --- a/frontend/assets/js/components/carousel.init.js +++ b/frontend/assets/js/components/carousel.init.js @@ -26,11 +26,20 @@ function goToSlide(index) { const slide = slides[index]; if (!slide) return; - slide.scrollIntoView({ - behavior: prefersReducedMotion ? "auto" : "smooth", - inline: "start", - block: "nearest", - }); + // Solo ejecuta scrollIntoView si el carousel esta dentro del viewport. + // Si esta fuera (por ejemplo el usuario bajo al footer), avanza el indice + // logico sin mover la pagina, para que al volver vea el slide correcto. + const rect = carousel.getBoundingClientRect(); + const inViewport = rect.top >= 0 && rect.bottom <= window.innerHeight; + if (inViewport) { + slide.scrollIntoView({ + behavior: prefersReducedMotion ? "auto" : "smooth", + inline: "start", + block: "nearest", + }); + } else { + setActiveDot(index); + } } dots.forEach(function (dot, index) { @@ -60,7 +69,7 @@ }); function startAutoRotate() { - if (prefersReducedMotion) return; + if (prefersReducedMotion || autoRotateTimer) return; autoRotateTimer = setInterval(function () { const nextIndex = (currentIndex + 1) % slides.length; goToSlide(nextIndex); @@ -74,9 +83,21 @@ } } - carousel.addEventListener("mouseenter", stopAutoRotate); - carousel.addEventListener("mouseleave", startAutoRotate); - carousel.addEventListener("touchstart", stopAutoRotate, { passive: true }); + // IntersectionObserver sobre el carousel completo: detiene el auto-rotate + // cuando el carousel sale del viewport y lo reanuda cuando entra. + // Esto reemplaza los listeners de mouseenter/mouseleave/touchstart. + const visibilityObserver = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + startAutoRotate(); + } else { + stopAutoRotate(); + } + }); + }, + { threshold: 0.1 }, + ); - startAutoRotate(); + visibilityObserver.observe(carousel); })(); diff --git a/frontend/assets/js/pages/index.js b/frontend/assets/js/pages/index.js index 0756c43..60559a5 100644 --- a/frontend/assets/js/pages/index.js +++ b/frontend/assets/js/pages/index.js @@ -2,7 +2,7 @@ async function initIndexPage() { try { const [summary, topSkills] = await Promise.all([ getSummary(), - getTopSkills(5), + getTopSkills(10), ]); const topSkill = topSkills[0]; @@ -27,6 +27,8 @@ async function initIndexPage() { formatNumber(summary.total_jobs); document.querySelector('[data-metric="preview-skill"]').textContent = topSkill.name; + + renderTopSkillsChips(topSkills); } catch (error) { console.error("No se pudieron cargar los datos del Panorama:", error); @@ -35,7 +37,41 @@ async function initIndexPage() { element.textContent = "No disponible"; } }); + + // La seccion de top skills es aditiva; si falla, se oculta sin romper el resto. + const topSkillsSection = document.getElementById("top-skills-section"); + if (topSkillsSection) topSkillsSection.hidden = true; + } +} + +function renderTopSkillsChips(skills) { + const container = document.getElementById("top-skills-chips"); + if (!container || !Array.isArray(skills) || skills.length === 0) { + const section = document.getElementById("top-skills-section"); + if (section) section.hidden = true; + return; } + + const fragment = document.createDocumentFragment(); + skills.slice(0, 10).forEach(function (skill) { + const chip = document.createElement("div"); + chip.className = "chip"; + chip.setAttribute("role", "listitem"); + + const name = document.createElement("span"); + name.textContent = skill.name; + + const badge = document.createElement("span"); + badge.className = "chip__badge"; + badge.textContent = formatNumber(skill.demand_count); + badge.setAttribute("aria-label", `${formatNumber(skill.demand_count)} vacantes`); + + chip.appendChild(name); + chip.appendChild(badge); + fragment.appendChild(chip); + }); + + container.appendChild(fragment); } document.addEventListener("DOMContentLoaded", initIndexPage); diff --git a/frontend/views/index.html b/frontend/views/index.html index ba607bf..27ece2c 100644 --- a/frontend/views/index.html +++ b/frontend/views/index.html @@ -304,6 +304,18 @@

+ +
+
+

Skills más demandados ahora

+

Lo que el mercado pide hoy

+
+ +
+ Ver el Panorama completo → +
+
+
From 60913f7ef2db03a8c83e589cbca27a7c03eb996f Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 3 Jul 2026 07:43:52 -0600 Subject: [PATCH 106/277] feat(panorama): add category field to skills/top endpoint --- backend/app/controllers/panorama_bp.py | 3 ++- backend/app/schemas/panorama_schema.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py index 27bc294..dbd3dac 100644 --- a/backend/app/controllers/panorama_bp.py +++ b/backend/app/controllers/panorama_bp.py @@ -83,6 +83,7 @@ def get_top_skills(): { "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, @@ -218,7 +219,7 @@ def get_compare(): status_code=422, ) - # Acotamos entre 2 y 5 habilidades: comparar una sola no tiene sentido funcional, y mas de 5 degrada la lectura de la grafica. + # 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", diff --git a/backend/app/schemas/panorama_schema.py b/backend/app/schemas/panorama_schema.py index 1edb0e6..f08cc5d 100644 --- a/backend/app/schemas/panorama_schema.py +++ b/backend/app/schemas/panorama_schema.py @@ -8,10 +8,11 @@ class SkillTrendSchema(Schema): 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. + # 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) From 1a431fa78d5d6deddad196c2c34f8ec2f843d359 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 3 Jul 2026 07:44:50 -0600 Subject: [PATCH 107/277] fix(frontend): color all tied top skills and add btn--sm variant Co-authored-by: Alejandro Balderrama --- frontend/assets/css/components/_buttons.css | 6 ++++++ frontend/assets/js/components/chart.init.js | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/assets/css/components/_buttons.css b/frontend/assets/css/components/_buttons.css index f7fae8b..bb127ee 100644 --- a/frontend/assets/css/components/_buttons.css +++ b/frontend/assets/css/components/_buttons.css @@ -75,3 +75,9 @@ 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/js/components/chart.init.js b/frontend/assets/js/components/chart.init.js index 38481e5..79e4c83 100644 --- a/frontend/assets/js/components/chart.init.js +++ b/frontend/assets/js/components/chart.init.js @@ -16,7 +16,7 @@ function renderSkillsChart(container, skills) { const bar = document.createElement("div"); bar.className = - index === 0 + skill.demand_count === skills[0].demand_count ? "skills-chart__bar skills-chart__bar--top" : "skills-chart__bar"; bar.style.height = `${heightPercent}%`; From 94b1121a8112503fb9cf1e242d3d0f14d26a2ef2 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 3 Jul 2026 07:45:53 -0600 Subject: [PATCH 108/277] feat(frontend): add habilidades page with category and demand filters Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira --- frontend/assets/css/main.css | 2 + frontend/assets/css/pages/_habilidades.css | 165 +++++++++++++ frontend/assets/js/pages/habilidades.js | 212 +++++++++++++++++ frontend/views/habilidades.html | 255 +++++++++++++++++++++ 4 files changed, 634 insertions(+) create mode 100644 frontend/assets/css/pages/_habilidades.css create mode 100644 frontend/assets/js/pages/habilidades.js create mode 100644 frontend/views/habilidades.html diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css index 1676a7e..5a68e99 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -31,4 +31,6 @@ /* @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/_admin.css'); */ 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/js/pages/habilidades.js b/frontend/assets/js/pages/habilidades.js new file mode 100644 index 0000000..60fa8d3 --- /dev/null +++ b/frontend/assets/js/pages/habilidades.js @@ -0,0 +1,212 @@ +let allSkills = []; +let activeCategory = "all"; +let activeDemand = "all"; +let searchQuery = ""; +let maxDemand = 0; +let demandChecks = {}; + +async function initHabilidadesPage() { + const loadingEl = document.getElementById("habilidades-loading"); + const errorEl = document.getElementById("habilidades-error"); + + try { + const skills = await getTopSkills(50); + allSkills = skills; + + if (skills.length > 0) { + maxDemand = Math.max(...skills.map((s) => s.demand_count)); + } + + loadingEl.hidden = true; + + setupFilters(skills); + setupDemandFilters(skills); + setupSearch(); + renderSkills(); + } catch (error) { + console.error("Error al cargar habilidades:", error); + loadingEl.innerHTML = + '

No se pudieron cargar las habilidades en este momento. Intenta de nuevo más tarde.

'; + } +} + +function setupFilters(skills) { + const filterContainer = document.getElementById("category-filters"); + + // Extraemos categorías únicas (ignorando nulls) + const categories = [ + ...new Set(skills.map((s) => s.category).filter((c) => c)), + ].sort(); + + categories.forEach((category) => { + const btn = document.createElement("button"); + btn.className = "pill"; + btn.type = "button"; + btn.dataset.category = category; + btn.textContent = category; + + btn.addEventListener("click", () => { + // Removemos clase activa de todos + filterContainer + .querySelectorAll(".pill") + .forEach((p) => p.classList.remove("pill--active")); + // Agregamos al actual + btn.classList.add("pill--active"); + + activeCategory = category; + renderSkills(); + }); + + filterContainer.appendChild(btn); + }); + + // Agregamos evento al botón "Todas" + const allBtn = filterContainer.querySelector('[data-category="all"]'); + if (allBtn) { + allBtn.addEventListener("click", () => { + filterContainer + .querySelectorAll(".pill") + .forEach((p) => p.classList.remove("pill--active")); + allBtn.classList.add("pill--active"); + activeCategory = "all"; + renderSkills(); + }); + } +} + +function setupDemandFilters(skills) { + if (skills.length === 0) return; + + // Calculamos umbrales de demanda basados en el array real + const demands = skills.map((s) => s.demand_count).sort((a, b) => a - b); + const p33 = demands[Math.floor(demands.length * 0.33)]; + const p67 = demands[Math.floor(demands.length * 0.67)]; + + const ranges = [ + { label: "Todas", value: "all", check: () => true }, + { label: "Alta demanda", value: "high", check: (d) => d >= p67 }, + { + label: "Demanda media", + value: "medium", + check: (d) => d >= p33 && d < p67, + }, + { label: "Baja demanda", value: "low", check: (d) => d < p33 }, + ]; + + const toolbar = document.querySelector(".habilidades-toolbar"); + const categoryFilters = document.getElementById("category-filters"); + + const demandFilters = document.createElement("div"); + demandFilters.id = "demand-filters"; + demandFilters.className = "habilidades-filters habilidades-filters--demand"; + demandFilters.setAttribute("role", "group"); + demandFilters.setAttribute("aria-label", "Filtrar por nivel de demanda"); + + ranges.forEach((range, idx) => { + demandChecks[range.value] = range.check; + + const btn = document.createElement("button"); + btn.className = "pill" + (idx === 0 ? " pill--active" : ""); + btn.type = "button"; + btn.dataset.demand = range.value; + btn.textContent = range.label; + + btn.addEventListener("click", () => { + demandFilters + .querySelectorAll(".pill") + .forEach((p) => p.classList.remove("pill--active")); + btn.classList.add("pill--active"); + + activeDemand = range.value; + renderSkills(); + }); + + demandFilters.appendChild(btn); + }); + + if (categoryFilters && categoryFilters.parentNode) { + categoryFilters.parentNode.insertBefore( + demandFilters, + categoryFilters.nextSibling, + ); + } else { + toolbar.appendChild(demandFilters); + } +} + +function setupSearch() { + const searchInput = document.getElementById("skill-search"); + if (searchInput) { + searchInput.addEventListener("input", (e) => { + searchQuery = e.target.value.toLowerCase().trim(); + renderSkills(); + }); + } +} + +function renderSkills() { + const listContainer = document.getElementById("habilidades-list"); + const emptyState = document.getElementById("habilidades-empty"); + + listContainer.innerHTML = ""; + + // Filtramos habilidades según categoría activa, búsqueda y demanda activa + const filtered = allSkills.filter((skill) => { + const matchesCategory = + activeCategory === "all" || skill.category === activeCategory; + const matchesSearch = skill.name.toLowerCase().includes(searchQuery); + const matchesDemand = demandChecks[activeDemand] + ? demandChecks[activeDemand](skill.demand_count) + : true; + return matchesCategory && matchesSearch && matchesDemand; + }); + + if (filtered.length === 0) { + emptyState.hidden = false; + listContainer.hidden = true; + return; + } + + emptyState.hidden = true; + listContainer.hidden = false; + + const fragment = document.createDocumentFragment(); + + filtered.forEach((skill) => { + const item = document.createElement("article"); + item.className = "skill-item surface"; + + // Aplicamos cálculo del ancho proporcional + const percentage = + maxDemand > 0 ? (skill.demand_count / maxDemand) * 100 : 0; + + item.innerHTML = ` +
+

${skill.name}

+ ${skill.category ? `${skill.category}` : ""} +
+ +
+
+
+
+ ${formatNumber(skill.demand_count)} +
+ + + `; + + fragment.appendChild(item); + }); + + listContainer.appendChild(fragment); + + // Re-inicializamos iconos de Lucide (si se agregaran íconos dinámicamente) + if (window.lucide) { + window.lucide.createIcons(); + } +} + +document.addEventListener("DOMContentLoaded", initHabilidadesPage); diff --git a/frontend/views/habilidades.html b/frontend/views/habilidades.html new file mode 100644 index 0000000..34f0f7a --- /dev/null +++ b/frontend/views/habilidades.html @@ -0,0 +1,255 @@ + + + + + + + SkillStat - Habilidades + + + + + + + + + + + + +
+
+
+

Habilidades del mercado tech

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

Cargando habilidades...

+
+ + + +
+ +
+
+
+
+ + + + + + + + + + From 4a32ec1f64ad433f6a253fa654958efdddeea53d Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Fri, 3 Jul 2026 07:46:35 -0600 Subject: [PATCH 109/277] feat(frontend): add salarios page and update navbar links across all views Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira --- frontend/assets/css/pages/_salarios.css | 29 +++ frontend/assets/js/pages/salarios.js | 78 +++++++ frontend/views/comparar.html | 10 +- frontend/views/panorama.html | 10 +- frontend/views/perfil.html | 10 +- frontend/views/salarios.html | 261 ++++++++++++++++++++++++ 6 files changed, 380 insertions(+), 18 deletions(-) create mode 100644 frontend/assets/css/pages/_salarios.css create mode 100644 frontend/assets/js/pages/salarios.js create mode 100644 frontend/views/salarios.html 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/js/pages/salarios.js b/frontend/assets/js/pages/salarios.js new file mode 100644 index 0000000..473a3d7 --- /dev/null +++ b/frontend/assets/js/pages/salarios.js @@ -0,0 +1,78 @@ +let allSkills = []; + +async function initSalariosPage() { + const container = document.getElementById("salarios-filters"); + const errorContainer = document.getElementById("salarios-error-container"); + + try { + const skills = await getTopSkills(50); + allSkills = skills; + + skills.forEach((skill) => { + const btn = document.createElement("button"); + btn.className = "pill"; + btn.type = "button"; + btn.textContent = skill.name; + btn.dataset.skillId = skill.skill_id; + + btn.addEventListener("click", () => handleSkillSelection(btn, skill)); + container.appendChild(btn); + }); + } catch (error) { + console.error("Error al cargar skills para salarios:", error); + errorContainer.innerHTML = + '

No se pudieron cargar las habilidades. Intenta recargar la página.

'; + } +} + +async function handleSkillSelection(btn, skill) { + const container = document.getElementById("salarios-filters"); + container + .querySelectorAll(".pill") + .forEach((p) => p.classList.remove("pill--active")); + btn.classList.add("pill--active"); + + const loadingEl = document.getElementById("salarios-loading"); + const resultEl = document.getElementById("salary-result"); + const emptyEl = document.getElementById("salary-empty"); + + loadingEl.hidden = false; + resultEl.hidden = true; + emptyEl.hidden = true; + + try { + const data = await apiGet(`/panorama/salaries?skill_id=${skill.skill_id}`); + + if ( + !data || + data.sample_size === 0 || + (data.avg_salary_min === null && data.avg_salary_max === null) + ) { + showEmptyState(); + return; + } + + const minMonthly = Math.round(data.avg_salary_min / 12); + const maxMonthly = Math.round(data.avg_salary_max / 12); + + document.getElementById("salary-skill-name").textContent = data.skill_name; + document.getElementById("salary-range-value").textContent = + `$${formatNumber(minMonthly)} - $${formatNumber(maxMonthly)}`; + document.getElementById("salary-sample").textContent = + `basado en ${data.sample_size} vacantes con salario declarado`; + + loadingEl.hidden = true; + resultEl.hidden = false; + } catch (error) { + console.error("Error al obtener salario:", error); + showEmptyState(); // Según las instrucciones, fallar el fetch se presenta igual que no tener datos. + } +} + +function showEmptyState() { + document.getElementById("salarios-loading").hidden = true; + document.getElementById("salary-result").hidden = true; + document.getElementById("salary-empty").hidden = false; +} + +document.addEventListener("DOMContentLoaded", initSalariosPage); diff --git a/frontend/views/comparar.html b/frontend/views/comparar.html index d250cd1..ba9c406 100644 --- a/frontend/views/comparar.html +++ b/frontend/views/comparar.html @@ -65,10 +65,10 @@ >
  • - Habilidades + Habilidades
  • - Salarios + Salarios
  • Regiones @@ -167,12 +167,10 @@ >
  • - Habilidades + Habilidades
  • - Salarios + Salarios
  • Regiones diff --git a/frontend/views/panorama.html b/frontend/views/panorama.html index a50b27f..49853f6 100644 --- a/frontend/views/panorama.html +++ b/frontend/views/panorama.html @@ -65,10 +65,10 @@
  • Comparar
  • - Habilidades + Habilidades
  • - Salarios + Salarios
  • Regiones @@ -167,12 +167,10 @@
  • Comparar
  • - Habilidades + Habilidades
  • - Salarios + Salarios
  • Regiones diff --git a/frontend/views/perfil.html b/frontend/views/perfil.html index 6943358..8baf827 100644 --- a/frontend/views/perfil.html +++ b/frontend/views/perfil.html @@ -60,10 +60,10 @@
  • Comparar
  • - Habilidades + Habilidades
  • - Salarios + Salarios
  • Regiones @@ -157,12 +157,10 @@
  • Comparar
  • - Habilidades + Habilidades
  • - Salarios + Salarios
  • Regiones diff --git a/frontend/views/salarios.html b/frontend/views/salarios.html new file mode 100644 index 0000000..aa5ef0b --- /dev/null +++ b/frontend/views/salarios.html @@ -0,0 +1,261 @@ + + + + + + + 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. +

    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + + + + + + +
    +
    +
    + + + + + + + + + + From 2867fa4d67e8bc685e3056fca678974ed8c5d0b2 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 5 Jul 2026 17:15:38 -0600 Subject: [PATCH 110/277] chore(gitignore): ignore rotated log files (*.log.*) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 61a02a8..e6d550b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ htmlcov/ # Migrations (solo rastrear estructura, no datos generados) migrations/versions/ +*.log.* From e91a31b927ba570e62ed88bf6177d07ce50f93ae Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 5 Jul 2026 17:17:47 -0600 Subject: [PATCH 111/277] feat(db): add email_verification_tokens table and users.email_verified_at column Co-authored-by: Alejandro Balderrama --- backend/app/models/__init__.py | 2 + .../app/models/email_verification_token.py | 28 ++++++++++ backend/app/models/user.py | 2 + .../email_verification_token_repository.py | 20 +++++++ ...89dde67bf_add_email_verification_schema.py | 52 +++++++++++++++++++ 5 files changed, 104 insertions(+) create mode 100644 backend/app/models/email_verification_token.py create mode 100644 backend/app/repositories/email_verification_token_repository.py create mode 100644 backend/migrations/versions/5fc89dde67bf_add_email_verification_schema.py diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 332dc01..d5595b6 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -6,6 +6,7 @@ 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 @@ -20,6 +21,7 @@ "User", "OAuthAccount", "PasswordResetToken", + "EmailVerificationToken", "Alert", "TrendSnapshot", "Backup", 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/user.py b/backend/app/models/user.py index 7a80350..f73d6be 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -17,6 +17,8 @@ class User(db.Model): 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) alerts = db.relationship("Alert", backref="user", lazy=True) backups = db.relationship("Backup", backref="user", lazy=True) 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/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 ### From 485b6fea91e59671cab8633d95684b099d6c97cb Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 5 Jul 2026 17:18:10 -0600 Subject: [PATCH 112/277] fix(db): enforce NOT NULL and server default on users.created_at --- ...a3808_harden_users_created_at_not_null_.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 backend/migrations/versions/6d85e79a3808_harden_users_created_at_not_null_.py 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) From 524f5e281fd6db197589355ce24ae916efac1dc9 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 5 Jul 2026 17:19:04 -0600 Subject: [PATCH 113/277] feat(email): add Resend email service with fail-fast config guard Co-authored-by: Aylin Chavira --- backend/.env.example | 2 +- backend/app/__init__.py | 6 ++-- backend/app/config.py | 7 ++++- backend/app/services/email_service.py | 41 +++++++++++++++++++++++++++ backend/requirements.txt | 4 +++ backend/run.py | 4 +++ 6 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 backend/app/services/email_service.py diff --git a/backend/.env.example b/backend/.env.example index dc64c57..a6b882c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,7 +18,7 @@ JWT_COOKIE_SECURE=false GOOGLE_CLIENT_ID=891817364914-fpq222eqf2jk1ticoldkuq5u74h5lurt.apps.googleusercontent.com ADZUNA_APP_ID=app-id-de-adzuna ADZUNA_APP_KEY=api-key-de-adzuna -SENDGRID_API_KEY=api-key-de-sendgrid +RESEND_API_KEY=api-key-de-sendgrid # Almacenamiento de respaldos BACKUP_STORAGE_URL=url-del-almacenamiento diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 4bbbc7f..6954415 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,14 +1,12 @@ import os import logging_config from flask import Flask -from dotenv import load_dotenv from app.config import config_map from app.extensions import db, jwt, cors, migrate, scheduler def create_app(env: str = None) -> Flask: - # Patrón Application Factory. Aísla la inicialización para permitir múltiples instancias durante pruebas automatizadas y evita variables globales. - load_dotenv() + # 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__) @@ -79,7 +77,7 @@ def check_if_token_revoked(jwt_header, jwt_payload): if pca.tzinfo is None: pca = pca.replace(tzinfo=tz.utc) - # Truncar pca a precisión de segundos para alinear con iat + # 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) diff --git a/backend/app/config.py b/backend/app/config.py index 0ed0bad..30852da 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -34,7 +34,12 @@ class BaseConfig: 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") - SENDGRID_API_KEY = os.environ.get("SENDGRID_API_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.") BACKUP_STORAGE_URL = os.environ.get("BACKUP_STORAGE_URL") BACKUP_STORAGE_KEY = os.environ.get("BACKUP_STORAGE_KEY") diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..45e01f5 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,41 @@ +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)}") diff --git a/backend/requirements.txt b/backend/requirements.txt index cf47023..23edc30 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -37,3 +37,7 @@ gunicorn>=23.0,<24.0 # Verificación de tokens de identidad de Google OAuth google-auth + +resend==2.32.2 + + diff --git a/backend/run.py b/backend/run.py index 94dc806..c48690a 100644 --- a/backend/run.py +++ b/backend/run.py @@ -1,4 +1,8 @@ import os +from dotenv import load_dotenv + +load_dotenv() + from app import create_app app = create_app(os.getenv("FLASK_ENV", "development")) From ab86d57e3a2e2b1d790777dd936f55ae590e6fa7 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 5 Jul 2026 17:19:29 -0600 Subject: [PATCH 114/277] refactor(auth): decouple resend-verification schema from forgot-password Co-authored-by: Aylin Chavira --- backend/app/schemas/auth_schema.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/backend/app/schemas/auth_schema.py b/backend/app/schemas/auth_schema.py index 60e5b0c..4537acc 100644 --- a/backend/app/schemas/auth_schema.py +++ b/backend/app/schemas/auth_schema.py @@ -3,7 +3,7 @@ def validate_password_strength(password): - # Reglas estandar de la industria: longitud minima ya la valida validate.Length por separado, aqui solo checamos composicion. + # 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): @@ -35,7 +35,6 @@ class UserLoginSchema(Schema): password = fields.String(required=True, error_messages={"required": "La contraseña es obligatoria."}) class UserResponseSchema(Schema): - # Exponemos la estructura desagregada del nombre y mantenemos la censura de la contraseña id = fields.Integer(dump_only=True) email = fields.Email(dump_only=True) first_name = fields.String(dump_only=True) @@ -60,6 +59,25 @@ def normalize_email(self, data, **kwargs): 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, From 21352ae58499517f81f02ef80d76ed638d90c02d Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 5 Jul 2026 17:20:19 -0600 Subject: [PATCH 115/277] feat(auth): require email verification before login, split verify-email into GET/POST --- backend/app/controllers/auth_bp.py | 166 +++++++++++++++++++++++++++-- 1 file changed, 155 insertions(+), 11 deletions(-) diff --git a/backend/app/controllers/auth_bp.py b/backend/app/controllers/auth_bp.py index f094c0e..71ff2ec 100644 --- a/backend/app/controllers/auth_bp.py +++ b/backend/app/controllers/auth_bp.py @@ -14,11 +14,13 @@ UserLoginSchema, UserResponseSchema, ForgotPasswordSchema, + EmailOnlySchema, ResetPasswordSchema, ) from app.repositories.user_repository import UserRepository from app.repositories.oauth_account_repository import OAuthAccountRepository from app.repositories.password_reset_token_repository import PasswordResetTokenRepository +from app.repositories.email_verification_token_repository import EmailVerificationTokenRepository from app.utils.hash import hash_password, verify_password from app.utils.security import generate_tokens from app.utils.response import success_response, error_response @@ -37,20 +39,30 @@ def register(): if UserRepository.get_by_email(data["email"]): return error_response(code="CONFLICT", message="El correo ya está registrado.", status_code=409) - # Traducimos el DTO de entrada al modelo de dominio. Extraemos 'password' y lo inyectamos como 'password_hash' para que SQLAlchemy lo acepte. data["password_hash"] = hash_password(data.pop("password")) 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. data["password_changed_at"] = datetime.now(timezone.utc) user = UserRepository.create(data) - result = UserResponseSchema().dump(user) - # Dejamos al usuario logueado de inmediato tras registrarse, en vez de obligarlo a escribir sus credenciales otra vez en una pantalla de login separada. - tokens = generate_tokens(user_id=user.id, role=user.role) - response, status_code = success_response(data=result, status_code=201) - set_access_cookies(response, tokens["access_token"]) - return response, status_code + 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, + }) + + 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"]) def login(): @@ -61,10 +73,12 @@ def login(): user = UserRepository.get_by_email(data["email"]) - # Comparamos contra el atributo real del modelo de base de datos (password_hash). Guardia explícita para cuentas solo-OAuth (password_hash is None) antes de llamar a bcrypt. if not user or user.password_hash is None or not verify_password(data["password"], user.password_hash): return error_response(code="UNAUTHORIZED", message="Credenciales incorrectas.", status_code=401) + if user.email_verified_at is None: + return error_response(code="EMAIL_NOT_VERIFIED", message="Verifica tu correo antes de iniciar sesión.", status_code=403) + tokens = generate_tokens(user_id=user.id, role=user.role) user_data = UserResponseSchema().dump(user) @@ -153,9 +167,9 @@ def google_login(): "first_name": idinfo.get("given_name", "Usuario"), "last_name": idinfo.get("family_name", "Google"), "role": "REGISTERED", + "email_verified_at": datetime.now(timezone.utc), }) - # Vinculamos esta identidad de Google a la cuenta, nueva o existente; si ya habia una cuenta con este correo via registro normal, queda vinculada automaticamente. oauth_link = OAuthAccountRepository.create({ "user_id": user.id, "provider": "google", @@ -176,6 +190,137 @@ def google_login(): return response, status_code +def _validate_verification_token(token: str): + """Valida un token de verificación sin mutar ningún estado. + Retorna (token_row, user, None) si el token es válido; retorna (None, None, response_tuple) con el error correspondiente si no lo es. + Esta función es segura para llamarse desde un GET, 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: + return None, None, error_response( + code="TOKEN_INVALID", + message="El enlace de verificación no es válido.", + status_code=404, + ) + + if token_row.used_at is not None: + return None, None, error_response( + code="TOKEN_ALREADY_USED", + message="Este correo ya fue verificado anteriormente.", + 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: + return None, None, error_response( + code="TOKEN_EXPIRED", + message="El enlace expiró. Solicita uno nuevo.", + status_code=410, + ) + + user = UserRepository.get_by_id(token_row.user_id) + return token_row, user, None + + +@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, + ) + + token_row, user, err = _validate_verification_token(token) + if err: + return err + + return success_response( + data={"valid": True, "email": user.email if user else None}, + 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, + ) + + token_row, user, err = _validate_verification_token(token) + if err: + return err + + now = datetime.now(timezone.utc) + if user: + user.email_verified_at = now + UserRepository.save(user) + + EmailVerificationTokenRepository.mark_as_used(token_row) + + return success_response( + data={"message": "Correo verificado correctamente."}, + status_code=200, + ) + + +@auth_bp.route("/resend-verification", methods=["POST"]) +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) + + user = UserRepository.get_by_email(data["email"]) + if not user or user.email_verified_at is not None: + return generic_ok, status_code + + 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, + }) + + 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"]) def forgot_password(): try: @@ -204,7 +349,7 @@ def forgot_password(): "expires_at": expires_at, }) - # Stub de desarrollo. SendGrid se integra en ronda separada, ver continuidad del proyecto. + # Stub de desarrollo. El envío real de este flujo (via Resend) queda pendiente de migración; ver deuda técnica documentada. reset_url = f"{current_app.config['FRONTEND_BASE_URL']}/views/restablecer-contrasena.html?token={plain_token}" logger.info("[DEV] Reset link para %s: %s", user.email, reset_url) @@ -221,7 +366,6 @@ def reset_password(): token_hash = hashlib.sha256(data["token"].encode()).hexdigest() token_row = PasswordResetTokenRepository.get_by_token_hash(token_hash) - # Rechazamos si el token no existe, ya fue consumido, o expiró. 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) From 73029c6d2fa21fa39c23e7cea76d77722179b071 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 5 Jul 2026 17:20:57 -0600 Subject: [PATCH 116/277] feat(frontend): add verificar-correo view with four-state verification flow Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira --- frontend/assets/css/main.css | 1 + .../assets/css/pages/_verificar-correo.css | 125 +++++++++++ frontend/assets/js/pages/verificar-correo.js | 179 +++++++++++++++ frontend/views/verificar-correo.html | 207 ++++++++++++++++++ 4 files changed, 512 insertions(+) create mode 100644 frontend/assets/css/pages/_verificar-correo.css create mode 100644 frontend/assets/js/pages/verificar-correo.js create mode 100644 frontend/views/verificar-correo.html diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css index 5a68e99..21fe3d6 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -33,4 +33,5 @@ @import url("pages/_perfil.css"); @import url("pages/_habilidades.css"); @import url("pages/_salarios.css"); +@import url("pages/_verificar-correo.css"); /* @import url('pages/_admin.css'); */ 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/js/pages/verificar-correo.js b/frontend/assets/js/pages/verificar-correo.js new file mode 100644 index 0000000..0f9bd69 --- /dev/null +++ b/frontend/assets/js/pages/verificar-correo.js @@ -0,0 +1,179 @@ +/* Separamos el flujo de verificación en dos pasos (GET para validar y POST para confirmar) porque los escáneres automáticos de correo suelen seguir los enlaces y si el GET mutara el estado, los tokens se consumirían antes de que el usuario realmente hiciera clic */ + +const ALL_STATES = [ + "verify-loading", + "verify-confirm", + "verify-success", + "verify-resend", + "verify-already-used", +]; + +function showState(stateId) { + ALL_STATES.forEach((id) => { + const el = document.getElementById(id); + if (el) el.hidden = id !== stateId; + }); + if (typeof lucide !== "undefined") lucide.createIcons(); +} + +function configureResendState(isExpired) { + const icon = document.getElementById("verify-resend-icon"); + const title = document.getElementById("verify-resend-title"); + const subtitle = document.getElementById("verify-resend-subtitle"); + + if (isExpired) { + if (icon) icon.setAttribute("data-lucide", "clock-alert"); + if (title) title.textContent = "El enlace expiró"; + if (subtitle) + subtitle.textContent = + "Los enlaces de verificación son válidos por 24 horas. Solicita uno nuevo y revisa tu bandeja."; + } else { + if (icon) icon.setAttribute("data-lucide", "link-2-off"); + if (title) title.textContent = "Enlace inválido"; + if (subtitle) + subtitle.textContent = + "Este enlace de verificación no es válido o ya no existe. Puedes solicitar uno nuevo ingresando tu correo."; + } +} + +// Aplicamos GET para validación segura sin mutación +async function verifyToken() { + const params = new URLSearchParams(window.location.search); + const token = params.get("token"); + + if (!token) { + configureResendState(false); + showState("verify-resend"); + return; + } + + try { + // Consumimos el endpoint con GET para asegurar que no se produzcan efectos secundarios; la librería cliente extrae el payload automáticamente + const result = await apiGet( + `/auth/verify-email?token=${encodeURIComponent(token)}`, + ); + + const subtitle = document.getElementById("verify-confirm-subtitle"); + if (subtitle && result.email) { + subtitle.textContent = `Tu enlace es válido para ${result.email}. Haz clic en el botón para activar tu cuenta.`; + } + + const confirmBtn = document.getElementById("verify-confirm-btn"); + if (confirmBtn) { + confirmBtn.addEventListener("click", () => confirmVerification(token), { + once: true, // Aplicamos un solo clic para evitar múltiples envíos + }); + } + + showState("verify-confirm"); + } catch (error) { + const code = error.code || ""; + + if (code === "TOKEN_EXPIRED") { + configureResendState(true); + showState("verify-resend"); + } else if (code === "TOKEN_ALREADY_USED") { + showState("verify-already-used"); + } else { + configureResendState(false); + showState("verify-resend"); + } + } +} + +async function confirmVerification(token) { + const confirmBtn = document.getElementById("verify-confirm-btn"); + const errorBox = document.getElementById("verify-confirm-error"); + const originalText = confirmBtn ? confirmBtn.textContent : ""; + + if (errorBox) errorBox.hidden = true; + if (confirmBtn) { + confirmBtn.disabled = true; + confirmBtn.textContent = "Verificando..."; + } + + try { + await apiPost("/auth/verify-email", { token }); + showState("verify-success"); + } catch (error) { + // Capturamos el error específico por si el token se consumió en paralelo durante el intervalo entre la validación inicial y el clic manual. + const code = error.code || ""; + + if (code === "TOKEN_ALREADY_USED") { + showState("verify-already-used"); + } else if (code === "TOKEN_EXPIRED") { + configureResendState(true); + showState("verify-resend"); + } else { + if (errorBox) { + errorBox.textContent = + error.message || "No se pudo verificar. Intenta de nuevo."; + errorBox.hidden = false; + } + if (confirmBtn) { + confirmBtn.disabled = false; + confirmBtn.textContent = originalText; + // Restauramos el escuchador de eventos porque la bandera de un solo clic lo consumió durante el intento fallido. + confirmBtn.addEventListener("click", () => confirmVerification(token), { + once: true, + }); + } + } + } +} + +async function handleResendSubmit(event) { + event.preventDefault(); + + const form = event.target; + const submitBtn = document.getElementById("verify-resend-submit"); + const errorBox = document.getElementById("verify-resend-error"); + const okBox = document.getElementById("verify-resend-ok"); + const originalText = submitBtn.textContent; + + errorBox.hidden = true; + okBox.hidden = true; + submitBtn.disabled = true; + submitBtn.textContent = "Enviando..."; + + try { + // Asumimos un resultado exitoso constante para prevenir que un atacante descubra cuáles correos están registrados en nuestro sistema. + const result = await apiPost("/auth/resend-verification", { + email: form.email.value.trim(), + }); + okBox.textContent = + result.message || + "Si el correo existe y no ha sido verificado, se envió un nuevo enlace."; + okBox.hidden = false; + form.email.value = ""; + } catch (error) { + errorBox.textContent = + error.message || "No se pudo enviar el enlace. Intenta más tarde."; + errorBox.hidden = false; + } finally { + submitBtn.disabled = false; + submitBtn.textContent = originalText; + } +} + +function initAuthClose() { + const closeButton = document.querySelector("[data-auth-close]"); + if (!closeButton) return; + + closeButton.addEventListener("click", () => { + if (window.history.length > 1) { + window.history.back(); + } else { + window.location.href = "index.html"; + } + }); +} + +document.addEventListener("DOMContentLoaded", () => { + initAuthClose(); + + const resendForm = document.getElementById("verify-resend-form"); + if (resendForm) resendForm.addEventListener("submit", handleResendSubmit); + + verifyToken(); +}); diff --git a/frontend/views/verificar-correo.html b/frontend/views/verificar-correo.html new file mode 100644 index 0000000..72c63fe --- /dev/null +++ b/frontend/views/verificar-correo.html @@ -0,0 +1,207 @@ + + + + + + + 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. +

    +
    +
    +
    +
    + + + + + + + From 2145af9eeb9df6ca087b9334182055185ac5eaad Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 5 Jul 2026 17:22:15 -0600 Subject: [PATCH 117/277] feat(frontend): remove auto-login on register, handle EMAIL_NOT_VERIFIED with resend banner Co-authored-by: Alejandro Balderrama --- frontend/assets/js/pages/register.js | 95 ++++++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 4 deletions(-) diff --git a/frontend/assets/js/pages/register.js b/frontend/assets/js/pages/register.js index 224c00b..4a8e04a 100644 --- a/frontend/assets/js/pages/register.js +++ b/frontend/assets/js/pages/register.js @@ -72,6 +72,83 @@ function updateRegisterSubmitState() { ); } +function showRegisterConfirmation(email) { + const registerPanel = document.querySelector('[data-auth-form="register"]'); + if (!registerPanel) return; + + registerPanel.innerHTML = ` +
    +
    + +
    +

    ¡Revisa tu correo!

    +

    + Enviamos un enlace de verificación a ${email}. + Haz clic en él para activar tu cuenta. +

    +

    + ¿No llegó? Revisa la carpeta de spam o + . +

    +
    + `; + + if (typeof lucide !== "undefined") lucide.createIcons(); + + const resendBtn = document.getElementById("reg-resend-btn"); + if (resendBtn) { + resendBtn.addEventListener("click", async () => { + resendBtn.disabled = true; + resendBtn.textContent = "Enviando..."; + try { + await apiPost("/auth/resend-verification", { email }); + resendBtn.textContent = "¡Enviado!"; + } catch { + resendBtn.textContent = "Error al reenviar"; + resendBtn.disabled = false; + } + }); + } +} + +function showUnverifiedBanner(form, email) { + const existing = document.getElementById("login-unverified-banner"); + if (existing) existing.remove(); + + const banner = document.createElement("div"); + banner.id = "login-unverified-banner"; + banner.className = "login-unverified-banner"; + banner.setAttribute("role", "alert"); + banner.innerHTML = ` + + + `; + + form.insertAdjacentElement("afterend", banner); + + const resendBtn = document.getElementById("login-resend-btn"); + if (resendBtn) { + resendBtn.addEventListener("click", async () => { + resendBtn.disabled = true; + resendBtn.textContent = "Enviando..."; + try { + await apiPost("/auth/resend-verification", { email }); + resendBtn.textContent = "¡Enviado! Revisa tu bandeja."; + } catch { + resendBtn.textContent = "Error al reenviar. Intenta de nuevo."; + resendBtn.disabled = false; + } + }); + } +} + async function handleRegisterSubmit(event) { event.preventDefault(); @@ -85,15 +162,17 @@ async function handleRegisterSubmit(event) { submitButton.textContent = "Creando cuenta..."; const { firstName, lastName } = splitFullName(form.fullName.value); + const email = form.email.value; try { await apiPost("/auth/register", { first_name: firstName, last_name: lastName, - email: form.email.value, + email: email, password: form.password.value, }); - window.location.href = "panorama.html"; + // No redirigimos al panorama porque el registro exitoso ya no otorga sesión; requerimos que el usuario confirme su correo primero. + showRegisterConfirmation(email); } catch (error) { errorBox.textContent = error.message; errorBox.hidden = false; @@ -124,6 +203,10 @@ async function handleLoginSubmit(event) { const originalText = submitButton.textContent; errorBox.hidden = true; + // Limpia cualquier banner de reenvío previo + const prevBanner = document.getElementById("login-unverified-banner"); + if (prevBanner) prevBanner.remove(); + submitButton.disabled = true; submitButton.textContent = "Iniciando sesión..."; @@ -134,8 +217,12 @@ async function handleLoginSubmit(event) { }); window.location.href = "panorama.html"; } catch (error) { - errorBox.textContent = error.message; - errorBox.hidden = false; + if (error.code === "EMAIL_NOT_VERIFIED") { + showUnverifiedBanner(form, form.email.value); + } else { + errorBox.textContent = error.message; + errorBox.hidden = false; + } submitButton.disabled = false; submitButton.textContent = originalText; } From 45c74cda72a2d2589a572c52d7a2729f2375a5d8 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 12:28:26 -0600 Subject: [PATCH 118/277] fix(repositories): remove redundant truncation, dedupe city lookup logic --- backend/app/repositories/base_repository.py | 7 +-- backend/app/repositories/city_repository.py | 49 ++++++++++++++++++++- backend/app/repositories/job_repository.py | 17 ++++--- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/backend/app/repositories/base_repository.py b/backend/app/repositories/base_repository.py index 43c09c2..a661988 100644 --- a/backend/app/repositories/base_repository.py +++ b/backend/app/repositories/base_repository.py @@ -2,12 +2,12 @@ from app.extensions import db class BaseRepository: - # Repositorio genérico con soporte de instanciación dinámica y segura. + # 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. + # 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() @@ -24,7 +24,8 @@ def save(cls, entity): return entity except Exception as e: db.session.rollback() - print(f"\n[ERROR DE PERSISTENCIA] Fallo al guardar en BD: {str(e)}\n") + safe_msg = str(e).encode("ascii", errors="replace").decode("ascii") + print(f"\n[ERROR DE PERSISTENCIA] Fallo al guardar en BD: {safe_msg}\n") return None @classmethod diff --git a/backend/app/repositories/city_repository.py b/backend/app/repositories/city_repository.py index e82f070..e8041cb 100644 --- a/backend/app/repositories/city_repository.py +++ b/backend/app/repositories/city_repository.py @@ -1,7 +1,8 @@ from app.repositories.base_repository import BaseRepository from app.models.city import City from app.extensions import db - +from app.clients.nominatim_client import NominatimClient +import unicodedata class CityRepository(BaseRepository): model = City @@ -11,3 +12,49 @@ def get_by_name(cls, name: str): return db.session.execute( db.select(City).filter_by(name=name) ).scalar_one_or_none() + + @classmethod + def get_or_create_city(cls, raw_location: str) -> tuple[City | None, bool]: + if not raw_location: + return None, False + + # lowercase, sin acentos y trim (Normaliza) + normalized = raw_location.strip().lower() + normalized = ''.join(c for c in unicodedata.normalize('NFD', normalized) if unicodedata.category(c) != 'Mn') + + # Búsqueda exhaustiva comparando el nombre normalizado + all_cities = db.session.execute(db.select(City)).scalars().all() + for city in all_cities: + city_norm = city.name.strip().lower() + city_norm = ''.join(c for c in unicodedata.normalize('NFD', city_norm) if unicodedata.category(c) != 'Mn') + if city_norm == normalized: + return city, False + + # Si no existe, llama a geocode_city + 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 México"). Revisamos si ese nombre real ya existe en BD para evitar IntegrityError secuencial + resolved_name = geo_data["name"] + resolved_norm = resolved_name.strip().lower() + resolved_norm = ''.join(c for c in unicodedata.normalize('NFD', resolved_norm) if unicodedata.category(c) != 'Mn') + + for city in all_cities: + city_norm = city.name.strip().lower() + city_norm = ''.join(c for c in unicodedata.normalize('NFD', city_norm) if unicodedata.category(c) != 'Mn') + if city_norm == resolved_norm: + return city, False + + # Si retorna datos válidos y no existe, inserta una nueva fila + new_city = City( + name=geo_data["name"], + state=geo_data["state"], + lat=geo_data["lat"], + lon=geo_data["lon"], + country="MX" + ) + + db.session.add(new_city) + db.session.commit() + return new_city, True diff --git a/backend/app/repositories/job_repository.py b/backend/app/repositories/job_repository.py index 6cefb20..a2260e1 100644 --- a/backend/app/repositories/job_repository.py +++ b/backend/app/repositories/job_repository.py @@ -1,5 +1,6 @@ from app.models.job import Job from app.repositories.base_repository import BaseRepository +from app.extensions import db class JobRepository(BaseRepository): model = Job @@ -17,12 +18,14 @@ def create(cls, data: dict): if "url" in mapped_data: del mapped_data["url"] - # Protección contra StringDataRightTruncation + # 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" - - if mapped_data.get("title"): - mapped_data["title"] = str(mapped_data["title"])[:150] - if mapped_data.get("company"): - mapped_data["company"] = str(mapped_data["company"])[:100] - + 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() From 5f43ad48275954f2a8f5255037eeb99652bf5a82 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 12:29:13 -0600 Subject: [PATCH 119/277] feat(geocoding): add Nominatim client with rate limiting and query disambiguation --- backend/app/clients/nominatim_client.py | 103 ++++++++++++++++++++---- 1 file changed, 86 insertions(+), 17 deletions(-) diff --git a/backend/app/clients/nominatim_client.py b/backend/app/clients/nominatim_client.py index 7d98cbc..eb412cc 100644 --- a/backend/app/clients/nominatim_client.py +++ b/backend/app/clients/nominatim_client.py @@ -1,34 +1,103 @@ +import time +import logging import requests -from app.utils.errors import AppError +from requests.exceptions import RequestException + +logger = logging.getLogger(__name__) class NominatimClient: - # Aislamos el servicio de geocodificación. Nominatim exige un User-Agent válido por sus políticas de uso libre, de lo contrario bloquea la petición. BASE_URL = "https://nominatim.openstreetmap.org/search" + USER_AGENT = "SkillStat/1.0 (proyecto academico UTCJ, contacto: eliaslucinoochoamalaga@gmail.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 geocode(cls, city_name: str, country: str = "Mexico") -> dict: - params = { - "city": city_name, - "country": country, - "format": "json", - "limit": 1 - } + 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)""" + import unicodedata + + # Desambiguación de query + normalized_query = query.strip().lower() + normalized_query = ''.join(c for c in unicodedata.normalize('NFD', normalized_query) if unicodedata.category(c) != 'Mn') + + # Eliminamos puntos comunes como en "mexico d.f." -> "mexico df" para que coincida con el diccionario + normalized_query = normalized_query.replace(".", "") + + actual_query = cls.QUERY_DISAMBIGUATION.get(normalized_query, query) + + # Sleep to respect Nominatim's strict 1 req/sec limit + time.sleep(1.1) + headers = { - "User-Agent": "SkillStat/1.0 (Student Project UTCJ)" + "User-Agent": cls.USER_AGENT } - + params = { + "q": actual_query, + "format": "jsonv2", + "countrycodes": "mx", + "limit": 1, + "addressdetails": 1 + } + try: - response = requests.get(cls.BASE_URL, params=params, headers=headers, timeout=10) + response = requests.get(cls.BASE_URL, headers=headers, params=params, timeout=10) response.raise_for_status() data = response.json() if not data: return None - # Devolvemos solo latitud y longitud para mantener el contrato de datos simple + result = data[0] + + place_type = result.get("type", "").lower() + place_class = result.get("class", "").lower() + addresstype = result.get("addresstype", "").lower() + + if place_type not in cls.VALID_TYPES and place_class not in cls.VALID_TYPES and addresstype not in cls.VALID_TYPES: + return None + + address = result.get("address", {}) + state = address.get("state") + + if not state: + return None + + name = address.get("city") or address.get("town") or address.get("village") or address.get("municipality") or result.get("name") + + if not name: + return None + return { - "lat": float(data[0]["lat"]), - "lon": float(data[0]["lon"]) + "name": name, + "state": state, + "lat": float(result.get("lat")), + "lon": float(result.get("lon")) } - except requests.RequestException as e: - raise AppError(f"Error de geocodificación en Nominatim: {str(e)}", code="EXTERNAL_API_ERROR") + + 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 From ba1da7ca037c2359f4da42bca3f12c71f9315f23 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 12:29:59 -0600 Subject: [PATCH 120/277] feat(ingestion): geocode job locations via Nominatim, add ingest-jobs CLI command Co-authored-by: Oscar Soriano --- backend/app/services/ingestion_service.py | 63 ++++++++++++++++++----- backend/run.py | 46 ++++++++++++++++- 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/backend/app/services/ingestion_service.py b/backend/app/services/ingestion_service.py index 7faebe5..1c3e9ce 100644 --- a/backend/app/services/ingestion_service.py +++ b/backend/app/services/ingestion_service.py @@ -1,17 +1,23 @@ 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.repositories.city_repository import CityRepository from app.utils.errors import AppError +# 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) -> dict: - stats = {"fetched": 0, "processed": 0, "skipped_or_failed": 0} + 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()} @@ -21,24 +27,33 @@ def run_ingestion(cls, country: str = "mx", what: str = "software developer", pa 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) + cls._process_job(item, known_skills, stats, verbose=verbose) return stats @classmethod - def _process_job(cls, item: dict, known_skills: dict, stats: dict) -> None: + def _process_job(cls, item: dict, known_skills: dict, stats: dict, verbose: bool = False) -> None: description = item.get("description", "") if not description: - stats["skipped_or_failed"] += 1 + 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() + + # Validar 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") @@ -50,22 +65,32 @@ def _process_job(cls, item: dict, known_skills: dict, stats: dict) -> None: 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[:200], - "company": company[:200], + "title": title, + "company": company, "description": description, "url": url, "description_hash": desc_hash, "remote": is_remote, - # Capturamos los rangos salariales cuando Adzuna los incluye. Muchas vacantes no los declaran, por eso permitimos nulos. + "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, } job = JobRepository.create(job_data) if not job: - # El repositorio atrapó un error SQL (generalmente violación de UNIQUE del hash) - stats["skipped_or_failed"] += 1 + # El repositorio atrapó un error SQL (diferente a duplicado, ya que esos los validamos antes) + stats["errors"] += 1 return # Extracción NLP y vinculación relacional @@ -82,9 +107,23 @@ def _process_job(cls, item: dict, known_skills: dict, stats: dict) -> None: 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 = CityRepository.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 de verdad en memoria durante el ciclo para minimizar I/O contra PostgreSQL. + # 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] @@ -92,7 +131,7 @@ def _get_or_create_skill(cls, skill_name: str, known_skills: dict): 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. + # Asignamos la categoría General como fallback para habilidades detectadas por el NLP que aún no tienen clasificación formal "category_id": 1, }) diff --git a/backend/run.py b/backend/run.py index c48690a..0ac79e9 100644 --- a/backend/run.py +++ b/backend/run.py @@ -1,4 +1,5 @@ import os +import click from dotenv import load_dotenv load_dotenv() @@ -10,7 +11,48 @@ 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. + # 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) \ No newline at end of file + 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}") \ No newline at end of file From 5b3fc30fb23d4efdef51b0105e8ae2d711ff7917 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 12:31:03 -0600 Subject: [PATCH 121/277] fix(trends): upsert snapshots by (skill_id, city_id, date) to prevent silent data loss Co-authored-by: Aylin Chavira --- backend/app/services/market_trends_service.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/backend/app/services/market_trends_service.py b/backend/app/services/market_trends_service.py index a68f3d1..9b3a744 100644 --- a/backend/app/services/market_trends_service.py +++ b/backend/app/services/market_trends_service.py @@ -5,7 +5,7 @@ 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. + # 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: @@ -14,19 +14,22 @@ def generate_snapshots(cls) -> int: if not raw_data: return 0 - # Incluimos salary_min y salary_max de la vacante asociada para poder calcular el salario promedio por habilidad en el mismo paso que calculamos la demanda, evitando una segunda consulta. + # 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. + # 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) - trends = df.groupby("skill_id").agg( + # 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() @@ -36,7 +39,7 @@ def generate_snapshots(cls) -> int: 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. + # 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: @@ -44,13 +47,13 @@ def generate_snapshots(cls) -> int: snapshot_data = { "skill_id": int(row["skill_id"]), - "city_id": 1, + "city_id": int(row["city_id"]), "date": today, "demand_count": int(row["demand_count"]), "avg_salary": avg_salary_value, } - result = TrendSnapshotRepository.create(snapshot_data) + result = TrendSnapshotRepository.upsert(snapshot_data) if result: snapshots_created += 1 From d1755fbf5fd01521c3d7a3cdb1502da7e8bb775e Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 12:31:40 -0600 Subject: [PATCH 122/277] feat(api): add group_by=state to /panorama/geo with is_fallback flag Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- backend/app/controllers/panorama_bp.py | 47 +++++++++---- .../repositories/trend_snapshot_repository.py | 69 +++++++++++++++---- backend/app/schemas/panorama_schema.py | 20 +++--- 3 files changed, 97 insertions(+), 39 deletions(-) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py index dbd3dac..f9b99b4 100644 --- a/backend/app/controllers/panorama_bp.py +++ b/backend/app/controllers/panorama_bp.py @@ -42,7 +42,7 @@ def get_catalogs(): @panorama_bp.route("/summary", methods=["GET"]) def get_summary(): - # KPIs globales que alimentan las tarjetas superiores del Panorama. + # KPIs globales que alimentan las tarjetas superiores del Panorama data = TrendSnapshotRepository.get_summary_data() def build_skill_block(row): @@ -72,9 +72,9 @@ def build_skill_block(row): @panorama_bp.route("/skills/top", methods=["GET"]) def get_top_skills(): - # Ranking de habilidades por demanda actual. El frontend lo usa para la grafica de barras principal del Panorama. + # Ranking de habilidades por demanda actual. El frontend lo usa para la grafica de barras principal del Panorama 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. + # Acotamos el limite para evitar que un valor arbitrario en la query fuerce una consulta desproporcionada contra la base de datos limit = max(1, min(limit, 50)) snapshots = TrendSnapshotRepository.get_top_skills(limit=limit) @@ -97,7 +97,7 @@ def get_top_skills(): @panorama_bp.route("/trends", methods=["GET"]) def get_trends(): - # Serie temporal de demanda para una habilidad especifica. El frontend la usa para la grafica de lineas de evolucion. + # Serie temporal de demanda para una habilidad especifica. El frontend la usa para la grafica de lineas de evolucion skill_id = request.args.get("skill_id", type=int) if not skill_id: @@ -134,6 +134,14 @@ def get_trends(): def get_geo(): # Distribucion geografica de demanda. Si se filtra por skill_id devolvemos la distribucion de esa habilidad especifica, de lo contrario la demanda total agregada por ciudad. 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, + ) skill = None if skill_id is not None: @@ -145,19 +153,28 @@ def get_geo(): status_code=404, ) - rows = TrendSnapshotRepository.get_geo_distribution(skill_id=skill_id) + rows = TrendSnapshotRepository.get_geo_distribution(skill_id=skill_id, group_by=group_by) - payload = { - "skill_id": skill.id if skill else None, - "skill_name": skill.name if skill else None, - "distribution": [ - { + 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, - } - for row in rows - ], + }) + + payload = { + "skill_id": skill.id if skill else None, + "skill_name": skill.name if skill else None, + "distribution": distribution, } result = GeoResponseSchema().dump(payload) @@ -200,7 +217,7 @@ def get_salaries(): @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. + # 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(): @@ -219,7 +236,7 @@ def get_compare(): 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. + # 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", diff --git a/backend/app/repositories/trend_snapshot_repository.py b/backend/app/repositories/trend_snapshot_repository.py index 9309cb5..545afbd 100644 --- a/backend/app/repositories/trend_snapshot_repository.py +++ b/backend/app/repositories/trend_snapshot_repository.py @@ -16,9 +16,35 @@ def get_latest_by_skill(cls, skill_id: int): .limit(1) ).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. + # 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)) @@ -43,8 +69,7 @@ def get_by_city_id(cls, city_id: int) -> list: @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. + # 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( @@ -86,7 +111,7 @@ def get_summary_data(cls): 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. + # 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) @@ -111,21 +136,35 @@ def get_summary_data(cls): } @classmethod - def get_geo_distribution(cls, skill_id: int = None) -> list: + def get_geo_distribution(cls, skill_id: int = None, group_by: str = "city") -> list: from sqlalchemy import func from app.models.city import City - # Sumamos demand_count por ciudad. Si se filtra por skill_id, la suma queda acotada a esa habilidad especifica, de lo contrario agregamos la demanda total de todas las habilidades. - query = ( - db.select( - City.id.label("city_id"), - City.name.label("city_name"), - func.sum(TrendSnapshot.demand_count).label("total_demand"), + 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()) ) - .join(City, City.id == TrendSnapshot.city_id) - .group_by(City.id, City.name) - .order_by(func.sum(TrendSnapshot.demand_count).desc()) - ) if skill_id is not None: query = query.filter(TrendSnapshot.skill_id == skill_id) diff --git a/backend/app/schemas/panorama_schema.py b/backend/app/schemas/panorama_schema.py index f08cc5d..e1a94ce 100644 --- a/backend/app/schemas/panorama_schema.py +++ b/backend/app/schemas/panorama_schema.py @@ -2,7 +2,7 @@ class SkillTrendSchema(Schema): - # Representa una habilidad con su metrica de demanda actual. Usado en skills/top y como bloque base de otros endpoints. + # 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) @@ -12,7 +12,7 @@ class SkillTrendSchema(Schema): class SummaryResponseSchema(Schema): - # KPIs globales del Panorama, totales y tendencias destacadas. + # 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) @@ -32,13 +32,13 @@ class SkillOptionSchema(Schema): class CatalogsResponseSchema(Schema): - # Listas livianas para alimentar selectores del frontend. + # 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. + # Un punto en la serie temporal de una habilidad especifica date = fields.Date(dump_only=True) demand_count = fields.Integer(dump_only=True) @@ -50,10 +50,12 @@ class TrendsResponseSchema(Schema): class GeoDistributionSchema(Schema): - # Demanda de una habilidad agrupada por ciudad. - city_id = fields.Integer(dump_only=True) - city_name = fields.String(dump_only=True) + # 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): @@ -63,7 +65,7 @@ class GeoResponseSchema(Schema): class SalaryResponseSchema(Schema): - # Cruce de habilidad contra rango salarial promedio. + # 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) @@ -72,7 +74,7 @@ class SalaryResponseSchema(Schema): class CompareSkillBlockSchema(Schema): - # Bloque de metricas para una sola habilidad dentro de la comparacion. + # 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) From e0ecbbd908e09d7f063b131719e9b1b667827694 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 12:32:15 -0600 Subject: [PATCH 123/277] feat(frontend): add getGeoDistribution client for regional grouping Co-authored-by: Oscar Soriano --- frontend/assets/js/api/panorama.api.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/assets/js/api/panorama.api.js b/frontend/assets/js/api/panorama.api.js index 959fc5b..c8ba586 100644 --- a/frontend/assets/js/api/panorama.api.js +++ b/frontend/assets/js/api/panorama.api.js @@ -11,9 +11,13 @@ async function getCatalogs() { } async function getCompareSkills(skillIds) { - // La respuesta de /compare viene envuelta en { skills: [...] }, a diferencia de otros endpoints que devuelven el array directo. + // La respuesta de /compare viene envuelta en { skills: [...] }, a diferencia de otros endpoints que devuelven el array directo const result = await apiGet( `/panorama/compare?skill_ids=${skillIds.join(",")}`, ); return result.skills; } + +async function getGeoDistribution(groupBy = "state") { + return apiGet(`/panorama/geo?group_by=${groupBy}`); +} From 6944706a0ebc537ddcbe248720378555aef2160a Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 12:33:10 -0600 Subject: [PATCH 124/277] feat(frontend): add regiones view with state-level demand distribution Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- frontend/assets/css/pages/regiones.css | 51 ++++++ frontend/assets/js/pages/regiones.js | 80 +++++++++ frontend/views/regiones.html | 233 +++++++++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 frontend/assets/css/pages/regiones.css create mode 100644 frontend/assets/js/pages/regiones.js create mode 100644 frontend/views/regiones.html 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/js/pages/regiones.js b/frontend/assets/js/pages/regiones.js new file mode 100644 index 0000000..40422d3 --- /dev/null +++ b/frontend/assets/js/pages/regiones.js @@ -0,0 +1,80 @@ +// Construimos las barras con divs +function renderRegionsChart(container, distribution) { + const unmappedContainer = document.querySelector("[data-regions-unmapped]"); + + if (!distribution || distribution.length === 0) { + container.innerHTML = + '

    No hay datos disponibles

    '; + if (unmappedContainer) unmappedContainer.style.display = "none"; + return; + } + + // Filtramos el caso base que no tiene ubicación geográfica específica (Nacional) + const nationalData = distribution.find((item) => item.is_fallback === true); + const competitiveData = distribution.filter( + (item) => item.is_fallback !== true, + ); + + container.innerHTML = ""; + + if (competitiveData.length > 0) { + const maxDemand = Math.max( + ...competitiveData.map((item) => item.demand_count), + ); + + competitiveData.forEach((item) => { + const heightPercent = (item.demand_count / maxDemand) * 100; + + const column = document.createElement("div"); + column.className = "regions-chart__column"; + + const value = document.createElement("span"); + value.className = "regions-chart__value text-label"; + value.textContent = item.demand_count; + + const bar = document.createElement("div"); + bar.className = + item.demand_count === maxDemand + ? "regions-chart__bar regions-chart__bar--top" + : "regions-chart__bar"; + bar.style.height = `${heightPercent}%`; + bar.setAttribute("aria-hidden", "true"); + + const label = document.createElement("span"); + label.className = "regions-chart__label text-label"; + label.textContent = item.state; + + column.append(value, bar, label); + container.appendChild(column); + }); + } else { + container.innerHTML = + '

    No hay datos regionales específicos

    '; + } + + if (unmappedContainer) { + if (nationalData && nationalData.demand_count > 0) { + unmappedContainer.textContent = `* Adicionalmente existen ${nationalData.demand_count} vacantes sin ubicación geográfica específica a nivel estatal.`; + unmappedContainer.style.display = "block"; + } else { + unmappedContainer.style.display = "none"; + } + } +} + +async function initRegionesPage() { + const chartContainer = document.querySelector("[data-regions-chart]"); + + try { + const geoResponse = await getGeoDistribution("state"); + renderRegionsChart(chartContainer, geoResponse.distribution); + } catch (error) { + console.error("No se pudieron cargar los datos de regiones:", error); + if (chartContainer) { + chartContainer.innerHTML = + '

    No disponible

    '; + } + } +} + +document.addEventListener("DOMContentLoaded", initRegionesPage); diff --git a/frontend/views/regiones.html b/frontend/views/regiones.html new file mode 100644 index 0000000..ce0172f --- /dev/null +++ b/frontend/views/regiones.html @@ -0,0 +1,233 @@ + + + + + + + SkillStat - Regiones + + + + + + + + + + + + + +
    +
    +
    +
    +

    Regiones

    +

    + Demanda agregada por estado +

    +
    + +
    + +
    + +
    + +
    +
    +
    +
    + + + + + + + + + + From c97c70d1401f74692694f749b6fac121f80b61ac Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 12:33:41 -0600 Subject: [PATCH 125/277] chore(navbar): link Regiones page across existing views Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- frontend/views/comparar.html | 4 ++-- frontend/views/habilidades.html | 4 ++-- frontend/views/panorama.html | 4 ++-- frontend/views/perfil.html | 4 ++-- frontend/views/salarios.html | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/views/comparar.html b/frontend/views/comparar.html index ba9c406..3aadf35 100644 --- a/frontend/views/comparar.html +++ b/frontend/views/comparar.html @@ -71,7 +71,7 @@ Salarios
  • - Regiones + Regiones
  • Reportes @@ -173,7 +173,7 @@ Salarios
  • - Regiones + Regiones
  • Reportes diff --git a/frontend/views/habilidades.html b/frontend/views/habilidades.html index 34f0f7a..26f0d9a 100644 --- a/frontend/views/habilidades.html +++ b/frontend/views/habilidades.html @@ -71,7 +71,7 @@ Salarios
  • - Regiones + Regiones
  • Reportes @@ -173,7 +173,7 @@ Salarios
  • - Regiones + Regiones
  • Reportes diff --git a/frontend/views/panorama.html b/frontend/views/panorama.html index 49853f6..434ac89 100644 --- a/frontend/views/panorama.html +++ b/frontend/views/panorama.html @@ -71,7 +71,7 @@ Salarios
  • - Regiones + Regiones
  • Reportes @@ -173,7 +173,7 @@ Salarios
  • - Regiones + Regiones
  • Reportes diff --git a/frontend/views/perfil.html b/frontend/views/perfil.html index 8baf827..831e997 100644 --- a/frontend/views/perfil.html +++ b/frontend/views/perfil.html @@ -66,7 +66,7 @@ Salarios
  • - Regiones + Regiones
  • Reportes @@ -163,7 +163,7 @@ Salarios
  • - Regiones + Regiones
  • Reportes diff --git a/frontend/views/salarios.html b/frontend/views/salarios.html index aa5ef0b..4b7f20c 100644 --- a/frontend/views/salarios.html +++ b/frontend/views/salarios.html @@ -71,7 +71,7 @@ >
  • - Regiones + Regiones
  • Reportes @@ -173,7 +173,7 @@ >
  • - Regiones + Regiones
  • Reportes From 39b15966af58621705a4eef01e48ffccd2bccb7d Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 12:33:57 -0600 Subject: [PATCH 126/277] docs: document mandatory ingest-jobs then generate-snapshots sequence --- backend/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/README.md b/backend/README.md index ff5b8a6..a8954c2 100644 --- a/backend/README.md +++ b/backend/README.md @@ -27,3 +27,13 @@ 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. From 384e081e3be70e4857f5b2af5164f519b1e773bc Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 21:44:13 -0600 Subject: [PATCH 127/277] chore(cleanup): remove dead backend/clients duplicate and empty trend_repository Both were confirmed via grep to have zero references anywhere in the codebase before removal. backend/clients/ was a stub duplicate of the real backend/app/clients/ (likely leftover from an incomplete folder reorganization). trend_repository.py was an empty file coexisting with the implemented trend_snapshot_repository.py. --- backend/app/repositories/trend_repository.py | 0 backend/clients/README.md | 23 -------------------- backend/clients/__init__.py | 1 - backend/clients/adzuna_client.py | 1 - backend/clients/base_client.py | 1 - backend/clients/nominatim_client.py | 1 - backend/clients/sendgrid_client.py | 1 - 7 files changed, 28 deletions(-) delete mode 100644 backend/app/repositories/trend_repository.py delete mode 100644 backend/clients/README.md delete mode 100644 backend/clients/__init__.py delete mode 100644 backend/clients/adzuna_client.py delete mode 100644 backend/clients/base_client.py delete mode 100644 backend/clients/nominatim_client.py delete mode 100644 backend/clients/sendgrid_client.py diff --git a/backend/app/repositories/trend_repository.py b/backend/app/repositories/trend_repository.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/clients/README.md b/backend/clients/README.md deleted file mode 100644 index 02b3205..0000000 --- a/backend/clients/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# clients/ - -Aqui viven los clientes que se comunican con APIs externas. Cada archivo -envuelve una sola API y expone metodos claros para que los servicios -puedan usarla sin conocer los detalles de la comunicacion HTTP. - -## Lo que va aqui - -- La logica de autenticacion con cada API externa -- El manejo de errores y reintentos de conexion -- La transformacion de la respuesta al formato que el sistema necesita - -## Los clientes del proyecto - -| Archivo | API que envuelve | -|---------|-----------------| -| `adzuna_client.py` | Adzuna Jobs API - fuente de vacantes | -| `nominatim_client.py` | Nominatim / OpenStreetMap - geocodificacion | -| `sendgrid_client.py` | SendGrid - envio de correos de alerta | -| `base_client.py` | Logica compartida: reintentos, timeouts, headers | - -Los servicios nunca llaman directamente a `requests` o `httpx`. -Siempre usan estos clientes. diff --git a/backend/clients/__init__.py b/backend/clients/__init__.py deleted file mode 100644 index d4eef60..0000000 --- a/backend/clients/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# clients/__init__ — SkillStat \ No newline at end of file diff --git a/backend/clients/adzuna_client.py b/backend/clients/adzuna_client.py deleted file mode 100644 index eda2ea1..0000000 --- a/backend/clients/adzuna_client.py +++ /dev/null @@ -1 +0,0 @@ -# adzuna_client — SkillStat \ No newline at end of file diff --git a/backend/clients/base_client.py b/backend/clients/base_client.py deleted file mode 100644 index db2f7e2..0000000 --- a/backend/clients/base_client.py +++ /dev/null @@ -1 +0,0 @@ -# base_client — SkillStat \ No newline at end of file diff --git a/backend/clients/nominatim_client.py b/backend/clients/nominatim_client.py deleted file mode 100644 index e48525f..0000000 --- a/backend/clients/nominatim_client.py +++ /dev/null @@ -1 +0,0 @@ -# nominatim_client — SkillStat \ No newline at end of file diff --git a/backend/clients/sendgrid_client.py b/backend/clients/sendgrid_client.py deleted file mode 100644 index a20b95c..0000000 --- a/backend/clients/sendgrid_client.py +++ /dev/null @@ -1 +0,0 @@ -# sendgrid_client — SkillStat \ No newline at end of file From a8bb858ef912072561ab2d9512817c12ddcd9e4d Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 22:54:40 -0600 Subject: [PATCH 128/277] refactor(auth): populate auth.api.js and auth.utils.js Both were 1-line stubs. auth.api.js now wraps the seven auth endpoints as named functions, matching the pattern already used by panorama.api.js. auth.utils.js now holds PASSWORD_RULES, updatePasswordChecklist, and initAuthClose, previously duplicated verbatim across page scripts. Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- frontend/assets/js/api/auth.api.js | 28 +++++++++++++++++++++++- frontend/assets/js/utils/auth.utils.js | 30 +++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/frontend/assets/js/api/auth.api.js b/frontend/assets/js/api/auth.api.js index 3fd0f8c..83d679f 100644 --- a/frontend/assets/js/api/auth.api.js +++ b/frontend/assets/js/api/auth.api.js @@ -1 +1,27 @@ -/* auth.api.js — SkillStat */ \ No newline at end of file +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/utils/auth.utils.js b/frontend/assets/js/utils/auth.utils.js index 0df3e29..c1eac85 100644 --- a/frontend/assets/js/utils/auth.utils.js +++ b/frontend/assets/js/utils/auth.utils.js @@ -1 +1,29 @@ -/* auth.utils.js — SkillStat */ \ No newline at end of file +const PASSWORD_RULES = { + length: (value) => value.length >= 8, + upper: (value) => /[A-Z]/.test(value), + number: (value) => /\d/.test(value), + special: (value) => /[^A-Za-z0-9]/.test(value), +}; + +function updatePasswordChecklist(password) { + Object.entries(PASSWORD_RULES).forEach(([rule, check]) => { + const item = document.querySelector(`[data-rule="${rule}"]`); + if (!item) return; + item.classList.toggle("is-valid", check(password)); + }); +} + +function initAuthClose() { + // Esta pantalla no es un overlay real sobre otra pagina, asi que cerrar + // significa volver al historial si existe, o caer a index.html si el + // usuario llego aqui directamente (ej. por un enlace compartido). + const closeButton = document.querySelector("[data-auth-close]"); + if (!closeButton) return; + closeButton.addEventListener("click", () => { + if (window.history.length > 1) { + window.history.back(); + } else { + window.location.href = "index.html"; + } + }); +} \ No newline at end of file From c8c67bdfb59b746b23c1d29eafc06b9d6d6af708 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 22:55:23 -0600 Subject: [PATCH 129/277] refactor(auth): consume shared auth layer in verificar-correo.js Removed local initAuthClose in favor of auth.utils.js. Verification calls now go through verifyEmailToken(), confirmEmailVerification(), and resendVerificationEmail() instead of calling apiGet/apiPost directly. HTML updated to load auth.api.js and auth.utils.js before the page script. Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- frontend/assets/js/pages/verificar-correo.js | 23 +++----------------- frontend/views/verificar-correo.html | 2 ++ 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/frontend/assets/js/pages/verificar-correo.js b/frontend/assets/js/pages/verificar-correo.js index 0f9bd69..a257b80 100644 --- a/frontend/assets/js/pages/verificar-correo.js +++ b/frontend/assets/js/pages/verificar-correo.js @@ -49,9 +49,7 @@ async function verifyToken() { try { // Consumimos el endpoint con GET para asegurar que no se produzcan efectos secundarios; la librería cliente extrae el payload automáticamente - const result = await apiGet( - `/auth/verify-email?token=${encodeURIComponent(token)}`, - ); + const result = await verifyEmailToken(token); const subtitle = document.getElementById("verify-confirm-subtitle"); if (subtitle && result.email) { @@ -93,7 +91,7 @@ async function confirmVerification(token) { } try { - await apiPost("/auth/verify-email", { token }); + await confirmEmailVerification(token); showState("verify-success"); } catch (error) { // Capturamos el error específico por si el token se consumió en paralelo durante el intervalo entre la validación inicial y el clic manual. @@ -138,9 +136,7 @@ async function handleResendSubmit(event) { try { // Asumimos un resultado exitoso constante para prevenir que un atacante descubra cuáles correos están registrados en nuestro sistema. - const result = await apiPost("/auth/resend-verification", { - email: form.email.value.trim(), - }); + const result = await resendVerificationEmail(form.email.value.trim()); okBox.textContent = result.message || "Si el correo existe y no ha sido verificado, se envió un nuevo enlace."; @@ -156,19 +152,6 @@ async function handleResendSubmit(event) { } } -function initAuthClose() { - const closeButton = document.querySelector("[data-auth-close]"); - if (!closeButton) return; - - closeButton.addEventListener("click", () => { - if (window.history.length > 1) { - window.history.back(); - } else { - window.location.href = "index.html"; - } - }); -} - document.addEventListener("DOMContentLoaded", () => { initAuthClose(); diff --git a/frontend/views/verificar-correo.html b/frontend/views/verificar-correo.html index 72c63fe..b5c3142 100644 --- a/frontend/views/verificar-correo.html +++ b/frontend/views/verificar-correo.html @@ -200,6 +200,8 @@

    Tu cuenta ya fue verificada

  • + + From 461c3035d966bf06c1e68e1fb7592a1a7de8b9f2 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 22:56:03 -0600 Subject: [PATCH 130/277] refactor(auth): consume shared auth layer in verificar-correo.js Removed local initAuthClose in favor of auth.utils.js. Verification calls now go through verifyEmailToken(), confirmEmailVerification(), and resendVerificationEmail() instead of calling apiGet/apiPost directly. HTML updated to load auth.api.js and auth.utils.js before the page script. Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- frontend/assets/js/pages/olvide-contrasena.js | 17 +---------------- frontend/views/olvide-contrasena.html | 2 ++ 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/frontend/assets/js/pages/olvide-contrasena.js b/frontend/assets/js/pages/olvide-contrasena.js index 648b214..3d10c0f 100644 --- a/frontend/assets/js/pages/olvide-contrasena.js +++ b/frontend/assets/js/pages/olvide-contrasena.js @@ -30,9 +30,7 @@ async function handleForgotSubmit(event) { submitButton.textContent = "Enviando..."; try { - const response = await apiPost("/auth/forgot-password", { - email: emailValue, - }); + const response = await requestPasswordReset(emailValue); messageBox.textContent = response.message || "Si el correo existe, recibirás un enlace de recuperación."; @@ -51,19 +49,6 @@ async function handleForgotSubmit(event) { } } -function initAuthClose() { - const closeButton = document.querySelector("[data-auth-close]"); - if (!closeButton) return; - - closeButton.addEventListener("click", () => { - if (window.history.length > 1) { - window.history.back(); - } else { - window.location.href = "index.html"; - } - }); -} - document.addEventListener("DOMContentLoaded", () => { initAuthClose(); const form = document.querySelector("[data-forgot-form]"); diff --git a/frontend/views/olvide-contrasena.html b/frontend/views/olvide-contrasena.html index 0237f55..f9f8a71 100644 --- a/frontend/views/olvide-contrasena.html +++ b/frontend/views/olvide-contrasena.html @@ -109,6 +109,8 @@

    Recuperar contraseña

    + + From 00ed150428ce19187e755fc45a47e2f63df121bb Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 22:56:40 -0600 Subject: [PATCH 131/277] refactor(auth): consume shared auth layer in restablecer-contrasena.js Removed local PASSWORD_RULES, updatePasswordChecklist, and initAuthClose in favor of auth.utils.js. Reset-password call now goes through resetPassword() instead of calling apiPost directly. HTML updated to load auth.api.js and auth.utils.js before the page script. Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- .../assets/js/pages/restablecer-contrasena.js | 33 +------------------ frontend/views/restablecer-contrasena.html | 2 ++ 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/frontend/assets/js/pages/restablecer-contrasena.js b/frontend/assets/js/pages/restablecer-contrasena.js index edb2246..e1aebc3 100644 --- a/frontend/assets/js/pages/restablecer-contrasena.js +++ b/frontend/assets/js/pages/restablecer-contrasena.js @@ -1,18 +1,3 @@ -const PASSWORD_RULES = { - length: (value) => value.length >= 8, - upper: (value) => /[A-Z]/.test(value), - number: (value) => /\d/.test(value), - special: (value) => /[^A-Za-z0-9]/.test(value), -}; - -function updatePasswordChecklist(password) { - Object.entries(PASSWORD_RULES).forEach(([rule, check]) => { - const item = document.querySelector(`[data-rule="${rule}"]`); - if (!item) return; - item.classList.toggle("is-valid", check(password)); - }); -} - function updateResetSubmitState() { const form = document.querySelector("[data-reset-form]"); if (!form) return; @@ -71,10 +56,7 @@ async function handleResetSubmit(event) { submitButton.textContent = "Actualizando..."; try { - const response = await apiPost("/auth/reset-password", { - token: token, - new_password: form.password.value, - }); + const response = await resetPassword(token, form.password.value); // Si todo sale bien solo ocultamos form y mostramos link de login showTerminalState( @@ -115,19 +97,6 @@ function initResetForm() { form.confirmPassword.addEventListener("input", updateResetSubmitState); } -function initAuthClose() { - const closeButton = document.querySelector("[data-auth-close]"); - if (!closeButton) return; - - closeButton.addEventListener("click", () => { - if (window.history.length > 1) { - window.history.back(); - } else { - window.location.href = "index.html"; - } - }); -} - document.addEventListener("DOMContentLoaded", () => { initAuthClose(); initResetForm(); diff --git a/frontend/views/restablecer-contrasena.html b/frontend/views/restablecer-contrasena.html index fe639d8..bed9b07 100644 --- a/frontend/views/restablecer-contrasena.html +++ b/frontend/views/restablecer-contrasena.html @@ -146,6 +146,8 @@

    Restablecer contraseña

    + + From 16793b9565fc68af43e2aa30dd2bfb29da7c3525 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Tue, 7 Jul 2026 22:59:12 -0600 Subject: [PATCH 132/277] refactor(auth): consume shared auth layer in register.js Removed local PASSWORD_RULES, updatePasswordChecklist, and initAuthClose in favor of auth.utils.js. Register and login calls now go through registerUser(), loginUser(), and resendVerificationEmail() instead of calling apiPost directly with hardcoded endpoint strings. HTML updated to load auth.api.js and auth.utils.js before the page script. Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- frontend/assets/js/pages/register.js | 67 ++++++++-------------------- frontend/views/register.html | 2 + 2 files changed, 21 insertions(+), 48 deletions(-) diff --git a/frontend/assets/js/pages/register.js b/frontend/assets/js/pages/register.js index 4a8e04a..c9c3561 100644 --- a/frontend/assets/js/pages/register.js +++ b/frontend/assets/js/pages/register.js @@ -23,36 +23,6 @@ function initAuthToggle() { }); } -const PASSWORD_RULES = { - length: (value) => value.length >= 8, - upper: (value) => /[A-Z]/.test(value), - number: (value) => /\d/.test(value), - special: (value) => /[^A-Za-z0-9]/.test(value), -}; - -function updatePasswordChecklist(password) { - Object.entries(PASSWORD_RULES).forEach(([rule, check]) => { - const item = document.querySelector(`[data-rule="${rule}"]`); - if (!item) return; - item.classList.toggle("is-valid", check(password)); - }); -} - -function splitFullName(fullName) { - const trimmed = fullName.trim(); - const firstSpaceIndex = trimmed.indexOf(" "); - - // Si no hay espacio, usamos el nombre completo como first_name y dejamos last_name vacio en blanco no es opcion porque el backend lo exige; en ese caso repetimos el nombre como apellido temporal. - if (firstSpaceIndex === -1) { - return { firstName: trimmed, lastName: trimmed }; - } - - return { - firstName: trimmed.slice(0, firstSpaceIndex), - lastName: trimmed.slice(firstSpaceIndex + 1), - }; -} - function updateRegisterSubmitState() { const form = document.querySelector("[data-register-form]"); if (!form) return; @@ -72,6 +42,21 @@ function updateRegisterSubmitState() { ); } +function splitFullName(fullName) { + const trimmed = fullName.trim(); + const firstSpaceIndex = trimmed.indexOf(" "); + + // Si no hay espacio, usamos el nombre completo como first_name y dejamos last_name vacio en blanco no es opcion porque el backend lo exige; en ese caso repetimos el nombre como apellido temporal. + if (firstSpaceIndex === -1) { + return { firstName: trimmed, lastName: trimmed }; + } + + return { + firstName: trimmed.slice(0, firstSpaceIndex), + lastName: trimmed.slice(firstSpaceIndex + 1), + }; +} + function showRegisterConfirmation(email) { const registerPanel = document.querySelector('[data-auth-form="register"]'); if (!registerPanel) return; @@ -103,7 +88,7 @@ function showRegisterConfirmation(email) { resendBtn.disabled = true; resendBtn.textContent = "Enviando..."; try { - await apiPost("/auth/resend-verification", { email }); + await resendVerificationEmail(email); resendBtn.textContent = "¡Enviado!"; } catch { resendBtn.textContent = "Error al reenviar"; @@ -139,7 +124,7 @@ function showUnverifiedBanner(form, email) { resendBtn.disabled = true; resendBtn.textContent = "Enviando..."; try { - await apiPost("/auth/resend-verification", { email }); + await resendVerificationEmail(email); resendBtn.textContent = "¡Enviado! Revisa tu bandeja."; } catch { resendBtn.textContent = "Error al reenviar. Intenta de nuevo."; @@ -165,7 +150,7 @@ async function handleRegisterSubmit(event) { const email = form.email.value; try { - await apiPost("/auth/register", { + await registerUser({ first_name: firstName, last_name: lastName, email: email, @@ -211,7 +196,7 @@ async function handleLoginSubmit(event) { submitButton.textContent = "Iniciando sesión..."; try { - await apiPost("/auth/login", { + await loginUser({ email: form.email.value, password: form.password.value, }); @@ -234,20 +219,6 @@ function initLoginForm() { form.addEventListener("submit", handleLoginSubmit); } -function initAuthClose() { - // Esta pantalla no es un overlay real sobre otra pagina, asi que cerrar significa volver al historial si existe, o caer a index.html si el usuario llego aqui directamente (ej. por un enlace compartido). - const closeButton = document.querySelector("[data-auth-close]"); - if (!closeButton) return; - - closeButton.addEventListener("click", () => { - if (window.history.length > 1) { - window.history.back(); - } else { - window.location.href = "index.html"; - } - }); -} - document.addEventListener("DOMContentLoaded", () => { initAuthToggle(); initAuthClose(); diff --git a/frontend/views/register.html b/frontend/views/register.html index d19ec2c..29a40ca 100644 --- a/frontend/views/register.html +++ b/frontend/views/register.html @@ -270,6 +270,8 @@

    Crear cuenta

    + + From 1f537b895fe259086ba11987c8fd3554f7f7dc3e Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Wed, 8 Jul 2026 16:50:08 -0600 Subject: [PATCH 133/277] feat(navbar): resolve pending Reportes link across shared views The Reportes nav item pointed to # with data-pending-page in all six views that share the navbar. Now points to reportes.html in both the top navbar and the mobile nav-drawer. Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- frontend/views/comparar.html | 4 ++-- frontend/views/habilidades.html | 4 ++-- frontend/views/panorama.html | 4 ++-- frontend/views/perfil.html | 4 ++-- frontend/views/regiones.html | 4 ++-- frontend/views/salarios.html | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/views/comparar.html b/frontend/views/comparar.html index 3aadf35..8f75d07 100644 --- a/frontend/views/comparar.html +++ b/frontend/views/comparar.html @@ -74,7 +74,7 @@ Regiones
  • - Reportes + Reportes
  • @@ -176,7 +176,7 @@ Regiones
  • - Reportes + Reportes
  • diff --git a/frontend/views/habilidades.html b/frontend/views/habilidades.html index 26f0d9a..bbf433e 100644 --- a/frontend/views/habilidades.html +++ b/frontend/views/habilidades.html @@ -74,7 +74,7 @@ Regiones
  • - Reportes + Reportes
  • @@ -176,7 +176,7 @@ Regiones
  • - Reportes + Reportes
  • diff --git a/frontend/views/panorama.html b/frontend/views/panorama.html index 434ac89..a9bb50a 100644 --- a/frontend/views/panorama.html +++ b/frontend/views/panorama.html @@ -74,7 +74,7 @@ Regiones
  • - Reportes + Reportes
  • @@ -176,7 +176,7 @@ Regiones
  • - Reportes + Reportes
  • diff --git a/frontend/views/perfil.html b/frontend/views/perfil.html index 831e997..2e8f3f2 100644 --- a/frontend/views/perfil.html +++ b/frontend/views/perfil.html @@ -69,7 +69,7 @@ Regiones
  • - Reportes + Reportes
  • @@ -166,7 +166,7 @@ Regiones
  • - Reportes + Reportes
  • diff --git a/frontend/views/regiones.html b/frontend/views/regiones.html index ce0172f..1e80d41 100644 --- a/frontend/views/regiones.html +++ b/frontend/views/regiones.html @@ -75,7 +75,7 @@ >
  • - Reportes + Reportes
  • @@ -177,7 +177,7 @@ >
  • - Reportes + Reportes
  • diff --git a/frontend/views/salarios.html b/frontend/views/salarios.html index 4b7f20c..3798bc1 100644 --- a/frontend/views/salarios.html +++ b/frontend/views/salarios.html @@ -74,7 +74,7 @@ Regiones
  • - Reportes + Reportes
  • @@ -176,7 +176,7 @@ Regiones
  • - Reportes + Reportes
  • From d5c98f625776a1bd7977c9ba9efc2b2c4e17f016 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Wed, 8 Jul 2026 16:50:46 -0600 Subject: [PATCH 134/277] feat(reportes): add dedicated printable market report view New reportes.html reuses the same endpoints already used by Panorama (getSummary, getTopSkills) with no new backend work. Designed as a document from the start rather than a dashboard replica: a skills table instead of the div-based bar chart, since that chart depends on JS/theme state and isn't suited for print. Includes a print button and an @media print block that hides navigation, forces black-on-white regardless of the user's theme, and avoids breaking metric cards or table rows across page boundaries. Mobile-first from 375px up through desktop, following the same breakpoint pattern already used in _panorama.css. Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- frontend/assets/css/main.css | 1 + frontend/assets/css/pages/_reportes.css | 197 +++++++++++++ frontend/assets/js/pages/reportes.js | 84 ++++++ frontend/views/reportes.html | 354 ++++++++++++++++++++++++ 4 files changed, 636 insertions(+) create mode 100644 frontend/assets/css/pages/_reportes.css create mode 100644 frontend/assets/js/pages/reportes.js create mode 100644 frontend/views/reportes.html diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css index 21fe3d6..8353704 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -34,4 +34,5 @@ @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'); */ 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/js/pages/reportes.js b/frontend/assets/js/pages/reportes.js new file mode 100644 index 0000000..71f250a --- /dev/null +++ b/frontend/assets/js/pages/reportes.js @@ -0,0 +1,84 @@ +/* Reutilizamos getSummary() y getTopSkills() de panorama.api.js. Este script no depende de chart.init.js ni de renderSkillsChart(). */ + +function formatReportDate(date) { + return date.toLocaleDateString("es-MX", { + day: "numeric", + month: "long", + year: "numeric", + }); +} + +function renderSkillsTable(skills) { + const tbody = document.querySelector("[data-skills-tbody]"); + if (!tbody) return; + + const maxDemand = Math.max(...skills.map((s) => s.demand_count)); + + tbody.innerHTML = ""; + + skills.forEach((skill, index) => { + const pct = ((skill.demand_count / maxDemand) * 100).toFixed(1); + const isTop = skill.demand_count === skills[0].demand_count; + + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${index + 1} + ${skill.name} + ${formatNumber(skill.demand_count)} + ${pct}% + `; + tbody.appendChild(tr); + }); +} + +async function initReportePage() { + // Insertamos la fecha de generación + const now = new Date(); + const dateStr = formatReportDate(now); + const dateEl = document.querySelector("[data-report-date]"); + if (dateEl) dateEl.textContent = dateStr; + + // Enlazamos el botón de imprimir + const printBtn = document.querySelector("[data-print-btn]"); + if (printBtn) { + printBtn.addEventListener("click", () => window.print()); + } + + try { + const [summary, skills] = await Promise.all([ + getSummary(), + getTopSkills(15), + ]); + + renderMarketMetrics(summary, { + activeJobs: '[data-metric="reporte-active-jobs"]', + activeJobsDetail: '[data-metric="reporte-active-jobs-detail"]', + activeCompanies: '[data-metric="reporte-active-companies"]', + activeCompaniesDetail: '[data-metric="reporte-active-companies-detail"]', + skillsTracked: '[data-metric="reporte-skills-tracked"]', + skillsTrackedDetail: '[data-metric="reporte-skills-tracked-detail"]', + }); + renderSkillsTable(skills); + } catch (error) { + console.error("No se pudieron cargar los datos del reporte:", error); + + document.querySelectorAll("[data-metric]").forEach((el) => { + if (el.textContent.trim() === "Cargando datos...") { + el.textContent = "No disponible"; + } + }); + + const tbody = document.querySelector("[data-skills-tbody]"); + if (tbody) { + tbody.innerHTML = ` + + + No se pudieron cargar los datos. Intenta recargar la página. + + + `; + } + } +} + +document.addEventListener("DOMContentLoaded", initReportePage); diff --git a/frontend/views/reportes.html b/frontend/views/reportes.html new file mode 100644 index 0000000..5eeaafa --- /dev/null +++ b/frontend/views/reportes.html @@ -0,0 +1,354 @@ + + + + + + + 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... +
    +
    +
    +
    +
    + + + + + + + + + + From ea33c1b689861dbc46d7acce509ba1c443e13621 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Wed, 8 Jul 2026 16:51:22 -0600 Subject: [PATCH 135/277] refactor(metrics): extract shared market metrics rendering panorama.js and reportes.js duplicated the same logic for rendering active jobs, active companies, and tracked skills into their metric cards, differing only in the data-metric selector prefix used by each view. Centralized as renderMarketMetrics(summary, selectors) in formatters.js, which both pages already load. No data-metric attributes were renamed in either HTML; behavior is unchanged, verified manually in both views after the refactor. Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- frontend/assets/js/pages/panorama.js | 25 ++++++++----------------- frontend/assets/js/utils/formatters.js | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/frontend/assets/js/pages/panorama.js b/frontend/assets/js/pages/panorama.js index 11347a9..bf612a5 100644 --- a/frontend/assets/js/pages/panorama.js +++ b/frontend/assets/js/pages/panorama.js @@ -22,23 +22,14 @@ async function initPanoramaPage() { try { const summary = await getSummary(); - document.querySelector('[data-metric="active-jobs"]').textContent = - formatNumber(summary.total_jobs); - document.querySelector('[data-metric="active-jobs-detail"]').textContent = - "Tecnología · México"; - - document.querySelector('[data-metric="active-companies"]').textContent = - formatNumber(summary.total_companies); - document.querySelector( - '[data-metric="active-companies-detail"]', - ).textContent = "Empresas únicas registradas"; - - document.querySelector( - '[data-metric="skills-tracked-panorama"]', - ).textContent = formatNumber(summary.total_skills_tracked); - document.querySelector( - '[data-metric="skills-tracked-panorama-detail"]', - ).textContent = "Catalogadas y actualizadas a diario"; + renderMarketMetrics(summary, { + activeJobs: '[data-metric="active-jobs"]', + activeJobsDetail: '[data-metric="active-jobs-detail"]', + activeCompanies: '[data-metric="active-companies"]', + activeCompaniesDetail: '[data-metric="active-companies-detail"]', + skillsTracked: '[data-metric="skills-tracked-panorama"]', + skillsTrackedDetail: '[data-metric="skills-tracked-panorama-detail"]', + }); await loadSkillsChart(5); bindSkillsFilters(); diff --git a/frontend/assets/js/utils/formatters.js b/frontend/assets/js/utils/formatters.js index b016ef0..422ba01 100644 --- a/frontend/assets/js/utils/formatters.js +++ b/frontend/assets/js/utils/formatters.js @@ -13,3 +13,21 @@ const currencyFormatter = new Intl.NumberFormat("es-MX", { function formatCurrency(value) { return currencyFormatter.format(value); } + +function renderMarketMetrics(summary, selectors) { + document.querySelector(selectors.activeJobs).textContent = formatNumber( + summary.total_jobs, + ); + document.querySelector(selectors.activeJobsDetail).textContent = + "Tecnología · México"; + document.querySelector(selectors.activeCompanies).textContent = formatNumber( + summary.total_companies, + ); + document.querySelector(selectors.activeCompaniesDetail).textContent = + "Empresas únicas registradas"; + document.querySelector(selectors.skillsTracked).textContent = formatNumber( + summary.total_skills_tracked, + ); + document.querySelector(selectors.skillsTrackedDetail).textContent = + "Catalogadas y actualizadas a diario"; +} From 4820cca629e7a7ecba86383fd8c804ebd0cccfa3 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Wed, 8 Jul 2026 21:58:15 -0600 Subject: [PATCH 136/277] fix(alerts): rename user_alerts.threshold to threshold_value Fixes a mismatch across the boundary: the model column was called threshold, but AlertRequestSchema/AlertResponseSchema and alerts_service.py already expected threshold_value. Since user_alerts was empty, the model was renamed instead of touching three files to match the old name. Migration confirmed as an atomic ALTER COLUMN rename, not Alembic's default drop+add, to avoid any data loss risk. Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- backend/app/models/alert.py | 2 +- ...b709e9_rename_user_alerts_threshold_to_.py | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/versions/146283b709e9_rename_user_alerts_threshold_to_.py diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py index c415b43..6ff33c2 100644 --- a/backend/app/models/alert.py +++ b/backend/app/models/alert.py @@ -8,6 +8,6 @@ class Alert(db.Model): 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) - threshold = db.Column(db.Integer, nullable=False) + threshold_value = db.Column(db.Integer, nullable=False) active = db.Column(db.Boolean, default=True, nullable=False) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) 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') From f7f25d29e03b4bcad53b324836a2081f63f4828c Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Wed, 8 Jul 2026 21:58:53 -0600 Subject: [PATCH 137/277] fix(alerts): bind AlertRepository.model as a class attribute AlertRepository linked its model via an __init__ method that was never invoked, since BaseRepository only exposes classmethods called on the class itself. cls.model stayed None, causing AttributeError once a real alert existed. Fixed to follow the same pattern already used by TrendSnapshotRepository. Same broken pattern found in backup_repository.py and category_repository.py, logged as DT-13, left untouched here as both are still empty stubs. Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- backend/app/repositories/alert_repository.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/app/repositories/alert_repository.py b/backend/app/repositories/alert_repository.py index 34a604b..194e4b4 100644 --- a/backend/app/repositories/alert_repository.py +++ b/backend/app/repositories/alert_repository.py @@ -3,8 +3,7 @@ class AlertRepository(BaseRepository): - def __init__(self): - super().__init__(Alert) + model = Alert @classmethod def get_by_user_id(cls, user_id: int) -> list: From d5d7731f3cb2a0b6968e9f42b1052b93d1f4a5b7 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Wed, 8 Jul 2026 21:59:43 -0600 Subject: [PATCH 138/277] refactor(alerts): migrate alert emails from SendGrid to Resend alerts_service.py used SendGrid via sendgrid_client.py while the rest of the system already moved to Resend for email. Added send_alert_email to email_service.py following the same pattern as send_verification_email. sendgrid_client.py had no other consumers and was removed. Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- backend/app/clients/sendgrid_client.py | 31 -------------------------- backend/app/services/alerts_service.py | 9 ++++---- backend/app/services/email_service.py | 19 ++++++++++++++++ 3 files changed, 23 insertions(+), 36 deletions(-) delete mode 100644 backend/app/clients/sendgrid_client.py diff --git a/backend/app/clients/sendgrid_client.py b/backend/app/clients/sendgrid_client.py deleted file mode 100644 index 23b74e5..0000000 --- a/backend/app/clients/sendgrid_client.py +++ /dev/null @@ -1,31 +0,0 @@ -from sendgrid import SendGridAPIClient -from sendgrid.helpers.mail import Mail -from flask import current_app -from app.utils.errors import AppError - -class EmailClient: - # Envolvemos el SDK de SendGrid. Si en el futuro cambiamos a AWS SES o Mailgun, - # los servicios del sistema no tendrán que ser modificados, limitando el impacto a esta clase. - - @classmethod - def send_alert_email(cls, to_email: str, subject: str, html_content: str) -> bool: - api_key = current_app.config.get("SENDGRID_API_KEY") - from_email = current_app.config.get("MAIL_DEFAULT_SENDER", "noreply@skillstat.com") - - if not api_key: - raise AppError("Clave de API de SendGrid no configurada en el entorno.", status_code=500) - - message = Mail( - from_email=from_email, - to_emails=to_email, - subject=subject, - html_content=html_content - ) - - try: - sg = SendGridAPIClient(api_key) - response = sg.send(message) - # SendGrid devuelve 202 (Accepted) cuando encola el correo correctamente para su envío - return str(response.status_code).startswith("20") - except Exception as e: - raise AppError(f"Fallo de comunicación con el proveedor de correo: {str(e)}", code="EXTERNAL_API_ERROR") diff --git a/backend/app/services/alerts_service.py b/backend/app/services/alerts_service.py index 835de77..603c6b3 100644 --- a/backend/app/services/alerts_service.py +++ b/backend/app/services/alerts_service.py @@ -2,7 +2,7 @@ from app.repositories.trend_snapshot_repository import TrendSnapshotRepository from app.repositories.user_repository import UserRepository from app.repositories.skill_repository import SkillRepository -from app.clients.sendgrid_client import EmailClient +from app.services.email_service import send_alert_email from app.utils.errors import AppError class AlertsService: @@ -38,11 +38,10 @@ def evaluate_and_notify(cls) -> int: """ try: - # Delegamos el envío al cliente Wrapper. + # Delegamos el envío al servicio de correo unificado. # Atrapamos errores para evitar que el fallo de un email cancele el ciclo entero. - success = EmailClient.send_alert_email(user.email, subject, html_content) - if success: - notifications_sent += 1 + send_alert_email(user.email, subject, html_content) + notifications_sent += 1 except AppError: continue diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 45e01f5..f14714f 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -39,3 +39,22 @@ def send_verification_email(to_email: str, token: str) -> None: 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 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)}") From 6303143cc3f08e78566843ad7e051f23af94f56e Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Wed, 8 Jul 2026 22:01:59 -0600 Subject: [PATCH 139/277] feat(alerts): add evaluate-alerts CLI command Separate from generate-snapshots by design, following the same CLI-first pattern already used for ingest-jobs, rather than coupling alert evaluation implicitly into the snapshot pipeline. Also adds .flaskenv with FLASK_APP=run, since new CLI commands weren't discoverable without it set manually per terminal session. Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- backend/.flaskenv | 1 + backend/run.py | 129 +++++++++++++++++++++++++--------------------- 2 files changed, 72 insertions(+), 58 deletions(-) create mode 100644 backend/.flaskenv 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/run.py b/backend/run.py index 0ac79e9..2a8b5b3 100644 --- a/backend/run.py +++ b/backend/run.py @@ -1,58 +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}") \ No newline at end of file +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 From 3d27ec63f7081bd1480097b47fc493219bcb4aab Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 9 Jul 2026 17:26:35 -0600 Subject: [PATCH 140/277] feat(alerts): add frontend logic for creating, listing, and deleting alerts alertas.api.js wraps the three alert endpoints as named functions, following the same pattern as panorama.api.js. alertas.js follows the same route-protection pattern already used in perfil.js: the initial protected call (listAlerts) doubles as the auth guard, redirecting to register.html on 401 instead of a separate check. Skill names are resolved client-side by cross-referencing skill_id against the catalog already exposed by getCatalogs(), avoiding a new backend endpoint. Co-authored-by: Alejandro Balderrama --- frontend/assets/js/api/alertas.api.js | 15 ++- frontend/assets/js/pages/alertas.js | 176 +++++++++++++++++++++++++- 2 files changed, 189 insertions(+), 2 deletions(-) diff --git a/frontend/assets/js/api/alertas.api.js b/frontend/assets/js/api/alertas.api.js index d51602e..ea23aa5 100644 --- a/frontend/assets/js/api/alertas.api.js +++ b/frontend/assets/js/api/alertas.api.js @@ -1 +1,14 @@ -/* alertas.api.js — SkillStat */ \ No newline at end of file +async function createAlert(skillId, thresholdValue) { + return apiPost("/alerts/", { + skill_id: skillId, + threshold_value: thresholdValue, + }); +} + +async function listAlerts() { + return apiGet("/alerts/"); +} + +async function deleteAlert(alertId) { + return apiDelete(`/alerts/${alertId}`); +} diff --git a/frontend/assets/js/pages/alertas.js b/frontend/assets/js/pages/alertas.js index 9e0f289..21d9726 100644 --- a/frontend/assets/js/pages/alertas.js +++ b/frontend/assets/js/pages/alertas.js @@ -1 +1,175 @@ -/* alertas.js — SkillStat */ \ No newline at end of file +// Catálogo cargado una sola vez por sesión de página, reutilizado para resolver skill_id => nombre en la tabla +let skillCatalog = []; + +function showInlineMessage(msgElement, text, isSuccess) { + msgElement.textContent = text; + msgElement.hidden = false; + if (isSuccess) { + msgElement.style.color = "var(--color-accent-green)"; + msgElement.style.borderColor = "var(--color-accent-green)"; + msgElement.style.backgroundColor = "rgba(16, 185, 129, 0.1)"; + } else { + msgElement.style.color = ""; + msgElement.style.borderColor = ""; + msgElement.style.backgroundColor = ""; + } +} + +function getSkillName(skillId) { + const skill = skillCatalog.find((s) => s.id === skillId); + return skill ? skill.name : `Skill #${skillId}`; +} + +function renderAlerts(alerts) { + const tbody = document.getElementById("alertas-tbody"); + const empty = document.getElementById("alertas-empty"); + const wrapper = document.getElementById("alertas-table-wrapper"); + + if (!alerts || alerts.length === 0) { + wrapper.hidden = true; + empty.hidden = false; + return; + } + + empty.hidden = true; + wrapper.hidden = false; + tbody.innerHTML = ""; + + alerts.forEach((alert) => { + const skillName = getSkillName(alert.skill_id); + const createdAt = alert.created_at + ? new Date(alert.created_at).toLocaleDateString("es-ES", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }) + : "—"; + + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${skillName} + ${alert.threshold_value} vacantes + ${createdAt} + + + + `; + tbody.appendChild(tr); + }); + + if (window.lucide && window.lucide.createIcons) { + window.lucide.createIcons(); + } + + tbody.querySelectorAll('[data-action="delete-alert"]').forEach((btn) => { + btn.addEventListener("click", () => handleDeleteAlert(btn.dataset.id, btn)); + }); +} + +async function handleDeleteAlert(alertId, btnElement) { + btnElement.disabled = true; + try { + await deleteAlert(alertId); + await loadAlerts(); + } catch (error) { + console.error("Error eliminando alerta:", error); + btnElement.disabled = false; + } +} + +async function loadAlerts() { + const alerts = await listAlerts(); + renderAlerts(alerts); +} + +function validateCreateForm(form) { + const skillId = parseInt(form.skill_id.value); + const thresholdValue = parseInt(form.threshold_value.value); + return skillId > 0 && Number.isInteger(thresholdValue) && thresholdValue > 0; +} + +function bindCreateForm() { + const form = document.getElementById("crear-alerta-form"); + const btn = document.getElementById("btn-crear-alerta"); + const msg = document.getElementById("crear-alerta-msg"); + + // Deshabilita el botón en tiempo real para que el usuario tenga feedback inmediato de validez del formulario antes de intentar enviarlo + function updateSubmitState() { + btn.disabled = !validateCreateForm(form); + } + + form.skill_id.addEventListener("change", updateSubmitState); + form.threshold_value.addEventListener("input", updateSubmitState); + updateSubmitState(); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const skillId = parseInt(form.skill_id.value); + const thresholdValue = parseInt(form.threshold_value.value); + + const originalText = btn.textContent; + btn.textContent = "Creando..."; + btn.disabled = true; + msg.hidden = true; + + try { + await createAlert(skillId, thresholdValue); + showInlineMessage(msg, "Alerta creada correctamente.", true); + form.reset(); + updateSubmitState(); + await loadAlerts(); + } catch (error) { + showInlineMessage(msg, error.message, false); + btn.disabled = false; + } finally { + btn.textContent = originalText; + } + }); +} + +function populateSkillSelect(skills) { + const select = document.getElementById("alert-skill-select"); + select.innerHTML = ''; + skills.forEach((skill) => { + const option = document.createElement("option"); + option.value = skill.id; + option.textContent = skill.name; + select.appendChild(option); + }); +} + +async function initAlertasPage() { + try { + const [catalogData, alerts] = await Promise.all([ + getCatalogs(), + listAlerts(), + ]); + + skillCatalog = catalogData.skills || []; + populateSkillSelect(skillCatalog); + renderAlerts(alerts); + + document.getElementById("alertas-loading").style.display = "none"; + document.getElementById("alertas-main").hidden = false; + + bindCreateForm(); + } catch (error) { + if (error.status === 401) { + window.location.href = "register.html"; + } else { + console.error("Error cargando alertas:", error); + document.getElementById("alertas-loading").innerHTML = + `

    No se pudieron cargar las alertas.

    `; + } + } +} + +document.addEventListener("DOMContentLoaded", initAlertasPage); From 73c948bb7ff15124b47d2d7b7b89e11b268504ac Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 9 Jul 2026 17:27:17 -0600 Subject: [PATCH 141/277] feat(alerts): add mobile-first styles with desktop two-column layout Table collapses to labeled cards below 768px, matching the pattern already used elsewhere in the project, rather than the max-width:600px breakpoint it shipped with initially. Desktop layout uses the existing grid-2-col utility (3fr/2fr) instead of leaving half the viewport empty. Uses only existing design tokens; two variable name mismatches (--text-sm, --color-text-muted vs --font-size-sm, --color-text-secondary) were caught and corrected against _variables.css before merge. Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira --- frontend/assets/css/main.css | 2 +- frontend/assets/css/pages/_alertas.css | 133 ++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css index 8353704..382aadc 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -28,7 +28,7 @@ @import url("pages/_index.css"); @import url("pages/_panorama.css"); @import url("pages/_auth.css"); -/* @import url('pages/_alertas.css'); */ +@import url("pages/_alertas.css"); @import url("pages/_comparar.css"); @import url("pages/_perfil.css"); @import url("pages/_habilidades.css"); diff --git a/frontend/assets/css/pages/_alertas.css b/frontend/assets/css/pages/_alertas.css index c366731..68d0753 100644 --- a/frontend/assets/css/pages/_alertas.css +++ b/frontend/assets/css/pages/_alertas.css @@ -1 +1,132 @@ -/* _alertas.css — SkillStat */ \ No newline at end of file +.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; +} From 9dedacf6b7797728c5d5a273baa7b59c3cc97f55 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 9 Jul 2026 17:27:42 -0600 Subject: [PATCH 142/277] feat(alerts): add Alertas view New view for creating, listing, and deleting job market alerts. Loading state gated behind the auth check in alertas.js, same pattern as perfil.html. Table wrapper uses its own alertas-table-wrapper class instead of reusing reporte-table-wrapper from Reportes, avoiding accidental coupling between unrelated pages. --- frontend/views/alertas.html | 291 +++++++++++++++++++++++++++++++++++- 1 file changed, 290 insertions(+), 1 deletion(-) diff --git a/frontend/views/alertas.html b/frontend/views/alertas.html index 14a9c64..ea254f8 100644 --- a/frontend/views/alertas.html +++ b/frontend/views/alertas.html @@ -1 +1,290 @@ - \ No newline at end of file + + + + + + + SkillStat - Mis alertas + + + + + + + + + + + + +
    +

    Cargando alertas...

    +
    + +
    +
    +
    +

    Mis alertas

    +
    +
    + +
    +
    +

    Nueva alerta

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

    Alertas activas

    + + +
    +
    +
    + + + + + + + + + + + \ No newline at end of file From 70ec48a80720117724bb76f66d4db3cfdce515e1 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Thu, 9 Jul 2026 17:28:58 -0600 Subject: [PATCH 143/277] feat(navbar): add Alertas link across shared views Alertas never had a placeholder link (unlike Reportes' previous data-pending-page), so this adds a new nav item rather than resolving an existing one, in both the top navbar and the mobile nav-drawer across all seven views that share the navbar. Co-authored-by: Aylin Chavira --- frontend/views/comparar.html | 6 ++++++ frontend/views/habilidades.html | 6 ++++++ frontend/views/panorama.html | 6 ++++++ frontend/views/perfil.html | 6 ++++++ frontend/views/regiones.html | 6 ++++++ frontend/views/reportes.html | 6 ++++++ frontend/views/salarios.html | 6 ++++++ 7 files changed, 42 insertions(+) diff --git a/frontend/views/comparar.html b/frontend/views/comparar.html index 8f75d07..d4585e8 100644 --- a/frontend/views/comparar.html +++ b/frontend/views/comparar.html @@ -76,6 +76,9 @@
  • Reportes
  • +
  • + Alertas +
  • + + + +
    +

    Cargando respaldos...

    +
    + +
    +
    +
    +

    Respaldos del sistema

    +
    +
    + +
    +
    + + +
    + +
    + + + +
    + + + Página 1 de 1 + + +
    +
    +
    +
    + + + + + + + + + + + diff --git a/frontend/views/index.html b/frontend/views/index.html index 27ece2c..ecb2e4e 100644 --- a/frontend/views/index.html +++ b/frontend/views/index.html @@ -305,14 +305,27 @@

    -
    +
    -

    Skills más demandados ahora

    +

    + Skills más demandados ahora +

    Lo que el mercado pide hoy

    -
    From 891e64cf1f477a122ce02a145c04cae3de5e9986 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 11 Jul 2026 16:36:31 -0600 Subject: [PATCH 151/277] fix(layout): reserve scrollbar space globally to prevent layout shift Switching between pages with different row counts (e.g. Backups pagination) caused the entire page -- header included, not just the table -- to shift horizontally as the vertical scrollbar appeared or disappeared. Applied scrollbar-gutter: stable to html in the base reset, so the fix protects every current and future view with variable-height content, not just Admin. --- frontend/assets/css/base/_reset.css | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/assets/css/base/_reset.css b/frontend/assets/css/base/_reset.css index 22cc779..03be8e4 100644 --- a/frontend/assets/css/base/_reset.css +++ b/frontend/assets/css/base/_reset.css @@ -10,6 +10,7 @@ html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; + scrollbar-gutter: stable; } body { From 28283bbe64546aa0ee677ef92a7abfe1b27c6194 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 11 Jul 2026 17:50:31 -0600 Subject: [PATCH 152/277] feat(admin): add Users view (read-only) and admin sub-navigation admin/usuarios.html mirrors admin-respaldos.js's guard pattern exactly (401 -> register.html, 403 -> silent redirect to panorama.html), but has no mutating action yet -- role changes and account deactivation are explicitly out of scope for now. Table shows id, full name, email, role (color-coded, orange for ADMIN reusing the same accent already used to highlight top skills in Reportes), and registration date, paginated through the new GET /api/admin/users. Added a pill-based sub-navigation (Respaldos | Usuarios) to both admin views instead of adding more items to the main navbar as the panel grows, reusing the existing .pill/.pill--active component already used for Panorama's Top 5/10/15 filters, applied here to real navigation instead of an in-page toggle. Verified with a real ADMIN session: sub-nav switches correctly between both views with the right pill active, users table loads real data with working pagination. REGISTERED and no-session guards behave identically to Backups, already verified in Slice 3. Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- frontend/assets/css/pages/_admin.css | 12 + frontend/assets/js/api/admin.api.js | 4 + frontend/assets/js/pages/admin-usuarios.js | 99 +++++++ frontend/views/admin/respaldos.html | 12 + frontend/views/admin/usuarios.html | 295 ++++++++++++++++++++- 5 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 frontend/assets/js/pages/admin-usuarios.js diff --git a/frontend/assets/css/pages/_admin.css b/frontend/assets/css/pages/_admin.css index 0b7607a..b94dcd1 100644 --- a/frontend/assets/css/pages/_admin.css +++ b/frontend/assets/css/pages/_admin.css @@ -87,6 +87,18 @@ 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) { diff --git a/frontend/assets/js/api/admin.api.js b/frontend/assets/js/api/admin.api.js index a46ddf1..3887a3c 100644 --- a/frontend/assets/js/api/admin.api.js +++ b/frontend/assets/js/api/admin.api.js @@ -5,3 +5,7 @@ async function listBackups(page = 1, perPage = 10) { async function createBackup() { return apiPost("/admin/backup", {}); } + +async function listUsers(page = 1, perPage = 10) { + return apiGet(`/admin/users?page=${page}&per_page=${perPage}`); +} diff --git a/frontend/assets/js/pages/admin-usuarios.js b/frontend/assets/js/pages/admin-usuarios.js new file mode 100644 index 0000000..a567bc3 --- /dev/null +++ b/frontend/assets/js/pages/admin-usuarios.js @@ -0,0 +1,99 @@ +let currentPage = 1; +const perPage = 10; + +function formatRole(role) { + if (role === "ADMIN") { + return `Admin`; + } + // REGISTERED u otro valor + return `${role}`; +} + +function renderUsers(data) { + const tbody = document.getElementById("admin-tbody"); + const empty = document.getElementById("admin-empty"); + const wrapper = document.getElementById("admin-table-wrapper"); + const pageInfo = document.getElementById("admin-page-info"); + const btnPrev = document.getElementById("admin-btn-prev"); + const btnNext = document.getElementById("admin-btn-next"); + + const { items, total_pages } = data; + + // Actualizar controles de paginación + pageInfo.textContent = `Página ${data.page} de ${total_pages || 0}`; + btnPrev.disabled = data.page <= 1; + btnNext.disabled = data.page >= total_pages || total_pages === 0; + + if (!items || items.length === 0) { + wrapper.hidden = true; + empty.hidden = false; + return; + } + + empty.hidden = true; + wrapper.hidden = false; + tbody.innerHTML = ""; + + items.forEach((user) => { + const nombre = + [user.first_name, user.last_name].filter(Boolean).join(" ") || "—"; + const fecha = user.created_at + ? new Date(user.created_at).toLocaleDateString("es-ES", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }) + : "—"; + + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${user.id} + ${nombre} + ${user.email || "—"} + ${formatRole(user.role)} + ${fecha} + `; + tbody.appendChild(tr); + }); +} + +async function loadPage(page) { + currentPage = page; + const data = await listUsers(currentPage, perPage); + renderUsers(data); +} + +function bindControls() { + document.getElementById("admin-btn-prev").addEventListener("click", () => { + if (currentPage > 1) loadPage(currentPage - 1); + }); + + document.getElementById("admin-btn-next").addEventListener("click", () => { + loadPage(currentPage + 1); + }); +} + +async function initAdminUsuariosPage() { + try { + const data = await listUsers(1, perPage); + + document.getElementById("admin-loading").style.display = "none"; + document.getElementById("admin-main").hidden = false; + + renderUsers(data); + bindControls(); + } catch (error) { + if (error.status === 401) { + window.location.href = "../register.html"; + } else if (error.status === 403) { + // Sesión válida pero sin rol ADMIN: redirige silenciosamente sin mensaje + window.location.href = "../panorama.html"; + } else { + console.error("Error cargando usuarios:", error); + document.getElementById("admin-loading").innerHTML = + `

    No se pudieron cargar los usuarios.

    `; + } + } +} + +document.addEventListener("DOMContentLoaded", initAdminUsuariosPage); diff --git a/frontend/views/admin/respaldos.html b/frontend/views/admin/respaldos.html index 329b170..857511f 100644 --- a/frontend/views/admin/respaldos.html +++ b/frontend/views/admin/respaldos.html @@ -225,6 +225,18 @@

    Respaldos del sistema

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

    Cargando usuarios...

    +
    + +
    +
    +
    +

    Usuarios registrados

    +
    +
    + +
    +
    + +
    +
    + +
    +
    + + + +
    + + + Página 1 de 1 + + +
    +
    +
    +
    + + + + + + + + + + + From 20c7829315ceb8c7262ec8caaff6c556a4e3554a Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sat, 11 Jul 2026 17:57:47 -0600 Subject: [PATCH 153/277] feat(admin): show real user email instead of Usuario #N in backups table BackupRepository.get_paginated() now outerjoins against User to include the email of whoever triggered each backup. outerjoin instead of an inner join because Backup.user_id is nullable (automated backups from a future scheduler wouldn't have one), so the frontend falls back to the old Usuario #N format only when user_email comes back null. --- backend/app/controllers/admin_bp.py | 3 ++- backend/app/repositories/backup_repository.py | 15 +++++++++++---- frontend/assets/js/pages/admin-respaldos.js | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/backend/app/controllers/admin_bp.py b/backend/app/controllers/admin_bp.py index 8a5d097..05ad7f1 100644 --- a/backend/app/controllers/admin_bp.py +++ b/backend/app/controllers/admin_bp.py @@ -61,8 +61,9 @@ def list_backups(): "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 in items + for b, email in items ], "total": total, "page": page, diff --git a/backend/app/repositories/backup_repository.py b/backend/app/repositories/backup_repository.py index a9b3ba4..7e51bac 100644 --- a/backend/app/repositories/backup_repository.py +++ b/backend/app/repositories/backup_repository.py @@ -3,17 +3,24 @@ 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).order_by(cls.model.created_at.desc()) + 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() - items = db.session.execute( + rows = db.session.execute( query.limit(per_page).offset((page - 1) * per_page) - ).scalars().all() - return items, total + ).all() + return rows, total diff --git a/frontend/assets/js/pages/admin-respaldos.js b/frontend/assets/js/pages/admin-respaldos.js index 8536543..e807728 100644 --- a/frontend/assets/js/pages/admin-respaldos.js +++ b/frontend/assets/js/pages/admin-respaldos.js @@ -65,7 +65,7 @@ function renderBackups(data) { ${backup.id} ${backup.filename || "—"} ${formatStatus(backup.status)} - Usuario #${backup.user_id} + ${backup.user_email || `Usuario #${backup.user_id}`} ${fecha} `; tbody.appendChild(tr); From 00701fee10d431e833974e193a38d4c3b014e36d Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 12 Jul 2026 19:06:13 -0600 Subject: [PATCH 154/277] feat(users): add is_active column for soft-delete --- backend/app/models/user.py | 4 +- .../4c1e2a0ac992_add_is_active_to_users.py | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/versions/4c1e2a0ac992_add_is_active_to_users.py diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f73d6be..0d07a97 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -12,13 +12,15 @@ class User(db.Model): # 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, default=lambda: datetime.now(timezone.utc)) + 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) 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 ### From 48a03e0e4cf76055f86e75fabfbe7e9b17b6176f Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 12 Jul 2026 19:06:30 -0600 Subject: [PATCH 155/277] fix(users): restore created_at not null after unrelated autogenerate drift --- ...b6_restore_created_at_not_null_on_users.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 backend/migrations/versions/03ff0fc315b6_restore_created_at_not_null_on_users.py 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 ### From bc412f4b62128378a80157bf2b131cfae9f366cd Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 12 Jul 2026 19:06:51 -0600 Subject: [PATCH 156/277] feat(auth): revoke sessions for deactivated users --- backend/app/__init__.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 6954415..67d013f 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -56,6 +56,14 @@ def handle_expired_token(jwt_header, jwt_payload): 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. @@ -67,8 +75,16 @@ def check_if_token_revoked(jwt_header, jwt_payload): return False user = UserRepository.get_by_id(int(user_id)) - if not user or user.password_changed_at is None: - # Usuario no encontrado o exclusivamente OAuth (sin contraseña), no aplicamos invalidación por cambio de contraseña. + 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. From 36b969a9f54faa5383673d2979a3d6862fd9eb83 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 12 Jul 2026 19:08:36 -0600 Subject: [PATCH 157/277] feat(admin): add role and status mutation endpoints with last-admin protection Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- backend/app/controllers/admin_bp.py | 109 ++++++++++++++++++++ backend/app/repositories/user_repository.py | 9 ++ backend/app/schemas/admin_schema.py | 19 ++++ 3 files changed, 137 insertions(+) create mode 100644 backend/app/schemas/admin_schema.py diff --git a/backend/app/controllers/admin_bp.py b/backend/app/controllers/admin_bp.py index 05ad7f1..1888a2c 100644 --- a/backend/app/controllers/admin_bp.py +++ b/backend/app/controllers/admin_bp.py @@ -1,13 +1,18 @@ +import logging from flask import Blueprint, request 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 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__) @@ -79,6 +84,7 @@ def list_backups(): @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) @@ -99,6 +105,7 @@ def list_users(): "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 @@ -110,3 +117,105 @@ def list_users(): } 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.count_active_admins() <= 1: + 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 + saved = UserRepository.save(target) + if not saved: + return error_response( + code="INTERNAL_ERROR", + message="No se pudo actualizar el rol.", + status_code=500, + ) + + 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.count_active_admins() <= 1: + 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 + saved = UserRepository.save(target) + if not saved: + return error_response( + code="INTERNAL_ERROR", + message="No se pudo actualizar el estado.", + status_code=500, + ) + + 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 + ) diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index 7dcf462..ed95c1a 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -50,3 +50,12 @@ def save(cls, user: User) -> User: except Exception: db.session.rollback() return None + + @classmethod + def count_active_admins(cls) -> int: + # Cuenta administradores activos para proteger contra que una operación deje al sistema sin ningún ADMIN capaz de operar el panel. + return db.session.execute( + db.select(db.func.count()).select_from(User).filter_by( + role="ADMIN", is_active=True + ) + ).scalar_one() diff --git a/backend/app/schemas/admin_schema.py b/backend/app/schemas/admin_schema.py new file mode 100644 index 0000000..191b131 --- /dev/null +++ b/backend/app/schemas/admin_schema.py @@ -0,0 +1,19 @@ +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."}, + ) From f53eee4873d370613d449cc1812f5a2410ec065b Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 12 Jul 2026 19:09:02 -0600 Subject: [PATCH 158/277] feat(admin): add role/status mutation controls to users panel Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- frontend/assets/css/pages/_admin.css | 39 ++++++++ frontend/assets/js/api/admin.api.js | 8 ++ frontend/assets/js/pages/admin-usuarios.js | 111 +++++++++++++++++++-- frontend/views/admin/usuarios.html | 2 + 4 files changed, 151 insertions(+), 9 deletions(-) diff --git a/frontend/assets/css/pages/_admin.css b/frontend/assets/css/pages/_admin.css index b94dcd1..9353527 100644 --- a/frontend/assets/css/pages/_admin.css +++ b/frontend/assets/css/pages/_admin.css @@ -142,3 +142,42 @@ 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; +} diff --git a/frontend/assets/js/api/admin.api.js b/frontend/assets/js/api/admin.api.js index 3887a3c..44acc81 100644 --- a/frontend/assets/js/api/admin.api.js +++ b/frontend/assets/js/api/admin.api.js @@ -9,3 +9,11 @@ async function createBackup() { 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/pages/admin-usuarios.js b/frontend/assets/js/pages/admin-usuarios.js index a567bc3..9789229 100644 --- a/frontend/assets/js/pages/admin-usuarios.js +++ b/frontend/assets/js/pages/admin-usuarios.js @@ -1,12 +1,15 @@ let currentPage = 1; const perPage = 10; +// Id del admin autenticado; se carga en initAdminUsuariosPage y se usa en renderUsers para deshabilitar los controles de la propia fila del actor. +let currentAdminId = null; -function formatRole(role) { - if (role === "ADMIN") { - return `Admin`; - } - // REGISTERED u otro valor - return `${role}`; +function showRowError(spanEl, message) { + spanEl.textContent = message; + spanEl.hidden = false; + setTimeout(() => { + spanEl.hidden = true; + spanEl.textContent = ""; + }, 4000); } function renderUsers(data) { @@ -45,15 +48,100 @@ function renderUsers(data) { }) : "—"; + const isSelf = user.id === currentAdminId; + const selfTitle = isSelf + ? ' title="No puedes modificar tu propia cuenta"' + : ""; + const disabledAttr = isSelf ? " disabled" : ""; + const tr = document.createElement("tr"); tr.innerHTML = ` ${user.id} ${nombre} ${user.email || "—"} - ${formatRole(user.role)} + + + + + + ${fecha} + + + `; tbody.appendChild(tr); + + // Listeners de mutación, aplica solo para filas que no son el propio admin + if (!isSelf) { + const roleSelect = tr.querySelector(".admin-role-select"); + const statusCheckbox = tr.querySelector(".admin-status-checkbox"); + const errorSpan = tr.querySelector(".admin-row-error"); + + roleSelect.addEventListener("change", async () => { + const newRole = roleSelect.value; + const previousRole = newRole === "ADMIN" ? "REGISTERED" : "ADMIN"; + const confirmed = window.confirm( + `¿Cambiar el rol de ${nombre} a ${newRole === "ADMIN" ? "Admin" : "Registered"}?`, + ); + if (!confirmed) { + roleSelect.value = previousRole; + return; + } + roleSelect.disabled = true; + try { + await updateUserRole(user.id, newRole); + } catch (err) { + roleSelect.value = previousRole; + showRowError(errorSpan, err.message); + } finally { + roleSelect.disabled = false; + } + }); + + statusCheckbox.addEventListener("change", async () => { + const newStatus = statusCheckbox.checked; + const statusLabel = tr.querySelector(".admin-status-text"); + const confirmed = window.confirm( + `¿${newStatus ? "Activar" : "Desactivar"} la cuenta de ${nombre}?`, + ); + if (!confirmed) { + statusCheckbox.checked = !newStatus; + return; + } + statusCheckbox.disabled = true; + try { + await updateUserStatus(user.id, newStatus); + statusLabel.textContent = newStatus ? "Activo" : "Inactivo"; + } catch (err) { + statusCheckbox.checked = !newStatus; + statusLabel.textContent = !newStatus ? "Activo" : "Inactivo"; + showRowError(errorSpan, err.message); + } finally { + statusCheckbox.disabled = false; + } + }); + } }); } @@ -75,7 +163,12 @@ function bindControls() { async function initAdminUsuariosPage() { try { - const data = await listUsers(1, perPage); + // Obtenemos el usuario actual con el mismo patrón que navbar-role.js para saber el id del admin logueado y deshabilitar su propia fila. + const [me, data] = await Promise.all([ + apiGet("/auth/me"), + listUsers(1, perPage), + ]); + currentAdminId = me.id; document.getElementById("admin-loading").style.display = "none"; document.getElementById("admin-main").hidden = false; @@ -86,7 +179,7 @@ async function initAdminUsuariosPage() { if (error.status === 401) { window.location.href = "../register.html"; } else if (error.status === 403) { - // Sesión válida pero sin rol ADMIN: redirige silenciosamente sin mensaje + // Sesión válida pero sin rol ADMIN que redirige silenciosamente sin mensaje window.location.href = "../panorama.html"; } else { console.error("Error cargando usuarios:", error); diff --git a/frontend/views/admin/usuarios.html b/frontend/views/admin/usuarios.html index db8f360..c8712e8 100644 --- a/frontend/views/admin/usuarios.html +++ b/frontend/views/admin/usuarios.html @@ -250,7 +250,9 @@

    Usuarios registrados

    Nombre completo Correo Rol + Activo Registrado el + Acciones From a5399ae39a0b6dcdc329e60c050678ccfdf70524 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 12 Jul 2026 23:16:43 -0600 Subject: [PATCH 159/277] feat(jobs): harden created_at not null Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- backend/app/models/job.py | 2 +- ...b2d2464_harden_jobs_created_at_not_null.py | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/versions/ee1b7b2d2464_harden_jobs_created_at_not_null.py diff --git a/backend/app/models/job.py b/backend/app/models/job.py index 2de1be5..69ed9af 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -19,7 +19,7 @@ class Job(db.Model): description_hash = db.Column(db.String(64), nullable=False, unique=True) processed = db.Column(db.Boolean, default=False, nullable=False) - created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + 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), 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 ### From d10381103f0b1aadc37d7fa19c02a057584dc7c2 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 12 Jul 2026 23:17:04 -0600 Subject: [PATCH 160/277] feat(trends): calculate growth_rate using 30-day activity window Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- .../app/repositories/job_skill_repository.py | 13 ++++++++++ .../repositories/trend_snapshot_repository.py | 7 ++++++ backend/app/services/market_trends_service.py | 25 +++++++++++++++---- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/backend/app/repositories/job_skill_repository.py b/backend/app/repositories/job_skill_repository.py index 096c31d..4155738 100644 --- a/backend/app/repositories/job_skill_repository.py +++ b/backend/app/repositories/job_skill_repository.py @@ -1,5 +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/trend_snapshot_repository.py b/backend/app/repositories/trend_snapshot_repository.py index 545afbd..da65d42 100644 --- a/backend/app/repositories/trend_snapshot_repository.py +++ b/backend/app/repositories/trend_snapshot_repository.py @@ -16,6 +16,13 @@ def get_latest_by_skill(cls, skill_id: int): .limit(1) ).scalar_one_or_none() + @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 diff --git a/backend/app/services/market_trends_service.py b/backend/app/services/market_trends_service.py index 9b3a744..56b0a7c 100644 --- a/backend/app/services/market_trends_service.py +++ b/backend/app/services/market_trends_service.py @@ -1,5 +1,5 @@ import pandas as pd -from datetime import datetime, timezone +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 @@ -9,7 +9,7 @@ class MarketTrendsService: @classmethod def generate_snapshots(cls) -> int: - raw_data = JobSkillRepository.get_all() + raw_data = JobSkillRepository.get_active(days=30) if not raw_data: return 0 @@ -45,12 +45,27 @@ def generate_snapshots(cls) -> int: 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": int(row["skill_id"]), - "city_id": int(row["city_id"]), + "skill_id": skill_id, + "city_id": city_id, "date": today, - "demand_count": int(row["demand_count"]), + "demand_count": nuevo_demand_count, "avg_salary": avg_salary_value, + "growth_rate": growth_rate, } result = TrendSnapshotRepository.upsert(snapshot_data) From bd6cf9015556c618f0b294ce28e6d2ea047d0a39 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 12 Jul 2026 23:17:26 -0600 Subject: [PATCH 161/277] feat(panorama): expose growth_rate in compare endpoint Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- backend/app/controllers/panorama_bp.py | 1 + backend/app/schemas/panorama_schema.py | 1 + 2 files changed, 2 insertions(+) diff --git a/backend/app/controllers/panorama_bp.py b/backend/app/controllers/panorama_bp.py index f9b99b4..b13778b 100644 --- a/backend/app/controllers/panorama_bp.py +++ b/backend/app/controllers/panorama_bp.py @@ -270,6 +270,7 @@ def get_compare(): "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} diff --git a/backend/app/schemas/panorama_schema.py b/backend/app/schemas/panorama_schema.py index e1a94ce..f9a475c 100644 --- a/backend/app/schemas/panorama_schema.py +++ b/backend/app/schemas/panorama_schema.py @@ -78,6 +78,7 @@ class CompareSkillBlockSchema(Schema): 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) From aa21b4e94cec81819a0bb7b34bbcaacdedc59a10 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 12 Jul 2026 23:09:12 -0600 Subject: [PATCH 162/277] feat(alerts): add alert_type and threshold_percentage schema Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- backend/app/models/alert.py | 17 +++++- ...dd_alert_type_and_threshold_percentage_.py | 53 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/versions/33f2045987f9_add_alert_type_and_threshold_percentage_.py diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py index 6ff33c2..4398b3e 100644 --- a/backend/app/models/alert.py +++ b/backend/app/models/alert.py @@ -8,6 +8,21 @@ class Alert(db.Model): 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) - threshold_value = db.Column(db.Integer, 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) OR " + "(alert_type = 'TREND' AND threshold_percentage IS NOT NULL)", + name="chk_alerts_threshold_matches_type", + ), + ) 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 ### From a0e5602c3c2e8f34a5140b022bc24808ce104521 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 12 Jul 2026 23:09:36 -0600 Subject: [PATCH 163/277] feat(alerts): validate threshold fields conditionally by alert_type Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- backend/app/schemas/alert_schema.py | 54 +++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/backend/app/schemas/alert_schema.py b/backend/app/schemas/alert_schema.py index 4037d7c..ac80b97 100644 --- a/backend/app/schemas/alert_schema.py +++ b/backend/app/schemas/alert_schema.py @@ -1,23 +1,49 @@ -from marshmallow import Schema, fields, validate +from marshmallow import Schema, fields, validate, validates_schema, ValidationError + class AlertRequestSchema(Schema): - # Validamos estrictamente que el threshold sea un número positivo. - # Evitamos que un usuario malintencionado o un error de UI envíe valores negativos que corrompan la lógica de evaluación en el servicio. - 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."}) + 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=True, - strict=True, - validate=validate.Range(min=1), - error_messages={ - "required": "El umbral es obligatorio.", - "validator_failed": "El umbral debe ser mayor a 0.", - "invalid": "El umbral debe ser un número entero." - } + 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 de la alerta al frontend. + # 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) - threshold_value = 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) From 815746b7a5df6cd70d136057ff3fcd931d9f7e37 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Sun, 12 Jul 2026 23:10:03 -0600 Subject: [PATCH 164/277] fix(alerts): add get_active to filter out deactivated alerts Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- backend/app/repositories/alert_repository.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/app/repositories/alert_repository.py b/backend/app/repositories/alert_repository.py index 194e4b4..8794e9c 100644 --- a/backend/app/repositories/alert_repository.py +++ b/backend/app/repositories/alert_repository.py @@ -9,3 +9,8 @@ class AlertRepository(BaseRepository): 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() From b9cc10f42933d58994c3499be0351b613bfa600f Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Mon, 13 Jul 2026 21:18:18 -0600 Subject: [PATCH 165/277] fix(alerts): filter active alerts and evaluate by type --- backend/app/services/alerts_service.py | 50 ++++++++++++++------------ 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/backend/app/services/alerts_service.py b/backend/app/services/alerts_service.py index 603c6b3..01e30a4 100644 --- a/backend/app/services/alerts_service.py +++ b/backend/app/services/alerts_service.py @@ -6,43 +6,49 @@ from app.utils.errors import AppError class AlertsService: - # Aísla la lógica de evaluación de notificaciones. Su diseño permite ser ejecutado de forma asíncrona (ej. Celery/APScheduler) sin bloquear la ingesta principal. - @classmethod def evaluate_and_notify(cls) -> int: - active_alerts = AlertRepository.get_all() + active_alerts = AlertRepository.get_active() if not active_alerts: return 0 - notifications_sent = 0 - for alert in active_alerts: - # Evaluamos contra el snapshot más reciente para determinar si se superó el umbral latest_trend = TrendSnapshotRepository.get_latest_by_skill(alert.skill_id) - - if not latest_trend or latest_trend.demand_count < alert.threshold_value: + 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 = 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

    - """ - + 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

    + """ try: - # Delegamos el envío al servicio de correo unificado. - # Atrapamos errores para evitar que el fallo de un email cancele el ciclo entero. send_alert_email(user.email, subject, html_content) notifications_sent += 1 except AppError: continue - return notifications_sent From 4f67a5bcd439227571944ff0d3286b5279a1be56 Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Mon, 13 Jul 2026 21:18:55 -0600 Subject: [PATCH 166/277] feat(alerts): add conditional form for absolute vs trend alerts Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- frontend/assets/css/pages/_alertas.css | 21 +++++++++ frontend/assets/js/api/alertas.api.js | 18 +++++--- frontend/assets/js/pages/alertas.js | 61 +++++++++++++++++++++++--- frontend/views/alertas.html | 45 ++++++++++++++++--- 4 files changed, 128 insertions(+), 17 deletions(-) diff --git a/frontend/assets/css/pages/_alertas.css b/frontend/assets/css/pages/_alertas.css index 68d0753..257b527 100644 --- a/frontend/assets/css/pages/_alertas.css +++ b/frontend/assets/css/pages/_alertas.css @@ -130,3 +130,24 @@ 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/js/api/alertas.api.js b/frontend/assets/js/api/alertas.api.js index ea23aa5..f9ed9f4 100644 --- a/frontend/assets/js/api/alertas.api.js +++ b/frontend/assets/js/api/alertas.api.js @@ -1,8 +1,16 @@ -async function createAlert(skillId, thresholdValue) { - return apiPost("/alerts/", { - skill_id: skillId, - threshold_value: thresholdValue, - }); +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() { diff --git a/frontend/assets/js/pages/alertas.js b/frontend/assets/js/pages/alertas.js index 21d9726..41539e7 100644 --- a/frontend/assets/js/pages/alertas.js +++ b/frontend/assets/js/pages/alertas.js @@ -45,10 +45,15 @@ function renderAlerts(alerts) { }) : "—"; + const thresholdLabel = + alert.alert_type === "TREND" + ? `≥ ${alert.threshold_percentage}% de crecimiento` + : `≥ ${alert.threshold_value} vacantes`; + const tr = document.createElement("tr"); tr.innerHTML = ` ${skillName} - ${alert.threshold_value} vacantes + ${thresholdLabel} ${createdAt}

    + +
    + + +
    +
    +
    @@ -242,7 +260,20 @@

    Nueva alerta

    min="1" step="1" placeholder="Ej. 50" - required + /> +
    + ` + : "" + } + `; tbody.appendChild(tr); }); + + tbody.querySelectorAll('[data-action="restore-backup"]').forEach((btn) => { + btn.addEventListener("click", () => + openRestoreDialog(btn.dataset.id, btn.dataset.filename), + ); + }); } async function loadPage(page) { @@ -104,6 +117,57 @@ async function handleGenerarRespaldo() { } } +function openRestoreDialog(backupId, filename) { + const dialog = document.getElementById("restore-confirm-dialog"); + const filenameEl = document.getElementById("restore-dialog-filename"); + const input = document.getElementById("restore-confirm-input"); + const confirmBtn = document.getElementById("restore-dialog-confirm"); + const msg = document.getElementById("restore-dialog-msg"); + + filenameEl.textContent = filename; + input.value = ""; + confirmBtn.disabled = true; + msg.hidden = true; + dialog.dataset.backupId = backupId; + dialog.dataset.filename = filename; + + input.oninput = () => { + confirmBtn.disabled = input.value !== filename; + }; + + dialog.showModal(); +} + +async function handleConfirmRestore() { + const dialog = document.getElementById("restore-confirm-dialog"); + const confirmBtn = document.getElementById("restore-dialog-confirm"); + const msg = document.getElementById("restore-dialog-msg"); + const backupId = dialog.dataset.backupId; + const filename = dialog.dataset.filename; + + const originalText = confirmBtn.textContent; + confirmBtn.textContent = "Restaurando..."; + confirmBtn.disabled = true; + msg.hidden = true; + + try { + const result = await restoreBackup(backupId, filename); + dialog.close(); + showInlineMessage( + document.getElementById("admin-action-msg"), + `Restauración completada. Se generó un respaldo de seguridad: ${result.safety_backup}.`, + true, + ); + await loadPage(currentPage); + } catch (error) { + msg.textContent = error.message || "No se pudo completar la restauración."; + msg.hidden = false; + confirmBtn.disabled = false; + } finally { + confirmBtn.textContent = originalText; + } +} + function bindControls() { document .getElementById("btn-generar-respaldo") @@ -116,6 +180,15 @@ function bindControls() { document.getElementById("admin-btn-next").addEventListener("click", () => { loadPage(currentPage + 1); }); + + document + .getElementById("restore-dialog-cancel") + .addEventListener("click", () => { + document.getElementById("restore-confirm-dialog").close(); + }); + document + .getElementById("restore-dialog-confirm") + .addEventListener("click", handleConfirmRestore); } async function initAdminRespaldosPage() { diff --git a/frontend/views/admin/respaldos.html b/frontend/views/admin/respaldos.html index 857511f..b069eeb 100644 --- a/frontend/views/admin/respaldos.html +++ b/frontend/views/admin/respaldos.html @@ -267,6 +267,7 @@

    Respaldos del sistema

    Estado Generado por Fecha + Acciones @@ -296,6 +297,51 @@

    Respaldos del sistema

    + + + +

    Confirmar restauración

    +

    + Esta acción reemplazará TODOS los datos actuales de + la base de datos con el contenido del respaldo + , incluyendo el propio + historial de respaldos: registros de respaldos generados después de + ese momento dejarán de aparecer en esta lista (aunque sus archivos + físicos permanecen en el servidor). Se generará un respaldo de + seguridad automático antes de proceder, pero esta acción no se puede + deshacer de ninguna otra forma. +

    +
    + + +
    + +
    + + +
    + +
    From f3388eb804e4dbaadae0312ba715b5c370cbd88c Mon Sep 17 00:00:00 2001 From: Ochoa-Stack Date: Wed, 15 Jul 2026 16:45:12 -0600 Subject: [PATCH 170/277] fix(frontend): escape user-provided data before HTML injection Co-authored-by: Oscar Soriano Co-authored-by: Aylin Chavira Co-authored-by: Alejandro Balderrama --- frontend/assets/js/pages/admin-respaldos.js | 11 ++++++++--- frontend/assets/js/pages/admin-usuarios.js | 15 +++++++++------ frontend/assets/js/pages/alertas.js | 6 ++++-- frontend/assets/js/utils/formatters.js | 7 +++++++ 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/frontend/assets/js/pages/admin-respaldos.js b/frontend/assets/js/pages/admin-respaldos.js index e51cd63..0021adf 100644 --- a/frontend/assets/js/pages/admin-respaldos.js +++ b/frontend/assets/js/pages/admin-respaldos.js @@ -60,17 +60,22 @@ function renderBackups(data) { }) : "—"; + const filenameSeguro = escapeHtml(backup.filename || "—"); + const userEmailSeguro = escapeHtml( + backup.user_email || `Usuario #${backup.user_id}`, + ); + const tr = document.createElement("tr"); tr.innerHTML = ` ${backup.id} - ${backup.filename || "—"} + ${filenameSeguro} ${formatStatus(backup.status)} - ${backup.user_email || `Usuario #${backup.user_id}`} + ${userEmailSeguro} ${fecha} ${ backup.status === "COMPLETED" - ? `` + ? `` : "" } diff --git a/frontend/assets/js/pages/admin-usuarios.js b/frontend/assets/js/pages/admin-usuarios.js index 9789229..7327c7c 100644 --- a/frontend/assets/js/pages/admin-usuarios.js +++ b/frontend/assets/js/pages/admin-usuarios.js @@ -54,17 +54,20 @@ function renderUsers(data) { : ""; const disabledAttr = isSelf ? " disabled" : ""; + const nombreSeguro = escapeHtml(nombre); + const emailSeguro = escapeHtml(user.email || "—"); + const tr = document.createElement("tr"); tr.innerHTML = ` ${user.id} - ${nombre} - ${user.email || "—"} + ${nombreSeguro} + ${emailSeguro}