Skip to content
172 changes: 21 additions & 151 deletions backend/app/controllers/panorama_bp.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
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.services.panorama_service import PanoramaService
from app.schemas.skill_schema import SkillResponseSchema
from app.schemas.panorama_schema import (
CatalogsResponseSchema,
Expand All @@ -16,88 +14,35 @@

panorama_bp = Blueprint("panorama_bp", __name__)


@panorama_bp.route("/skills", methods=["GET"])
def get_skills():
# 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()
skills = PanoramaService.get_all_skills()
result = SkillResponseSchema(many=True).dump(skills)
return success_response(data=result, status_code=200)


@panorama_bp.route("/catalogs", methods=["GET"])
def get_catalogs():
# 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],
}

payload = PanoramaService.get_catalogs_data()
result = CatalogsResponseSchema().dump(payload)
return success_response(data=result, status_code=200)


@panorama_bp.route("/summary", methods=["GET"])
def get_summary():
# 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"],
"total_companies": data["total_companies"],
"top_emerging_skill": build_skill_block(data["top_emerging"]),
"top_declining_skill": build_skill_block(data["top_declining"]),
"last_updated": data["latest_date"],
}

payload = PanoramaService.get_summary_data()
result = SummaryResponseSchema().dump(payload)
return success_response(data=result, status_code=200)


@panorama_bp.route("/skills/top", methods=["GET"])
def get_top_skills():
# 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 — validacion de entrada HTTP, no logica de negocio.
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,
"category": s.skill.category.name if s.skill and s.skill.category else None,
"demand_count": s.demand_count,
"growth_rate": s.growth_rate,
"avg_salary": s.avg_salary,
}
for s in snapshots
]

payload = PanoramaService.get_top_skills_data(limit=limit)
result = SkillTrendSchema(many=True).dump(payload)
return success_response(data=result, status_code=200)


@panorama_bp.route("/trends", methods=["GET"])
def get_trends():
# 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:
Expand All @@ -107,32 +52,19 @@ def get_trends():
status_code=422,
)

skill = SkillRepository.get_by_id(skill_id)
if not skill:
payload = PanoramaService.get_trends_data(skill_id)
if payload is None:
return error_response(
code="NOT_FOUND",
message="La habilidad solicitada no existe.",
status_code=404,
)

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)


@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)
group_by = request.args.get("group_by", default="city", type=str)

Expand All @@ -143,47 +75,19 @@ def get_geo():
status_code=422,
)

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, group_by=group_by)

distribution = []
for row in rows:
if group_by == "state":
distribution.append({
"state": row.state,
"demand_count": row.total_demand,
"is_fallback": row.is_fallback,
})
else:
distribution.append({
"city_id": row.city_id,
"city_name": row.city_name,
"state": row.state,
"demand_count": row.total_demand,
})

payload = {
"skill_id": skill.id if skill else None,
"skill_name": skill.name if skill else None,
"distribution": distribution,
}
payload = PanoramaService.get_geo_data(skill_id=skill_id, group_by=group_by)
if isinstance(payload, tuple) and payload[0] == "NOT_FOUND":
return error_response(
code="NOT_FOUND",
message="La habilidad solicitada no existe.",
status_code=404,
)

result = GeoResponseSchema().dump(payload)
return success_response(data=result, status_code=200)


@panorama_bp.route("/salaries", methods=["GET"])
def get_salaries():
# 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:
Expand All @@ -193,28 +97,17 @@ def get_salaries():
status_code=422,
)

skill = SkillRepository.get_by_id(skill_id)
if not skill:
payload = PanoramaService.get_salaries_data(skill_id)
if payload is None:
return error_response(
code="NOT_FOUND",
message="La habilidad solicitada no existe.",
status_code=404,
)

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)


@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
Expand Down Expand Up @@ -244,39 +137,16 @@ def get_compare():
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)
data = PanoramaService.get_compare_data(skill_ids)

if missing_ids:
if data["missing_ids"]:
return error_response(
code="NOT_FOUND",
message=f"Las siguientes habilidades no existen: {missing_ids}.",
message=f"Las siguientes habilidades no existen: {data['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,
"growth_rate": latest.growth_rate if latest else None,
"avg_salary": latest.avg_salary if latest else None,
"series": [
{"date": s.date, "demand_count": s.demand_count}
for s in series_snapshots
],
})
blocks = data["blocks"]

result = CompareResponseSchema().dump({"skills": blocks})
return success_response(data=result, status_code=200)
63 changes: 13 additions & 50 deletions backend/app/repositories/city_repository.py
Original file line number Diff line number Diff line change
@@ -1,63 +1,26 @@
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

@classmethod
def _normalize(cls, text: str) -> str:
return ''.join(
c for c in unicodedata.normalize('NFD', text.strip().lower())
if unicodedata.category(c) != 'Mn'
)

@classmethod
def get_by_name(cls, name: str):
return db.session.execute(
db.select(City).filter_by(name=name)
).scalar_one_or_none()

@classmethod
def 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 = cls._normalize(raw_location)

# Búsqueda exhaustiva comparando el nombre normalizado
all_cities = db.session.execute(db.select(City)).scalars().all()
for city in all_cities:
city_norm = cls._normalize(city.name)
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 = cls._normalize(resolved_name)

for city in all_cities:
city_norm = cls._normalize(city.name)
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
def find_by_normalized_name(cls, raw_name: str):
# Usa unaccent() nativo de PostgreSQL para busqueda insensible a acentos y mayusculas, reemplazando el escaneo completo en memoria que hacia _normalize. Sin indice funcional (unaccent de un solo argumento es STABLE, no IMMUTABLE, y el wrapper IMMUTABLE resulto incompatible entre versiones de PostgreSQL), aceptable dado el volumen actual del catalogo de ciudades; escanea la tabla completa en el motor, no en Python, que ya es la mejora real sobre el comportamiento anterior.
if not raw_name:
return None
raw_name = raw_name.strip()
from sqlalchemy import func
normalized_input = func.unaccent(func.lower(raw_name))
return db.session.execute(
db.select(City).filter(
func.unaccent(func.lower(City.name)) == normalized_input
)
).scalar_one_or_none()
6 changes: 6 additions & 0 deletions backend/app/repositories/skill_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ def get_by_canonical_name(cls, canonical_name: str) -> Skill:
db.select(Skill).filter_by(canonical_name=canonical_name)
).scalar_one_or_none()

@classmethod
def get_by_ids(cls, skill_ids: list[int]) -> list:
return db.session.execute(
db.select(Skill).filter(Skill.id.in_(skill_ids))
).scalars().all()

@classmethod
def get_salary_stats(cls, skill_id: int):
from sqlalchemy import func
Expand Down
18 changes: 18 additions & 0 deletions backend/app/repositories/trend_snapshot_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ def get_latest_by_skill(cls, skill_id: int):
.limit(1)
).scalar_one_or_none()

@classmethod
def get_latest_by_skill_ids(cls, skill_ids: list[int]) -> list:
# DISTINCT ON requiere que el primer campo de ORDER BY coincida con la columna de distinct, así garantizamos una fila por skill_id: la de fecha mas reciente (date DESC).
return db.session.execute(
db.select(TrendSnapshot)
.filter(TrendSnapshot.skill_id.in_(skill_ids))
.distinct(TrendSnapshot.skill_id)
.order_by(TrendSnapshot.skill_id, desc(TrendSnapshot.date))
).scalars().all()

@classmethod
def get_by_skill_city_date(cls, skill_id: int, city_id: int, target_date):
return db.session.execute(
Expand Down Expand Up @@ -66,6 +76,14 @@ def get_by_skill_id(cls, skill_id: int) -> list:
.order_by(TrendSnapshot.date)
).scalars().all()

@classmethod
def get_by_skill_ids(cls, skill_ids: list[int]) -> list:
return db.session.execute(
db.select(TrendSnapshot)
.filter(TrendSnapshot.skill_id.in_(skill_ids))
.order_by(TrendSnapshot.skill_id, TrendSnapshot.date)
).scalars().all()

@classmethod
def get_by_city_id(cls, city_id: int) -> list:
return db.session.execute(
Expand Down
Loading
Loading