Spaces:
Sleeping
Sleeping
Adzacam commited on
Commit ·
04b8e02
1
Parent(s): 59ad42d
feat: expandir endpoints de dashboard con 6 KPIs ejecutivos y analíticas de riesgo académico detalladas
Browse files
app.py
CHANGED
|
@@ -823,50 +823,128 @@ def get_dashboard_scorecard(
|
|
| 823 |
):
|
| 824 |
"""
|
| 825 |
Perspectiva 1: Scorecard Ejecutivo.
|
| 826 |
-
|
| 827 |
-
Rentabilidad Presupuestaria (Ejecutado vs Meta).
|
| 828 |
"""
|
| 829 |
try:
|
| 830 |
from sqlalchemy import func
|
| 831 |
-
|
| 832 |
-
# 1. Riesgo Multidimensional
|
| 833 |
-
cruzados = db.query(
|
| 834 |
-
DimEstudiante, FactRendimientoAcademico, FactSituacionFinanciera
|
| 835 |
-
).join(
|
| 836 |
-
FactRendimientoAcademico, DimEstudiante.id_estudiante == FactRendimientoAcademico.id_estudiante
|
| 837 |
-
).join(
|
| 838 |
-
FactSituacionFinanciera, DimEstudiante.id_estudiante == FactSituacionFinanciera.id_estudiante
|
| 839 |
-
).filter(
|
| 840 |
-
FactRendimientoAcademico.nota_final <= umbral_nota,
|
| 841 |
-
FactSituacionFinanciera.cuotas_impagas >= min_cuotas
|
| 842 |
-
).all()
|
| 843 |
-
|
| 844 |
-
total_activos = db.query(DimEstudiante).count()
|
| 845 |
-
alumnos_riesgo = len(cruzados)
|
| 846 |
-
indice_riesgo = (alumnos_riesgo / total_activos) if total_activos > 0 else 0
|
| 847 |
-
|
| 848 |
-
# 2. Salud de Cartera (Mora vs Vigente)
|
| 849 |
-
cartera_query = db.query(
|
| 850 |
-
FactSituacionFinanciera.estado_cartera,
|
| 851 |
-
func.sum(FactSituacionFinanciera.monto_deuda).label("total")
|
| 852 |
-
).group_by(FactSituacionFinanciera.estado_cartera).all()
|
| 853 |
-
|
| 854 |
-
cartera = {row.estado_cartera: float(row.total or 0) for row in cartera_query}
|
| 855 |
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 859 |
return {
|
| 860 |
"status": "success",
|
| 861 |
-
"
|
| 862 |
-
"
|
| 863 |
-
"
|
| 864 |
-
"
|
| 865 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 866 |
},
|
| 867 |
-
"cartera": cartera
|
|
|
|
| 868 |
}
|
| 869 |
except Exception as e:
|
|
|
|
|
|
|
| 870 |
raise HTTPException(status_code=500, detail=str(e))
|
| 871 |
|
| 872 |
@app.get("/api/v1/dashboard/academica")
|
|
@@ -876,54 +954,130 @@ def get_dashboard_academica(
|
|
| 876 |
):
|
| 877 |
"""
|
| 878 |
Perspectiva 2: Gestión Académica.
|
| 879 |
-
|
| 880 |
"""
|
| 881 |
try:
|
| 882 |
from sqlalchemy import func
|
|
|
|
| 883 |
# 1. Aprobación vs Reprobación
|
| 884 |
-
reprobados = db.query(FactRendimientoAcademico).filter(FactRendimientoAcademico.nota_final <= umbral_nota).count()
|
| 885 |
totales = db.query(FactRendimientoAcademico).count()
|
|
|
|
| 886 |
aprobados = totales - reprobados
|
| 887 |
|
| 888 |
-
# 2.
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
func.avg(FactRendimientoAcademico.nota_final).
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
DimModulo, FactRendimientoAcademico.id_modulo == DimModulo.id_modulo
|
| 896 |
-
).group_by(DimModulo.nombre_modulo).all()
|
| 897 |
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 906 |
|
| 907 |
-
#
|
|
|
|
|
|
|
| 908 |
try:
|
| 909 |
docentes_nps = db.query(
|
| 910 |
DimDocente.nombre_completo,
|
| 911 |
-
|
|
|
|
|
|
|
| 912 |
).outerjoin(
|
| 913 |
FactEvaluacionDocente, DimDocente.id_docente == FactEvaluacionDocente.id_docente
|
| 914 |
-
).group_by(DimDocente.nombre_completo).all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 915 |
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 919 |
db.rollback()
|
| 920 |
-
nps_data = []
|
| 921 |
|
| 922 |
return {
|
| 923 |
"status": "success",
|
| 924 |
-
"aprobacion": {"aprobados": aprobados, "reprobados": reprobados},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 925 |
"dispersion": dispersion,
|
| 926 |
-
"nps_docentes": nps_data
|
|
|
|
| 927 |
}
|
| 928 |
except Exception as e:
|
| 929 |
import traceback
|
|
|
|
| 823 |
):
|
| 824 |
"""
|
| 825 |
Perspectiva 1: Scorecard Ejecutivo.
|
| 826 |
+
Retorna los 6 KPIs ejecutivos del CMI.
|
|
|
|
| 827 |
"""
|
| 828 |
try:
|
| 829 |
from sqlalchemy import func
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 830 |
|
| 831 |
+
total_estudiantes = db.query(DimEstudiante).count()
|
| 832 |
+
|
| 833 |
+
# 1. Riesgo Multidimensional (Académico + Financiero cruzado)
|
| 834 |
+
alumnos_riesgo = 0
|
| 835 |
+
try:
|
| 836 |
+
cruzados = db.query(DimEstudiante.id_estudiante).join(
|
| 837 |
+
FactRendimientoAcademico, DimEstudiante.id_estudiante == FactRendimientoAcademico.id_estudiante
|
| 838 |
+
).join(
|
| 839 |
+
FactSituacionFinanciera, DimEstudiante.id_estudiante == FactSituacionFinanciera.id_estudiante
|
| 840 |
+
).filter(
|
| 841 |
+
FactRendimientoAcademico.nota_final <= umbral_nota,
|
| 842 |
+
FactSituacionFinanciera.cuotas_impagas >= min_cuotas
|
| 843 |
+
).distinct().count()
|
| 844 |
+
alumnos_riesgo = cruzados
|
| 845 |
+
except Exception:
|
| 846 |
+
db.rollback()
|
| 847 |
+
|
| 848 |
+
indice_riesgo = (alumnos_riesgo / total_estudiantes * 100) if total_estudiantes > 0 else 0
|
| 849 |
+
|
| 850 |
+
# 2. Cartera financiera
|
| 851 |
+
cartera = {}
|
| 852 |
+
deuda_total = 0
|
| 853 |
+
try:
|
| 854 |
+
cartera_q = db.query(
|
| 855 |
+
FactSituacionFinanciera.estado_cartera,
|
| 856 |
+
func.count(FactSituacionFinanciera.id_hecho_fin).label("cantidad"),
|
| 857 |
+
func.sum(FactSituacionFinanciera.monto_deuda).label("total")
|
| 858 |
+
).group_by(FactSituacionFinanciera.estado_cartera).all()
|
| 859 |
+
cartera = {r.estado_cartera: {"cantidad": int(r.cantidad), "monto": float(r.total or 0)} for r in cartera_q}
|
| 860 |
+
deuda_total = sum(v["monto"] for v in cartera.values())
|
| 861 |
+
except Exception:
|
| 862 |
+
db.rollback()
|
| 863 |
+
|
| 864 |
+
# 3. EBITDA / Rentabilidad (try real table, fallback to computed from cartera)
|
| 865 |
+
ebitda_data = {"ingresos_ejecutados": 0, "costos_asociados": 0, "ebitda": 0, "meta_ingresos": 0}
|
| 866 |
+
margen_por_programa = []
|
| 867 |
+
try:
|
| 868 |
+
rent_data = db.query(
|
| 869 |
+
DimModulo.programa,
|
| 870 |
+
func.sum(FactRentabilidadPresupuesto.monto_ejecutado).label("ejecutado"),
|
| 871 |
+
func.sum(FactRentabilidadPresupuesto.monto_meta).label("meta")
|
| 872 |
+
).join(DimModulo, FactRentabilidadPresupuesto.id_modulo == DimModulo.id_modulo
|
| 873 |
+
).group_by(DimModulo.programa).all()
|
| 874 |
+
|
| 875 |
+
total_ejecutado = sum(float(r.ejecutado or 0) for r in rent_data)
|
| 876 |
+
total_meta = sum(float(r.meta or 0) for r in rent_data)
|
| 877 |
+
ebitda_data = {
|
| 878 |
+
"ingresos_ejecutados": total_ejecutado,
|
| 879 |
+
"costos_asociados": total_ejecutado * 0.65,
|
| 880 |
+
"ebitda": total_ejecutado * 0.35,
|
| 881 |
+
"meta_ingresos": total_meta,
|
| 882 |
+
"cumplimiento_pct": round((total_ejecutado / total_meta * 100), 1) if total_meta > 0 else 0
|
| 883 |
+
}
|
| 884 |
+
margen_por_programa = [
|
| 885 |
+
{"programa": r.programa or "General", "ejecutado": float(r.ejecutado or 0), "meta": float(r.meta or 0),
|
| 886 |
+
"margen_pct": round(float(r.ejecutado or 0) / float(r.meta or 1) * 100, 1)}
|
| 887 |
+
for r in rent_data
|
| 888 |
+
]
|
| 889 |
+
except Exception:
|
| 890 |
+
db.rollback()
|
| 891 |
+
|
| 892 |
+
# 4. Tasa de Retención Estudiantil
|
| 893 |
+
total_hechos_aca = 0
|
| 894 |
+
estudiantes_activos = 0
|
| 895 |
+
retencion_pct = 0
|
| 896 |
+
try:
|
| 897 |
+
total_hechos_aca = db.query(FactRendimientoAcademico).count()
|
| 898 |
+
estudiantes_activos = db.query(FactRendimientoAcademico.id_estudiante).distinct().count()
|
| 899 |
+
# Students with nota > umbral are "retained"
|
| 900 |
+
retenidos = db.query(FactRendimientoAcademico.id_estudiante).filter(
|
| 901 |
+
FactRendimientoAcademico.nota_final > umbral_nota
|
| 902 |
+
).distinct().count()
|
| 903 |
+
retencion_pct = round((retenidos / estudiantes_activos * 100), 1) if estudiantes_activos > 0 else 0
|
| 904 |
+
except Exception:
|
| 905 |
+
db.rollback()
|
| 906 |
+
|
| 907 |
+
# 5. Satisfacción Global (NPS Docente)
|
| 908 |
+
satisfaccion = 0
|
| 909 |
+
try:
|
| 910 |
+
avg_nps = db.query(func.avg(FactEvaluacionDocente.puntuacion)).scalar()
|
| 911 |
+
satisfaccion = round(float(avg_nps or 0), 1)
|
| 912 |
+
except Exception:
|
| 913 |
+
db.rollback()
|
| 914 |
+
|
| 915 |
+
# 6. Integridad MLOps
|
| 916 |
+
mlops_integridad = {"validados_pct": 0, "en_auditoria_pct": 0, "total_registros": 0}
|
| 917 |
+
try:
|
| 918 |
+
total_reg = db.query(FactRendimientoAcademico).count()
|
| 919 |
+
en_revision = db.query(FactRendimientoAcademico).filter(FactRendimientoAcademico.requiere_revision == True).count()
|
| 920 |
+
avg_conf = db.query(func.avg(FactRendimientoAcademico.nivel_confianza_ia)).scalar()
|
| 921 |
+
mlops_integridad = {
|
| 922 |
+
"validados_pct": round(((total_reg - en_revision) / total_reg * 100), 1) if total_reg > 0 else 0,
|
| 923 |
+
"en_auditoria_pct": round((en_revision / total_reg * 100), 1) if total_reg > 0 else 0,
|
| 924 |
+
"total_registros": total_reg,
|
| 925 |
+
"confianza_promedio": round(float(avg_conf or 0) * 100, 1)
|
| 926 |
+
}
|
| 927 |
+
except Exception:
|
| 928 |
+
db.rollback()
|
| 929 |
+
|
| 930 |
return {
|
| 931 |
"status": "success",
|
| 932 |
+
"kpis": {
|
| 933 |
+
"ebitda": ebitda_data,
|
| 934 |
+
"retencion_pct": retencion_pct,
|
| 935 |
+
"satisfaccion_global": satisfaccion,
|
| 936 |
+
"riesgo_desercion_pct": round(indice_riesgo, 1),
|
| 937 |
+
"alumnos_riesgo": alumnos_riesgo,
|
| 938 |
+
"total_estudiantes": total_estudiantes,
|
| 939 |
+
"deuda_total": deuda_total,
|
| 940 |
+
"mlops": mlops_integridad
|
| 941 |
},
|
| 942 |
+
"cartera": cartera,
|
| 943 |
+
"margen_por_programa": margen_por_programa
|
| 944 |
}
|
| 945 |
except Exception as e:
|
| 946 |
+
import traceback
|
| 947 |
+
traceback.print_exc()
|
| 948 |
raise HTTPException(status_code=500, detail=str(e))
|
| 949 |
|
| 950 |
@app.get("/api/v1/dashboard/academica")
|
|
|
|
| 954 |
):
|
| 955 |
"""
|
| 956 |
Perspectiva 2: Gestión Académica.
|
| 957 |
+
Riesgo académico, deserción, evaluaciones docentes, distribución por estado.
|
| 958 |
"""
|
| 959 |
try:
|
| 960 |
from sqlalchemy import func
|
| 961 |
+
|
| 962 |
# 1. Aprobación vs Reprobación
|
|
|
|
| 963 |
totales = db.query(FactRendimientoAcademico).count()
|
| 964 |
+
reprobados = db.query(FactRendimientoAcademico).filter(FactRendimientoAcademico.nota_final <= umbral_nota).count()
|
| 965 |
aprobados = totales - reprobados
|
| 966 |
|
| 967 |
+
# 2. Riesgo Académico Crítico
|
| 968 |
+
nota_promedio = 0
|
| 969 |
+
try:
|
| 970 |
+
avg_nota = db.query(func.avg(FactRendimientoAcademico.nota_final)).scalar()
|
| 971 |
+
nota_promedio = round(float(avg_nota or 0), 1)
|
| 972 |
+
except Exception:
|
| 973 |
+
db.rollback()
|
|
|
|
|
|
|
| 974 |
|
| 975 |
+
# 3. Riesgo Deserción (Inasistencia > 30% o Incumplimiento > 30%)
|
| 976 |
+
riesgo_desercion = 0
|
| 977 |
+
try:
|
| 978 |
+
riesgo_desercion = db.query(FactRendimientoAcademico).filter(
|
| 979 |
+
(FactRendimientoAcademico.asistencia_pct < 70) |
|
| 980 |
+
(FactRendimientoAcademico.incumplimiento_actividades_pct > 30)
|
| 981 |
+
).count()
|
| 982 |
+
except Exception:
|
| 983 |
+
db.rollback()
|
| 984 |
+
|
| 985 |
+
# 4. Dispersión Notas vs Asistencia por módulo
|
| 986 |
+
dispersion = []
|
| 987 |
+
try:
|
| 988 |
+
dispersion_raw = db.query(
|
| 989 |
+
DimModulo.nombre_modulo,
|
| 990 |
+
func.avg(FactRendimientoAcademico.nota_final).label("nota_promedio"),
|
| 991 |
+
func.avg(FactRendimientoAcademico.asistencia_pct).label("asistencia_promedio"),
|
| 992 |
+
func.avg(FactRendimientoAcademico.incumplimiento_actividades_pct).label("incumplimiento_promedio"),
|
| 993 |
+
func.count(FactRendimientoAcademico.id_hecho_aca).label("total_alumnos")
|
| 994 |
+
).join(
|
| 995 |
+
DimModulo, FactRendimientoAcademico.id_modulo == DimModulo.id_modulo
|
| 996 |
+
).group_by(DimModulo.nombre_modulo).all()
|
| 997 |
+
|
| 998 |
+
dispersion = [
|
| 999 |
+
{
|
| 1000 |
+
"modulo": r.nombre_modulo,
|
| 1001 |
+
"nota": round(float(r.nota_promedio or 0), 1),
|
| 1002 |
+
"asistencia": round(float(r.asistencia_promedio or 0), 1),
|
| 1003 |
+
"incumplimiento": round(float(r.incumplimiento_promedio or 0), 1),
|
| 1004 |
+
"alumnos": int(r.total_alumnos)
|
| 1005 |
+
} for r in dispersion_raw
|
| 1006 |
+
]
|
| 1007 |
+
except Exception:
|
| 1008 |
+
db.rollback()
|
| 1009 |
|
| 1010 |
+
# 5. Evaluación Docente (NPS)
|
| 1011 |
+
nps_data = []
|
| 1012 |
+
nps_promedio_global = 0
|
| 1013 |
try:
|
| 1014 |
docentes_nps = db.query(
|
| 1015 |
DimDocente.nombre_completo,
|
| 1016 |
+
DimDocente.area_especialidad,
|
| 1017 |
+
func.avg(FactEvaluacionDocente.puntuacion).label("nps_promedio"),
|
| 1018 |
+
func.count(FactEvaluacionDocente.id_hecho_eval).label("total_evaluaciones")
|
| 1019 |
).outerjoin(
|
| 1020 |
FactEvaluacionDocente, DimDocente.id_docente == FactEvaluacionDocente.id_docente
|
| 1021 |
+
).group_by(DimDocente.nombre_completo, DimDocente.area_especialidad).all()
|
| 1022 |
+
|
| 1023 |
+
nps_data = [
|
| 1024 |
+
{
|
| 1025 |
+
"docente": d.nombre_completo,
|
| 1026 |
+
"area": d.area_especialidad,
|
| 1027 |
+
"nps": round(float(d.nps_promedio), 1) if d.nps_promedio else 0,
|
| 1028 |
+
"evaluaciones": int(d.total_evaluaciones)
|
| 1029 |
+
} for d in docentes_nps
|
| 1030 |
+
]
|
| 1031 |
+
if nps_data:
|
| 1032 |
+
nps_promedio_global = round(sum(d["nps"] for d in nps_data if d["nps"] > 0) / max(len([d for d in nps_data if d["nps"] > 0]), 1), 1)
|
| 1033 |
+
except Exception:
|
| 1034 |
+
db.rollback()
|
| 1035 |
|
| 1036 |
+
# 6. Distribución por estado ARCA (derivado de notas y asistencia)
|
| 1037 |
+
estado_distribucion = []
|
| 1038 |
+
try:
|
| 1039 |
+
# Aprobado-Titulado: nota > 70 y asistencia >= 70
|
| 1040 |
+
aprobado_titulado = db.query(FactRendimientoAcademico).filter(
|
| 1041 |
+
FactRendimientoAcademico.nota_final > umbral_nota,
|
| 1042 |
+
FactRendimientoAcademico.asistencia_pct >= 70
|
| 1043 |
+
).count()
|
| 1044 |
+
# Reprobado-Insuficiencia: nota <= 70 y asistencia >= 70
|
| 1045 |
+
reprobado_insuf = db.query(FactRendimientoAcademico).filter(
|
| 1046 |
+
FactRendimientoAcademico.nota_final <= umbral_nota,
|
| 1047 |
+
FactRendimientoAcademico.asistencia_pct >= 70
|
| 1048 |
+
).count()
|
| 1049 |
+
# Reprobado-Deserción: asistencia < 50
|
| 1050 |
+
reprobado_desercion = db.query(FactRendimientoAcademico).filter(
|
| 1051 |
+
FactRendimientoAcademico.asistencia_pct < 50
|
| 1052 |
+
).count()
|
| 1053 |
+
# Reprobado-Congelamiento: nota <= 70 y 50 <= asistencia < 70
|
| 1054 |
+
reprobado_congelamiento = db.query(FactRendimientoAcademico).filter(
|
| 1055 |
+
FactRendimientoAcademico.nota_final <= umbral_nota,
|
| 1056 |
+
FactRendimientoAcademico.asistencia_pct >= 50,
|
| 1057 |
+
FactRendimientoAcademico.asistencia_pct < 70
|
| 1058 |
+
).count()
|
| 1059 |
+
|
| 1060 |
+
estado_distribucion = [
|
| 1061 |
+
{"estado": "Aprobado - Titulado", "cantidad": aprobado_titulado, "color": "#10b981"},
|
| 1062 |
+
{"estado": "Reprobado - Insuficiencia", "cantidad": reprobado_insuf, "color": "#f59e0b"},
|
| 1063 |
+
{"estado": "Reprobado - Deserción", "cantidad": reprobado_desercion, "color": "#ef4444"},
|
| 1064 |
+
{"estado": "Reprobado - Congelamiento", "cantidad": reprobado_congelamiento, "color": "#8b5cf6"}
|
| 1065 |
+
]
|
| 1066 |
+
except Exception:
|
| 1067 |
db.rollback()
|
|
|
|
| 1068 |
|
| 1069 |
return {
|
| 1070 |
"status": "success",
|
| 1071 |
+
"aprobacion": {"aprobados": aprobados, "reprobados": reprobados, "total": totales},
|
| 1072 |
+
"kpis": {
|
| 1073 |
+
"nota_promedio": nota_promedio,
|
| 1074 |
+
"riesgo_desercion": riesgo_desercion,
|
| 1075 |
+
"nps_promedio_global": nps_promedio_global,
|
| 1076 |
+
"tasa_aprobacion_pct": round((aprobados / totales * 100), 1) if totales > 0 else 0
|
| 1077 |
+
},
|
| 1078 |
"dispersion": dispersion,
|
| 1079 |
+
"nps_docentes": nps_data,
|
| 1080 |
+
"estado_distribucion": estado_distribucion
|
| 1081 |
}
|
| 1082 |
except Exception as e:
|
| 1083 |
import traceback
|