Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
131 changes: 131 additions & 0 deletions backend/app/controllers/profile_bp.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +91 to +93


@profile_bp.route("/skills/<int:skill_id>", 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."})
9 changes: 7 additions & 2 deletions backend/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
39 changes: 39 additions & 0 deletions backend/app/repositories/user_skill_repository.py
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions backend/app/schemas/profile_schema.py
Original file line number Diff line number Diff line change
@@ -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.")
Comment on lines +6 to +28


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."},
)
72 changes: 72 additions & 0 deletions backend/app/services/profile_service.py
Original file line number Diff line number Diff line change
@@ -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}
Comment on lines +14 to +16

# 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,
Comment on lines +24 to +28
"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)
37 changes: 37 additions & 0 deletions backend/migrations/versions/050a32090a02_add_intent_to_users.py
Original file line number Diff line number Diff line change
@@ -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 ###
14 changes: 13 additions & 1 deletion frontend/assets/css/layouts/_grid.css
Original file line number Diff line number Diff line change
@@ -1 +1,13 @@
/* _grid.css — SkillStat */
/* 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;
}
}
Loading