commit 857f8f94d4abb1cf2144451d72df25c2565df8b7 Author: AlexLara Date: Tue Mar 3 13:05:51 2026 -0600 Carga inicial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fe7ad8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +test/ +.streamlit/secrets.toml \ No newline at end of file diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..44ab21c --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,29 @@ +# for local development +[server] + +#no default browser +headless = true + +# for local development +runOnSave = true + +# Limit uploads (MB). Excel files here are small; adjust if needed +maxUploadSize = 5 + +# for local development +fileWatcherType="poll" + + +[theme] +base="light" +primaryColor = "#001d68" +backgroundColor = "#FFFFFF" +secondaryBackgroundColor = "#F4F6FA" +textColor = "#111827" +font = "sans serif" + +[client] +toolbarMode = "minimal" + +[ui] +hideTopBar = true diff --git a/.streamlit/config_prod.toml b/.streamlit/config_prod.toml new file mode 100644 index 0000000..5ec701d --- /dev/null +++ b/.streamlit/config_prod.toml @@ -0,0 +1,49 @@ +## Streamlit config — Production defaults + +[server] +# Run without launching a browser +headless = true + +# Listen on all interfaces (useful for containers/VMs) +address = "0.0.0.0" + +# Fixed port; override with STREAMLIT_SERVER_PORT if needed +port = 8501 + +# Behind a reverse proxy you typically want CORS disabled +enableCORS = false + +# Keep XSRF protection ON in production +enableXsrfProtection = true + +# Limit uploads (MB). Excel files here are small; adjust if needed +maxUploadSize = 5 + +# Reduce CPU usage by disabling file watching (no auto-reload) +fileWatcherType = "none" + +# Don’t auto-rerun on file save in production +runOnSave = false + +# Better network performance for many clients +enableWebsocketCompression = true + +# Optional: if serving behind a subpath (e.g., https://host/sgu) +# baseUrlPath = "sgu" + +[browser] +# Disable Streamlit telemetry in production +gatherUsageStats = false + +[client] +# Hide developer toolbar in production +toolbarMode = "viewer" + +[logger] +# Use info or warning for less noise +level = "info" + +## Secrets +# For production, set a strong cookie secret via environment variable: +# STREAMLIT_SERVER_COOKIE_SECRET="" +# Don’t store secrets in this file. \ No newline at end of file diff --git a/.streamlit/secrets_template.toml b/.streamlit/secrets_template.toml new file mode 100644 index 0000000..7901e93 --- /dev/null +++ b/.streamlit/secrets_template.toml @@ -0,0 +1,7 @@ +# Credenciales de PostgreSQL +[postgres] +host = "host" +port = "5432" +database = "database" +user = "usr" +password = "P4ssw0rd." diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..610ad5e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.10-slim + +WORKDIR /code + +RUN apt-get update && apt-get install -y \ + build-essential \ + curl \ + software-properties-common \ + git \ + && rm -rf /var/lib/apt/lists/* + +COPY . . + +RUN mkdir -p /code/.streamlit + +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt + +EXPOSE 8501 + +ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0", "--server.runOnSave=true"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f5bab9 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +## Instalación (Windows PowerShell) + +```powershell +Activar el entorno +.\.venv\Scripts\Activate.ps1 # powershell +source .venv/Scripts/activate # git bash +call venv\Scripts\activate.bat +``` + +## Ejecución + +```powershell +streamlit run app.py +``` \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..a948b46 --- /dev/null +++ b/app.py @@ -0,0 +1,413 @@ +import streamlit as st +import pandas as pd +from sqlalchemy import create_engine +import json +import altair as alt + +st.set_page_config(layout="wide") +st.logo("./assets/logo_lasalle.png") +SPACE_BETWEEN_SECTIONS = 45 + +# --------------------------------------------------------- +# LIMPIEZA DE DATAFRAMES +# --------------------------------------------------------- + +def _limpia_info(df): + # Limpieza de columnas + df["promedio"] = pd.to_numeric(df["promedio"].replace("", 0).fillna(0), errors="coerce").fillna(0) + df["creditos_totales"] = pd.to_numeric(df["creditos_totales"].replace("", 0).fillna(0), errors="coerce").astype(int) + df["servicio_social"] = df["servicio_social"].fillna(False) + df["calificacion"] = ( + df["calificacion"] + .replace({"SD": 0, "NP": 0}) + .fillna(0) + ) + +def _iniciales_materia(materia_desc): + palabras = materia_desc.split() + iniciales = ''.join([palabra[0].upper() for palabra in palabras if palabra]) + return iniciales + +# --------------------------------------------------------- +# CARGA Y PROCESAMIENTO DE DATOS DESDE POSTGRESQL +# --------------------------------------------------------- + +@st.cache_data(show_spinner="Cargando información de la base de datos, esto puede tardar unos segundos...") +def _crea_df(conn_str): + + engine = create_engine(conn_str) + + # --- Carga inicial desde SQL --- + df_info_completa = pd.read_sql( + ''' + SELECT al."Usuario_claveULSA" as "claveULSA", al."Alumno_sexo" as sexo, al."Alumno_semestre" as semestre, al."Carrera_id" as carrera_clave, ca."Carrera_prefijo" as carrera_prefijo, + "Alumno_serviciosocial" as servicio_social, "Alumno_beca" as beca, "Alumno_promedioSalle" as promedio, "Alumno_creditos" as creditos_totales, mca."Carrera_prefijo" as carrera_mat_prefijo, + m."Materia_desc" as materia, m."Materia_id" as materia_clave, p."Periodo_shortname" as periodo, m."Materia_semestre" as materia_semestre, + amc."Calificacion_calif" as calificacion, tc."TipoCalificacion_desc_corta" as tipo_calificacion + FROM "Alumno_Materia_Calificacion" amc + INNER JOIN "Materia" m ON amc."Materia_id" = m."Materia_id" + INNER JOIN "Periodo" p ON amc."Periodo_id" = p."Periodo_id" + INNER JOIN "TipoCalificacion" tc ON amc."TipoCalificacion_id" = tc."TipoCalificacion_id" + INNER JOIN "Alumno_view" al ON amc."Usuario_claveULSA" = al."Usuario_claveULSA" + INNER JOIN "Carrera" ca ON al."Carrera_id" = ca."Carrera_id" + INNER JOIN "PlanEstudio" mpe ON m."PlanEstudio_id" = mpe."PlanEstudio_id" + INNER JOIN "Carrera" mca ON mpe."Carrera_id" = mca."Carrera_id" + WHERE al."EstadoAlumno_isActivo" = true and al."Nivel_id" = 1 + ''', + engine + ) + + # LIMPIAR DATAFRAMES + _limpia_info(df_info_completa) + + df_info_profesores = pd.read_sql( + ''' + SELECT * from fs_profesorespromedio(NULL, 1, NULL); + ''', + engine + ) + df_info_profesores.rename(columns={ + "Usuario_claveULSA": "claveULSA", + "Carrera_prefijo": "carrera_prefijo", + "Periodo_shortname": "periodo", + "promedio_final": "promedio", + }, inplace=True) + #df_info_profesores["profesor_nombre"] = df_info_profesores["Usuario_nombre"]+" "+df_info_profesores["Usuario_apellidos"] + + return df_info_completa, df_info_profesores + +# --------------------------------------------------------- +# FUNCIÓN INICIAL (USA CACHE) +# --------------------------------------------------------- + +def _init_datos(): + secrets = st.secrets["postgres"] + + conn_str = ( + f"postgresql+psycopg2://{secrets['user']}:{secrets['password']}" + f"@{secrets['host']}:{secrets['port']}/{secrets['database']}" + ) + df_info_completa, df_info_profesores = _crea_df(conn_str) + return df_info_completa, df_info_profesores + +# --------------------------------------------------------- +# DASHBOARD +# --------------------------------------------------------- + +st.title("📊 Dashboard de alumnos de Ingeniería") + +df_info_completa, df_info_profesores = _init_datos() + +tabInfo, tabMaterias, tabAlumnos, tabProfesores = st.tabs([ "❓Acerca de", "📒 Análisis por Materias", "🚻 Análisis por Alumnos", "🧑‍🎓 Análisis por Profesores"]) +with tabInfo: + st.write("Los datos presentados en este dashboard corresponden a los alumnos de Ingeniería de la Universidad La Salle, con información obtenida en la extracción de datos.") + st.write("El objetivo es proporcionar una visión general del desempeño académico, distribución de becas, servicio social y otros aspectos relevantes de los estudiantes.") + st.write("Los datos se han obtenido directamente de la base de datos institucional y se han limpiado para asegurar su precisión y utilidad en el análisis. En las siguientes secciones podrás explorar diferentes perspectivas sobre el rendimiento académico tanto a nivel de materias, alumnos individuales y profesores.") + st.write("Debido a la naturaleza de la extracción y limpieza de datos, es posible que algunos registros no estén completos o que ciertos promedios se calculen solo con la información disponible. Se recomienda interpretar los resultados considerando estas limitaciones.") + +with tabMaterias: + # csv_historial = df_historial.to_csv(index=False).encode('utf-8') + # st.download_button( + # label="Descargar historial completo (CSV)", + # data=csv_historial, + # file_name='historial.csv', + # mime='text/csv', + # icon="📥", + # ) + + + df = df_info_completa.copy() + periodos_list = df["periodo"].unique().tolist() + periodos_list.sort() + tipo_calif_list = df["tipo_calificacion"].unique().tolist() + + col1, col2, col3 = st.columns([2,1,2]) + with col1: + # periodos = st.pills("Selecciona un periodo", periodos_list, help="Los periodos 1 son de Ago-Dic, los 2 de Ene-Jun,los I de Invierno y los V de Verano.", default=periodos_list[-1]) + periodos = st.select_slider("Selecciona un periodo", periodos_list, help="Los periodos 1 son de Ago-Dic, los 2 de Ene-Jun,los I de Invierno y los V de Verano.", value=periodos_list[-3]) + with col2: + tipo_calif = st.pills("Selecciona el tipo de calificación", tipo_calif_list, help="Tipo de calificación: Ordinaria, Extraordinaria, etc.", selection_mode="multi", default=tipo_calif_list) + with col3: + carrera_mat_list = df_info_completa["carrera_mat_prefijo"].unique().tolist() + carrera_mat_list.sort() + carrera_mat = st.pills("Selecciona la carrera", carrera_mat_list, help="Carrera del alumno.", selection_mode="multi", default=carrera_mat_list, key="carrera_mat") + + + if len(tipo_calif) > 0: + #df_periodo = df[df["periodo"] == periodos] + df_periodo = df[(df["periodo"] == periodos) & (df["tipo_calificacion"].isin(tipo_calif))] + df_periodo_filtrado = df_periodo[df_periodo["carrera_mat_prefijo"].isin(carrera_mat)] + alumnos_periodo = df_periodo_filtrado["claveULSA"].nunique() + + df_prom= df_periodo_filtrado.groupby(["semestre"]).mean({"calificacion": "mean", }).reset_index() + # st.write(df_periodo.head()) + + if df_prom.empty: + st.info("No hay datos para los filtros seleccionados.") + else: + st.space(SPACE_BETWEEN_SECTIONS) + row = st.container(horizontal=True) + with row: + st.metric("Número de materias", f"{df_periodo_filtrado.shape[0]:,}/{df_periodo.shape[0]:,}", border=True, help="Número de materias registradas en el periodo filtrado respecto al total de materias en ese periodo.") + st.metric("Promedio de calificación en periodo", f"{df_prom['calificacion'].mean():.2f}", border=True) + st.metric("Materias reprobadas en periodo", f"{df_periodo_filtrado[df_periodo_filtrado['calificacion'] < 6].shape[0]} ({df_periodo_filtrado[df_periodo_filtrado['calificacion'] < 6].shape[0] / df_periodo_filtrado.shape[0] * 100:.2f}%)", border=True) + st.metric("Alumnos registrados en periodo", f"{alumnos_periodo:,}", border=True, help="Número de alumnos con calificaciones registradas en el periodo filtrado.") + + + st.space(SPACE_BETWEEN_SECTIONS) + col1, col2 = st.columns(2) + with col1: + st.subheader(f"Promedios del periodo") + st.bar_chart(data=df_prom, x="semestre", y="calificacion") + + st.subheader(f"Promedios del periodo agrupado por sexo") + df_sexo = ( + df_periodo_filtrado + .groupby(["semestre", "sexo"])["calificacion"] + .mean() + .reset_index() + ) + chart = ( + alt.Chart(df_sexo) + .mark_line(point=True) # 👈 activa puntos para ver cada valor + .encode( + x=alt.X("semestre:O", title="Semestre"), + y=alt.Y("calificacion:Q", title="Calificación promedio"), + color=alt.Color("sexo:N", title="Sexo"), + tooltip=["semestre", "sexo", "calificacion"] + ) + .properties(height=400) + ) + + st.altair_chart(chart, width='stretch') + + + with col2: + st.subheader(f"Frecuencia de calificaciones por materia") + df_materias = df_periodo_filtrado.groupby(["calificacion"]).count().reset_index() + df_materias = df_materias.sort_values(by="calificacion", ascending=False) + df_materias["total"] = df_materias["materia_clave"] + st.bar_chart(data=df_materias, x="calificacion", y="total") + + + min_alumnos_materia = st.slider("Mínimo de alumnos para análisis de reprobación", min_value=1, max_value=40, value=10, help="Número mínimo de alumnos inscritos en una materia para que sea considerada en el análisis de reprobación.") + st.subheader(f"Materias con mayor reprobación") + # Crear columna con el nombre corto (iniciales + carrera) + df_base_reprobacion = df_periodo_filtrado.copy() + df_base_reprobacion["materia_corta"] = ( + df_base_reprobacion["materia"].apply(_iniciales_materia) + + " [" + df_base_reprobacion["carrera_mat_prefijo"] + "]" + ) + + # Totales + df_reprobadas = df_base_reprobacion[df_base_reprobacion["calificacion"] < 6].groupby(["materia_corta", "materia"]).size().reset_index(name="total_reprobadas") + df_total = df_base_reprobacion.groupby(["materia_corta", "materia"]).size().reset_index(name="total_registradas") + # Unir ambas + df_merge = df_total.merge(df_reprobadas, on=["materia_corta", "materia"], how="left") + # Llenar NaN en materias sin reprobados + df_merge["total_reprobadas"] = df_merge["total_reprobadas"].fillna(0) + + # Crear porcentaje + df_merge["porcentaje_reprobacion"] = ( + df_merge["total_reprobadas"] / df_merge["total_registradas"] * 100 + ) + + df_merge = df_merge[(df_merge["total_registradas"] >= min_alumnos_materia) & (df_merge["porcentaje_reprobacion"] > 0) ] + + # Top 10 según % reprobación + df_merge = df_merge.sort_values("porcentaje_reprobacion", ascending=False).head( 10) + + chart = ( + alt.Chart(df_merge) + .mark_bar() + .encode( + y=alt.Y("materia_corta:N", sort='-x', title="Materia"), + x=alt.X("porcentaje_reprobacion:Q", title="Porcentaje de reprobación (%)"), + tooltip=[ + "materia:N", + "total_registradas:Q", + "total_reprobadas:Q", + alt.Tooltip("porcentaje_reprobacion:Q", format=".2f") + ] + ) + .properties(height=400) + ) + + st.altair_chart(chart, width='stretch') + + + + + + # with st.expander("Ver datos filtrados"): + # st.dataframe(df_prom) + else: + st.warning("Por favor selecciona al menos un tipo de calificación para mostrar los datos.") + +with tabAlumnos: + # csv_info = df_info_completa.to_csv(index=False).encode('utf-8') + # st.download_button( + # label="Descargar datos completos (CSV)", + # data=csv_info, + # file_name='info_completa.csv', + # mime='text/csv', + # icon="📥", + # ) + # cambiar beca a numérico + df_alumnos = df_info_completa.copy() + df_alumnos = df_alumnos.drop_duplicates(subset=["claveULSA"]) + total_alumnos = df_alumnos.shape[0] + df_alumnos["beca"] = (df_info_completa["beca"].replace("No tiene beca", "0")) + df_alumnos["beca"] = df_alumnos["beca"].fillna("0") + df_alumnos["beca"] = pd.to_numeric(df_alumnos["beca"].astype(str).str.replace("%", ""), errors="coerce").fillna(0) + + col1, col2, col3 = st.columns([1,2,2]) + with col1: + genero_list = df_alumnos["sexo"].unique().tolist() + genero = st.pills("Selecciona el género", genero_list, help="Género del alumno.", selection_mode="multi", default=genero_list) + with col2: + semestre_list = df_alumnos["semestre"].unique().tolist() + semestre_list.sort() + semestre = st.pills("Selecciona el semestre", semestre_list, help="Semestre actual del alumno.", selection_mode="multi", default=semestre_list) + with col3: + carrera_list = df_info_completa["carrera_prefijo"].unique().tolist() + carrera_list.sort() + carrera = st.pills("Selecciona la carrera", carrera_list, help="Carrera del alumno.", selection_mode="multi", default=carrera_list) + + + if len(genero) > 0 and len(semestre) > 0 and len(carrera) > 0: + + df_info_filtrado = df_alumnos.copy() + df_info_filtrado = df_info_filtrado[ + (df_info_filtrado["sexo"].isin(genero)) & + (df_info_filtrado["semestre"].isin(semestre)) & + (df_info_filtrado["carrera_prefijo"].isin(carrera)) + ] + servicio_social_count = df_info_filtrado[df_info_filtrado["servicio_social"] == True].shape[0] + servicio_social_perc = (servicio_social_count / df_info_filtrado.shape[0]) * 100 if df_info_filtrado.shape[0] > 0 else 0 + beca_perc = (df_info_filtrado['beca'] > 0).sum()/df_info_filtrado.shape[0]*100 if df_info_filtrado.shape[0] > 0 else 0 + promedio_alumnos = df_info_filtrado[df_info_filtrado["promedio"] > 0]["promedio"].mean() + + + st.space(SPACE_BETWEEN_SECTIONS) + row2 = st.container(horizontal=True) + with row2: + st.metric("Total de alumnos", f"{df_info_filtrado.shape[0]:,} ({df_info_filtrado.shape[0]/total_alumnos*100:.2f}%)", border=True, help="Número total y porcentaje de alumnos filtrados respecto al total de alumnos.") + st.metric("Alumnos con beca", f"{(df_info_filtrado['beca'] > 0).sum():,} ({beca_perc:.2f}%)", border=True, help="Número y porcentaje de alumnos que cuentan con algún tipo de beca.") + st.metric("Creditos promedio", f"{df_info_filtrado['creditos_totales'].mean():.2f}", border=True, help="Pomedio de créditos totales de formación") + st.metric("Alumnos con servicio social", f"{servicio_social_count:,} ({servicio_social_perc:.2f}%)", border=True) + st.metric("Promedio general", f"{promedio_alumnos:.2f}", border=True, help="Promedio general de los alumnos filtrados. No cuenta de nuevo ingreso") + + st.space(SPACE_BETWEEN_SECTIONS) + + col1, col2 = st.columns(2) + with col1: + st.subheader("Distribución de becas") + beca_counts = df_info_filtrado["beca"].value_counts().reset_index() + beca_counts.columns = ["Beca (%)", "Total"] + st.bar_chart(data=beca_counts, x="Beca (%)", y="Total") + with col2: + st.subheader("Promedio de alumnos por carrera") + carrera_prom = df_info_filtrado[df_info_filtrado["promedio"] > 0] + carrera_prom = carrera_prom.groupby("carrera_prefijo").mean({"promedio": "mean"}).reset_index() + st.bar_chart(data=carrera_prom, x="carrera_prefijo", y="promedio") + + # st.write(f"Total de alumnos filtrados: {df_info_filtrado.shape[0]}") + #alumnos por carrera + else: + st.warning("Por favor selecciona al menos un valor en cada filtro para mostrar los datos.") + + +with tabProfesores: + df_prof = df_info_profesores.copy() + + col1, col2 = st.columns(2) + with col1: + # periodos = st.pills("Selecciona un periodo", periodos_list, help="Los periodos 1 son de Ago-Dic, los 2 de Ene-Jun,los I de Invierno y los V de Verano.", default=periodos_list[-1]) + periodos_prof = st.select_slider("Selecciona un periodo", periodos_list, help="Los periodos 1 son de Ago-Dic, los 2 de Ene-Jun,los I de Invierno y los V de Verano.", value=periodos_list[-3], key="periodo_prof") + with col2: + carrera_prof = st.pills("Selecciona la carrera", carrera_mat_list, help="Carrera en la que imparte el profesor.", selection_mode="multi", default=carrera_mat_list, key="carrera_prof") + + df_prof_periodo = df_prof[(df_prof["periodo"] == periodos_prof) & (df_prof["carrera_prefijo"].isin(carrera_prof))] + + st.subheader("Buscar profesor") + col1, col2, col3 = st.columns([1,1,2]) + with col1: + profesor_buscar = st.selectbox("Selecciona un profesor", df_prof_periodo["profesor_nombre"].unique().tolist(), key="profesor_buscar") + + with col2: + # 1. Calcular el promedio por profesor + df_rank = (df_prof_periodo.groupby("profesor_nombre", as_index=False).agg(promedio=("promedio", "mean"))) + + # 2. Ordenar por promedio (de mayor a menor) y resetear índice + df_rank = df_rank.sort_values(by="promedio",ascending=False).reset_index(drop=True) + + # 3. Agregar ranking comenzando en 1 + df_rank["ranking"] = df_rank.index + 1 + + # 4. Filtrar el profesor seleccionado + fila_prof = df_rank[df_rank["profesor_nombre"] == profesor_buscar] + + if fila_prof.empty: + st.warning("El profesor seleccionado no tiene registros en este periodo.") + else: + ranking_profesor = int(fila_prof["ranking"].iloc[0]) + promedio_profesor = float(fila_prof["promedio"].iloc[0]) + st.metric("Promedio del profesor", f"{promedio_profesor:.2f}", border=True, delta=f"{ranking_profesor}/{df_rank.shape[0]}", help="Promedio de calificaciones del profesor en el periodo seleccionado.") + + with col3: + st.info("No se consideran calificaciones de 0 (SD, NP) en los promedios de los profesores.") + + + + col1, col2 = st.columns(2) + with col1: + st.subheader("Promedios más altos por profesor") + #df_prof_periodo_top = df_prof_periodo.groupby("profesor_nombre").mean({"promedio": "mean"}).reset_index() + df_prof_periodo_top = df_prof_periodo.groupby("profesor_nombre").agg( + promedio=("promedio", "mean"), + total_alumnos=("total_alumnos", "sum") # O "count" si viene repetido por alumno + ).reset_index() + df_prof_periodo_top = df_prof_periodo_top.sort_values(by="promedio", ascending=False).head(10) + chart = ( + alt.Chart(df_prof_periodo_top) + .mark_bar() + .encode( + y=alt.Y("profesor_nombre:N", sort='-x', title="Profesores", axis=alt.Axis(labelLimit=300)), + x=alt.X("promedio:Q", title="Promedio de calificaciones"), + tooltip=[ + "profesor_nombre:N", + "promedio:Q", + "total_alumnos:Q" + ] + ) + .properties(height=400) + ) + + st.altair_chart(chart, width='stretch') + + with col2: + st.subheader("Promedios más bajos por profesor") + #df_prof_periodo_top = df_prof_periodo.groupby("profesor_nombre").mean({"promedio": "mean"}).reset_index() + df_prof_periodo_top = df_prof_periodo.groupby("profesor_nombre").agg( + promedio=("promedio", "mean"), + total_alumnos=("total_alumnos", "sum") # O "count" si viene repetido por alumno + ).reset_index() + + df_prof_periodo_top = df_prof_periodo_top.sort_values(by="promedio", ascending=True).head(10) + chart = ( + alt.Chart(df_prof_periodo_top) + .mark_bar() + .encode( + y=alt.Y("profesor_nombre:N", sort='x', title="Profesores", axis=alt.Axis(labelLimit=300)), + x=alt.X("promedio:Q", title="Promedio de calificaciones"), + tooltip=[ + "profesor_nombre:N", + "promedio:Q", + "total_alumnos:Q" + ] + ) + .properties(height=400) + ) + + st.altair_chart(chart, width='stretch') diff --git a/assets/logo_lasalle.png b/assets/logo_lasalle.png new file mode 100644 index 0000000..02fc608 Binary files /dev/null and b/assets/logo_lasalle.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8c8df25 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +streamlit +pandas +numpy +matplotlib +seaborn +sqlalchemy +psycopg2-binary \ No newline at end of file