O Projeto 4 é um dashboard analítico completo — uma aplicação web interativa que consome dados reais de um banco PostgreSQL, processa com pandas e exibe visualizações dinâmicas com Plotly Dash. O sistema monitora o desempenho acadêmico de uma escola em tempo real: médias por turma, evolução ao longo do tempo, distribuição de notas, ranking de alunos e alertas automáticos. É o tipo de ferramenta que diretores e coordenadores usam no dia a dia.
Visão Geral do Projeto
escola-dashboard/
├── app/
│ ├── __init__.py
│ ├── main.py ← entry point Dash
│ ├── config.py
│ ├── database.py ← SQLAlchemy e modelos
│ ├── dados/
│ │ ├── __init__.py
│ │ └── queries.py ← consultas SQL
│ ├── componentes/
│ │ ├── __init__.py
│ │ ├── cartoes.py ← KPI cards
│ │ └── graficos.py ← figuras Plotly
│ └── layout/
│ ├── __init__.py
│ └── paginas/
│ ├── __init__.py
│ ├── visao_geral.py
│ └── alertas.py
├── scripts/
│ └── popular_banco.py ← dados de demonstração
├── Dockerfile
├── docker-compose.yml
└── requirements.txt
Pacotes do requirements.txt: dash, dash-bootstrap-components, pandas, plotly, sqlalchemy, psycopg[binary], pydantic-settings, numpy e gunicorn. O código foi conferido com Dash 4.4, SQLAlchemy 2.1 e PostgreSQL 18.
Banco de Dados e Queries
# app/config.py
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Configuracoes(BaseSettings):
database_url: str # ex.: postgresql+psycopg://escola:senha@db:5432/escola
model_config = SettingsConfigDict(env_file=".env")
@lru_cache
def get_config() -> Configuracoes:
return Configuracoes()
# app/database.py
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.config import get_config
import pandas as pd
config = get_config()
engine = create_engine(config.database_url, pool_pre_ping=True, echo=False)
Session = sessionmaker(bind=engine)
class Base(DeclarativeBase):
pass
class Turma(Base):
__tablename__ = "turmas"
id = Column(Integer, primary_key=True)
nome = Column(String(50), unique=True)
turno = Column(String(20))
ano = Column(Integer)
alunos = relationship("Aluno", back_populates="turma")
class Aluno(Base):
__tablename__ = "alunos"
id = Column(Integer, primary_key=True)
nome = Column(String(100))
email = Column(String(100), unique=True)
turma_id = Column(Integer, ForeignKey("turmas.id"))
ativo = Column(Boolean, default=True)
criado_em = Column(DateTime, server_default=func.now())
turma = relationship("Turma", back_populates="alunos")
notas = relationship("Nota", back_populates="aluno")
class Disciplina(Base):
__tablename__ = "disciplinas"
id = Column(Integer, primary_key=True)
nome = Column(String(80))
notas = relationship("Nota", back_populates="disciplina")
class Nota(Base):
__tablename__ = "notas"
id = Column(Integer, primary_key=True)
aluno_id = Column(Integer, ForeignKey("alunos.id"))
disciplina_id = Column(Integer, ForeignKey("disciplinas.id"))
valor = Column(Float)
bimestre = Column(Integer)
ano = Column(Integer)
criado_em = Column(DateTime, server_default=func.now())
aluno = relationship("Aluno", back_populates="notas")
disciplina = relationship("Disciplina", back_populates="notas")
# app/dados/queries.py
import pandas as pd
from sqlalchemy import text
from app.database import engine
def media_por_turma(ano: int = 2024) -> pd.DataFrame:
sql = text("""
SELECT
t.id AS turma_id,
t.nome AS turma,
t.turno,
COUNT(DISTINCT a.id) AS n_alunos,
ROUND(AVG(n.valor)::numeric, 2) AS media,
ROUND(MIN(n.valor)::numeric, 2) AS menor,
ROUND(MAX(n.valor)::numeric, 2) AS maior,
ROUND(STDDEV(n.valor)::numeric, 2) AS desvio,
ROUND(
SUM(CASE WHEN n.valor >= 6 THEN 1 ELSE 0 END) * 100.0
/ COUNT(n.id), 1
) AS pct_aprovados
FROM turmas t
JOIN alunos a ON a.turma_id = t.id
JOIN notas n ON n.aluno_id = a.id
WHERE n.ano = :ano AND a.ativo = true
GROUP BY t.id, t.nome, t.turno
ORDER BY media DESC
""")
return pd.read_sql(sql, engine, params={"ano": ano})
def evolucao_bimestral(turma_id: int = None, ano: int = 2024) -> pd.DataFrame:
sql = text("""
SELECT
n.bimestre,
t.nome AS turma,
d.nome AS disciplina,
ROUND(AVG(n.valor)::numeric, 2) AS media
FROM notas n
JOIN alunos a ON a.id = n.aluno_id
JOIN turmas t ON t.id = a.turma_id
JOIN disciplinas d ON d.id = n.disciplina_id
WHERE n.ano = :ano
-- o CAST diz ao PostgreSQL o tipo do parâmetro quando ele vem None
AND (CAST(:turma_id AS integer) IS NULL
OR t.id = CAST(:turma_id AS integer))
AND a.ativo = true
GROUP BY n.bimestre, t.nome, d.nome
ORDER BY n.bimestre, t.nome
""")
return pd.read_sql(sql, engine, params={"ano": ano, "turma_id": turma_id})
def distribuicao_notas(turma_id: int = None, ano: int = 2024) -> pd.DataFrame:
sql = text("""
SELECT
a.nome AS aluno,
t.nome AS turma,
d.nome AS disciplina,
n.valor AS nota,
n.bimestre,
CASE
WHEN n.valor >= 9 THEN 'Excelente'
WHEN n.valor >= 7 THEN 'Bom'
WHEN n.valor >= 6 THEN 'Regular'
WHEN n.valor >= 5 THEN 'Recuperação'
ELSE 'Reprovado'
END AS conceito
FROM notas n
JOIN alunos a ON a.id = n.aluno_id
JOIN turmas t ON t.id = a.turma_id
JOIN disciplinas d ON d.id = n.disciplina_id
WHERE n.ano = :ano
-- o CAST diz ao PostgreSQL o tipo do parâmetro quando ele vem None
AND (CAST(:turma_id AS integer) IS NULL
OR t.id = CAST(:turma_id AS integer))
AND a.ativo = true
ORDER BY a.nome, d.nome, n.bimestre
""")
return pd.read_sql(sql, engine, params={"ano": ano, "turma_id": turma_id})
def ranking_alunos(turma_id: int = None, ano: int = 2024, limite: int = 20) -> pd.DataFrame:
sql = text("""
SELECT
a.id,
a.nome AS aluno,
t.nome AS turma,
ROUND(AVG(n.valor)::numeric, 2) AS media_geral,
COUNT(DISTINCT n.disciplina_id) AS disciplinas,
SUM(CASE WHEN n.valor >= 6 THEN 1 ELSE 0 END) AS aprovacoes,
SUM(CASE WHEN n.valor < 6 THEN 1 ELSE 0 END) AS reprovacoes,
RANK() OVER (ORDER BY AVG(n.valor) DESC) AS posicao
FROM alunos a
JOIN turmas t ON t.id = a.turma_id
JOIN notas n ON n.aluno_id = a.id
WHERE n.ano = :ano
-- o CAST diz ao PostgreSQL o tipo do parâmetro quando ele vem None
AND (CAST(:turma_id AS integer) IS NULL
OR t.id = CAST(:turma_id AS integer))
AND a.ativo = true
GROUP BY a.id, a.nome, t.nome
ORDER BY media_geral DESC
LIMIT :limite
""")
return pd.read_sql(sql, engine, params={
"ano": ano, "turma_id": turma_id, "limite": limite
})
def alertas_risco(ano: int = 2024) -> pd.DataFrame:
sql = text("""
WITH medias AS (
SELECT
a.id,
a.nome AS aluno,
t.nome AS turma,
t.id AS turma_id,
AVG(n.valor) AS media_geral,
MIN(n.valor) AS pior_nota,
SUM(CASE WHEN n.valor < 6 THEN 1 ELSE 0 END) AS n_reprovacoes
FROM alunos a
JOIN turmas t ON t.id = a.turma_id
JOIN notas n ON n.aluno_id = a.id
WHERE n.ano = :ano AND a.ativo = true
GROUP BY a.id, a.nome, t.nome, t.id
)
SELECT
*,
CASE
WHEN media_geral < 4 THEN 'CRÍTICO'
WHEN media_geral < 5 THEN 'ALTO'
WHEN media_geral < 6 THEN 'MODERADO'
ELSE 'BAIXO'
END AS nivel_risco
FROM medias
WHERE media_geral < 6
ORDER BY media_geral ASC
""")
return pd.read_sql(sql, engine, params={"ano": ano})
def kpis_gerais(ano: int = 2024) -> dict:
sql = text("""
SELECT
COUNT(DISTINCT a.id) AS total_alunos,
COUNT(DISTINCT t.id) AS total_turmas,
ROUND(AVG(n.valor)::numeric, 2) AS media_geral,
ROUND(
SUM(CASE WHEN n.valor >= 6 THEN 1 ELSE 0 END) * 100.0
/ COUNT(n.id), 1
) AS pct_aprovados,
-- uma linha por NOTA: sem o DISTINCT, cada aluno contaria 20 vezes
COUNT(DISTINCT CASE WHEN sub.media < 6 THEN a.id END) AS alunos_risco
FROM notas n
JOIN alunos a ON a.id = n.aluno_id
JOIN turmas t ON t.id = a.turma_id
LEFT JOIN (
SELECT aluno_id, AVG(valor) AS media
FROM notas WHERE ano = :ano
GROUP BY aluno_id
) sub ON sub.aluno_id = a.id
WHERE n.ano = :ano AND a.ativo = true
""")
with engine.connect() as conn:
row = conn.execute(sql, {"ano": ano}).fetchone()
return dict(row._mapping) if row else {}
As consultas foram rodadas contra um PostgreSQL 18, e três defeitos só apareceram ali. O primeiro derrubava quase tudo: a coluna valor é Float, que vira double precision no PostgreSQL, e a função round(double precision, integer) não existe — o arredondamento com casas decimais só aceita numeric. Quatro das seis funções falhavam com function round(double precision, integer) does not exist; o ::numeric resolve. O segundo quebrava justamente o filtro padrão, "todas as turmas": com turma_id = None, o psycopg 3 envia o parâmetro sem tipo, e o PostgreSQL responde could not determine data type of parameter; o CAST(:turma_id AS integer) informa o tipo. O terceiro não dava erro nenhum: o KPI de alunos em risco somava uma linha por nota, e com 20 notas por aluno, marcava 20 onde havia 1 — medido. O COUNT(DISTINCT ...) conta alunos. Vale notar que a "taxa de aprovação" continua sendo a proporção de notas acima de 6, não de alunos aprovados; um painel honesto diz qual das duas mostra.
Componentes Visuais
# app/componentes/graficos.py
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import pandas as pd
CORES = {
"primaria": "#1F4E79",
"secundaria": "#2E75B6",
"sucesso": "#375623",
"aviso": "#F5A623",
"perigo": "#C00000",
"neutro": "#888888",
}
TEMPLATE = "plotly_white"
def grafico_media_turmas(df: pd.DataFrame) -> go.Figure:
if df.empty:
return go.Figure()
cores = [
CORES["sucesso"] if m >= 7
else CORES["aviso"] if m >= 6
else CORES["perigo"]
for m in df["media"]
]
fig = go.Figure()
fig.add_trace(go.Bar(
x=df["turma"], y=df["media"],
marker_color=cores,
text=df["media"].round(2),
textposition="outside",
hovertemplate=(
"<b>%{x}</b><br>"
"Média: %{y:.2f}<br>"
"Aprovados: %{customdata[0]:.1f}%<br>"
"Alunos: %{customdata[1]}<br>"
"<extra></extra>"
),
customdata=df[["pct_aprovados", "n_alunos"]].values
))
fig.add_hline(
y=6, line_dash="dash",
line_color=CORES["perigo"],
annotation_text="Mínimo (6.0)",
annotation_position="right"
)
fig.add_hline(
y=df["media"].mean(),
line_dash="dot",
line_color=CORES["neutro"],
annotation_text=f"Média geral ({df['media'].mean():.2f})",
annotation_position="left"
)
fig.update_layout(
title="Média por Turma",
yaxis=dict(range=[0, 11], title="Média"),
xaxis_title="Turma",
template=TEMPLATE,
showlegend=False,
height=380
)
return fig
def grafico_evolucao_bimestral(df: pd.DataFrame, turma: str = None) -> go.Figure:
if df.empty:
return go.Figure()
if turma:
df = df[df["turma"] == turma]
fig = px.line(
df,
x="bimestre", y="media",
color="disciplina",
markers=True,
title=f"Evolução Bimestral{f' — {turma}' if turma else ''}",
labels={"bimestre": "Bimestre", "media": "Média", "disciplina": "Disciplina"},
template=TEMPLATE,
height=380
)
fig.add_hline(y=6, line_dash="dash", line_color=CORES["perigo"], opacity=0.5)
fig.update_xaxes(tickmode="linear", tick0=1, dtick=1)
return fig
def grafico_distribuicao(df: pd.DataFrame) -> go.Figure:
if df.empty:
return go.Figure()
ordem = ["Reprovado", "Recuperação", "Regular", "Bom", "Excelente"]
contagem = df["conceito"].value_counts().reindex(ordem, fill_value=0)
cores = [
CORES["perigo"], "#FF6B35",
CORES["aviso"], "#5B9BD5", CORES["sucesso"]
]
fig = go.Figure(go.Bar(
x=contagem.index, y=contagem.values,
marker_color=cores,
text=contagem.values,
textposition="outside",
hovertemplate="<b>%{x}</b><br>Quantidade: %{y}<extra></extra>"
))
fig.update_layout(
title="Distribuição por Conceito",
xaxis_title="Conceito",
yaxis_title="Quantidade",
template=TEMPLATE,
showlegend=False,
height=350
)
return fig
def grafico_heatmap_disciplinas(df: pd.DataFrame) -> go.Figure:
if df.empty:
return go.Figure()
pivot = df.pivot_table(
values="nota",
index="disciplina",
columns="bimestre",
aggfunc="mean"
).round(2)
fig = go.Figure(go.Heatmap(
z=pivot.values,
x=[f"Bimestre {b}" for b in pivot.columns],
y=pivot.index,
colorscale=[
[0.0, CORES["perigo"]],
[0.5, CORES["aviso"]],
[0.7, "#5B9BD5"],
[1.0, CORES["sucesso"]]
],
zmid=6,
text=pivot.values,
texttemplate="%{text:.1f}",
hovertemplate=(
"<b>%{y}</b><br>"
"%{x}<br>"
"Média: %{z:.2f}<extra></extra>"
)
))
fig.update_layout(
title="Média por Disciplina e Bimestre",
template=TEMPLATE,
height=400
)
return fig
def grafico_radar_turma(df_turmas: pd.DataFrame) -> go.Figure:
"""Gráfico radar comparando turmas em múltiplos indicadores."""
if df_turmas.empty:
return go.Figure()
categorias = ["média", "% aprovados", "consistência"]
fig = go.Figure()
for _, row in df_turmas.iterrows():
consistencia = max(0, 10 - (row.get("desvio", 2) * 2))
valores = [
row["media"],
row["pct_aprovados"] / 10,
consistencia
]
fig.add_trace(go.Scatterpolar(
r=valores + [valores[0]],
theta=categorias + [categorias[0]],
fill="toself",
name=row["turma"],
opacity=0.7
))
fig.update_layout(
polar=dict(radialaxis=dict(visible=True, range=[0, 10])),
title="Comparativo de Turmas",
template=TEMPLATE,
height=380
)
return fig
# app/componentes/cartoes.py
from dash import html
import dash_bootstrap_components as dbc
def cartao_kpi(
titulo: str,
valor: str,
subtitulo: str = "",
cor: str = "primary",
icone: str = "📊"
) -> dbc.Card:
cores_map = {
"primary": "#1F4E79",
"success": "#375623",
"warning": "#F5A623",
"danger": "#C00000",
}
cor_hex = cores_map.get(cor, "#1F4E79")
return dbc.Card([
dbc.CardBody([
html.Div([
html.Span(icone, style={"fontSize": "2rem"}),
html.Div([
html.H4(valor, className="mb-0",
style={"color": cor_hex, "fontWeight": "bold"}),
html.P(titulo, className="mb-0 text-muted",
style={"fontSize": "0.85rem"}),
html.Small(subtitulo, className="text-muted")
if subtitulo else html.Span()
])
], style={"display": "flex", "gap": "1rem", "alignItems": "center"})
])
], style={
"borderLeft": f"4px solid {cor_hex}",
"borderRadius": "8px",
"boxShadow": "0 2px 8px rgba(0,0,0,0.08)"
})
Layout do Dashboard
# app/layout/paginas/visao_geral.py
from dash import html, dcc
import dash_bootstrap_components as dbc
from app.dados.queries import kpis_gerais, media_por_turma, distribuicao_notas
from app.componentes.cartoes import cartao_kpi
from app.componentes.graficos import (
grafico_media_turmas, grafico_distribuicao, grafico_radar_turma
)
def layout_visao_geral(ano: int = 2024, turma_id: int | None = None):
kpis = kpis_gerais(ano)
turmas = media_por_turma(ano)
notas = distribuicao_notas(turma_id=turma_id, ano=ano)
return html.Div([
# ── Cabeçalho ──────────────────────────────
html.Div([
html.H2("📊 Visão Geral", className="mb-1"),
html.P(f"Ano letivo {ano}", className="text-muted")
], className="mb-4"),
# ── KPIs ───────────────────────────────────
dbc.Row([
dbc.Col(cartao_kpi(
"Total de Alunos",
str(kpis.get("total_alunos", 0)),
icone="👥", cor="primary"
), md=3),
dbc.Col(cartao_kpi(
"Média Geral",
f"{kpis.get('media_geral', 0):.2f}",
icone="📈",
cor="success" if kpis.get("media_geral", 0) >= 7 else "warning"
), md=3),
dbc.Col(cartao_kpi(
"Taxa de Aprovação",
f"{kpis.get('pct_aprovados', 0):.1f}%",
icone="✅",
cor="success" if kpis.get("pct_aprovados", 0) >= 80 else "warning"
), md=3),
dbc.Col(cartao_kpi(
"Alunos em Risco",
str(kpis.get("alunos_risco", 0)),
icone="⚠️", cor="danger"
), md=3),
], className="mb-4 g-3"),
# ── Gráficos ───────────────────────────────
dbc.Row([
dbc.Col(dcc.Graph(
id="grafico-turmas",
figure=grafico_media_turmas(turmas),
config={"displayModeBar": False}
), md=8),
dbc.Col(dcc.Graph(
id="grafico-radar",
figure=grafico_radar_turma(turmas),
config={"displayModeBar": False}
), md=4),
], className="mb-4"),
dbc.Row([
dbc.Col(dcc.Graph(
id="grafico-distribuicao",
figure=grafico_distribuicao(notas),
config={"displayModeBar": False}
), md=12),
]),
], className="p-4")
# app/layout/paginas/alertas.py
from dash import html, dash_table
import dash_bootstrap_components as dbc
from app.dados.queries import alertas_risco
import pandas as pd
def layout_alertas(ano: int = 2024):
df = alertas_risco(ano)
cores_risco = {
"CRÍTICO": "#C00000",
"ALTO": "#FF6B35",
"MODERADO": "#F5A623",
"BAIXO": "#5B9BD5"
}
return html.Div([
html.Div([
html.H2("⚠️ Alertas — Alunos em Risco", className="mb-1"),
html.P(f"{len(df)} aluno(s) necessitam atenção", className="text-muted")
], className="mb-4"),
# Resumo por nível
dbc.Row([
dbc.Col([
dbc.Badge(
f"🔴 Crítico: {len(df[df['nivel_risco']=='CRÍTICO'])}",
color="danger", className="me-2 p-2 fs-6"
),
dbc.Badge(
f"🟠 Alto: {len(df[df['nivel_risco']=='ALTO'])}",
color="warning", className="me-2 p-2 fs-6"
),
dbc.Badge(
f"🟡 Moderado: {len(df[df['nivel_risco']=='MODERADO'])}",
color="info", className="p-2 fs-6"
),
], className="mb-4")
]),
# Tabela interativa
dash_table.DataTable(
id="tabela-alertas",
data=df.to_dict("records"),
columns=[
{"name": "Aluno", "id": "aluno"},
{"name": "Turma", "id": "turma"},
{"name": "Média", "id": "media_geral", "type": "numeric",
"format": {"specifier": ".2f"}},
{"name": "Pior Nota", "id": "pior_nota", "type": "numeric",
"format": {"specifier": ".2f"}},
{"name": "Reprovações", "id": "n_reprovacoes", "type": "numeric"},
{"name": "Risco", "id": "nivel_risco"},
],
style_table={"overflowX": "auto"},
style_cell={
"fontFamily": "Arial, sans-serif",
"fontSize": "0.9rem",
"padding": "10px"
},
style_header={
"backgroundColor": "#1F4E79",
"color": "white",
"fontWeight": "bold"
},
style_data_conditional=[
{
"if": {"filter_query": '{nivel_risco} = "CRÍTICO"'},
"backgroundColor": "#FCE4D6",
"color": "#C00000"
},
{
"if": {"filter_query": '{nivel_risco} = "ALTO"'},
"backgroundColor": "#FFF2CC",
},
{
"if": {"row_index": "odd"},
"backgroundColor": "#F9F9F9"
}
],
sort_action="native",
filter_action="native",
page_size=15,
export_format="csv",
)
], className="p-4")
Aplicação Principal
# app/main.py
import dash
from dash import html, dcc, Input, Output, State
import dash_bootstrap_components as dbc
from app.dados.queries import media_por_turma
from app.componentes.graficos import grafico_media_turmas, grafico_evolucao_bimestral
from app.layout.paginas.visao_geral import layout_visao_geral
from app.layout.paginas.alertas import layout_alertas
app = dash.Dash(
__name__,
external_stylesheets=[
dbc.themes.BOOTSTRAP,
"https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
],
suppress_callback_exceptions=True,
title="Escola — Dashboard Analítico",
update_title=None
)
server = app.server # o Flask por baixo — é o que o gunicorn serve
# ── Sidebar ──────────────────────────────────────────
sidebar = html.Div([
html.Div([
html.H4("🎓 Escola", style={"color": "white", "margin": "0"}),
html.Small("Dashboard Analítico",
style={"color": "rgba(255,255,255,0.7)"})
], style={"padding": "1.5rem 1rem", "borderBottom": "1px solid rgba(255,255,255,0.1)"}),
html.Nav([
dbc.Nav([
dbc.NavLink([
html.Span("📊", className="me-2"), "Visão Geral"
], href="/", active="exact", className="nav-item-custom"),
dbc.NavLink([
html.Span("⚠️", className="me-2"), "Alertas"
], href="/alertas", active="exact", className="nav-item-custom"),
], vertical=True)
], style={"padding": "1rem 0"}),
# Filtros globais
html.Div([
html.Label("Ano Letivo", style={"color": "rgba(255,255,255,0.8)",
"fontSize": "0.8rem"}),
dcc.Dropdown(
id="filtro-ano",
options=[{"label": str(a), "value": a} for a in [2022, 2023, 2024]],
value=2024,
clearable=False,
style={"fontSize": "0.9rem"}
),
html.Label("Turma", style={"color": "rgba(255,255,255,0.8)",
"fontSize": "0.8rem", "marginTop": "1rem"}),
dcc.Dropdown(
id="filtro-turma",
options=[{"label": "Todas", "value": "todas"}],
value="todas",
clearable=False,
style={"fontSize": "0.9rem"}
)
], style={"padding": "1rem", "borderTop": "1px solid rgba(255,255,255,0.1)",
"marginTop": "auto"}),
], style={
"width": "240px",
"minHeight": "100vh",
"background": "linear-gradient(180deg, #1F4E79 0%, #2E75B6 100%)",
"position": "fixed",
"top": 0,
"left": 0,
"display": "flex",
"flexDirection": "column"
})
# ── Header ────────────────────────────────────────────
header = html.Div([
html.Div(id="titulo-pagina",
style={"fontWeight": "600", "fontSize": "1.1rem"}),
html.Div([
dbc.Badge(id="badge-ano", color="primary", className="me-2"),
html.Span(id="ultima-atualizacao",
style={"fontSize": "0.8rem", "color": "#888"})
], style={"display": "flex", "alignItems": "center"})
], style={
"background": "white",
"padding": "1rem 2rem",
"borderBottom": "1px solid #eee",
"display": "flex",
"justifyContent": "space-between",
"alignItems": "center",
"boxShadow": "0 2px 4px rgba(0,0,0,0.05)"
})
# ── Layout Principal ──────────────────────────────────
app.layout = html.Div([
dcc.Location(id="url", refresh=False),
dcc.Interval(id="intervalo-refresh", interval=5 * 60 * 1000), # 5 min
sidebar,
html.Div([
header,
html.Div(id="conteudo-pagina",
style={"background": "#F4F7FB", "minHeight": "calc(100vh - 60px)"})
], style={"marginLeft": "240px"})
])
# ── Callbacks ─────────────────────────────────────────
@app.callback(
Output("conteudo-pagina", "children"),
Output("titulo-pagina", "children"),
Output("badge-ano", "children"),
Input("url", "pathname"),
Input("filtro-ano", "value"),
Input("filtro-turma","value"),
)
def renderizar_pagina(pathname, ano, turma_id):
turma = None if turma_id == "todas" else int(turma_id) if turma_id else None
badge = f"Ano {ano}"
rotas = {
"/": ("Visão Geral", lambda: layout_visao_geral(ano, turma)),
"/alertas": ("Alertas de Risco", lambda: layout_alertas(ano)),
}
# só a página pedida consulta o banco
titulo, montar = rotas.get(pathname, ("Página não encontrada", lambda: html.Div("404")))
conteudo = montar()
return conteudo, titulo, badge
@app.callback(
Output("filtro-turma", "options"),
Input("filtro-ano", "value")
)
def atualizar_opcoes_turma(ano):
df = media_por_turma(ano)
opcoes = [{"label": "Todas", "value": "todas"}]
# o id de verdade da turma, não a posição na lista (que segue a média)
opcoes += [{"label": row["turma"], "value": str(row["turma_id"])}
for _, row in df.iterrows()]
return opcoes
@app.callback(
Output("ultima-atualizacao", "children"),
Input("intervalo-refresh", "n_intervals")
)
def atualizar_timestamp(n):
from datetime import datetime
return f"Atualizado: {datetime.now().strftime('%H:%M:%S')}"
Três correções nesse arquivo. A linha server = app.server expõe o Flask que o Dash usa por baixo, e é o que o gunicorn app.main:server do Dockerfile procura; sem ela, o contêiner não subia. O filtro de turma usava como valor a posição na lista, str(i + 1), e a lista vem ordenada pela média: a turma de melhor média virava "1", fosse qual fosse o id dela. No banco de teste, as turmas tinham ids de 9 a 12, e o filtro apontava para turmas que não existiam. Agora a consulta devolve o id, e o filtro chega de fato à distribuição de notas da visão geral — antes, ele era lido e descartado. Por fim, o dicionário de rotas montava as duas páginas a cada clique, consultando o banco para a que não seria mostrada; com lambda, só a página pedida é montada. As páginas de turmas e de alunos ficam como exercício, e os links para elas saíram do menu até existirem.
Script de Dados de Demonstração
# scripts/popular_banco.py
import numpy as np
from sqlalchemy.orm import Session
from app.database import engine, Base, Turma, Aluno, Disciplina, Nota
rng = np.random.default_rng(42)
Base.metadata.create_all(engine)
with Session(engine) as session:
# Turmas
turmas = [
Turma(nome="9A", turno="manha", ano=2024),
Turma(nome="9B", turno="tarde", ano=2024),
Turma(nome="8A", turno="manha", ano=2024),
Turma(nome="8B", turno="noite", ano=2024),
]
session.add_all(turmas)
session.flush()
# Disciplinas
disciplinas = [
Disciplina(nome="Matemática"),
Disciplina(nome="Português"),
Disciplina(nome="Ciências"),
Disciplina(nome="História"),
Disciplina(nome="Geografia"),
]
session.add_all(disciplinas)
session.flush()
# Alunos e notas
nomes = [
"Ana Silva", "Bruno Costa", "Carla Souza", "Diego Lima",
"Elena Pires", "Fábio Santos", "Gabriela Rocha", "Hugo Alves",
"Isabela Ferreira", "João Pedro", "Karen Oliveira", "Lucas Mendes",
]
for i, nome in enumerate(nomes):
turma = turmas[i % len(turmas)]
aluno = Aluno(
nome=nome,
email=f"{nome.split()[0].lower()}{i}@escola.com",
turma_id=turma.id
)
session.add(aluno)
session.flush()
# Perfil de desempenho variado
media_base = rng.normal(6.5 + (i % 4) * 0.5, 1.0)
for disc in disciplinas:
for bimestre in [1, 2, 3, 4]:
nota_val = float(np.clip(
rng.normal(media_base, 0.8), 0, 10
))
session.add(Nota(
aluno_id=aluno.id,
disciplina_id=disc.id,
valor=round(nota_val, 1),
bimestre=bimestre,
ano=2024
))
session.commit()
print("Banco populado com sucesso!")
A primeira versão do script não rodava. Ela importava o passlib, que com o bcrypt 5.0 falha em qualquer hash, e mesmo sem isso criava cada aluno com senha_hash, um campo que o modelo do dashboard não tem: TypeError: 'senha_hash' is an invalid keyword argument for Aluno. Um painel de leitura não precisa de senhas, e as duas linhas saíram. Um efeito colateral das tentativas que falharam ensina uma coisa sobre o PostgreSQL: as turmas acabaram com ids de 9 a 12, porque um ROLLBACK não devolve os números já tirados de uma sequência. Id é identificador, não posição, e código que supõe ids 1, 2, 3 quebra no primeiro banco real.
Docker
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
COPY scripts/ ./scripts/
COPY assets/ ./assets/
EXPOSE 8050
CMD ["gunicorn", "app.main:server", \
"--bind", "0.0.0.0:8050", \
"--workers", "2", "--timeout", "120"]
# docker-compose.yml
services:
dashboard:
build: .
ports:
- "8050:8050"
environment:
- DATABASE_URL=postgresql+psycopg://escola:${POSTGRES_PASSWORD}@db:5432/escola
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=escola
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD} # do .env
- POSTGRES_DB=escola
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U escola"]
interval: 10s
retries: 5
volumes:
pg_data:
O dashboard junta SQL analítico, pandas e Plotly numa aplicação Dash: consultas agregadas no PostgreSQL alimentam cartões de indicadores, gráficos de barras, radar, heatmap e distribuição, e uma tabela de alertas com filtro e exportação. Os callbacks ligam a URL e os filtros ao conteúdo, e o gunicorn serve tudo em produção. Os gráficos são os de Análise de Dados com Pandas Avançado e Plotly, e a infraestrutura de banco e Docker é a mesma de Projeto 1: API REST Completa com FastAPI, PostgreSQL e Docker.
O que este projeto mostrou é que SQL escrito sem rodar no banco de destino é SQL não testado. Quatro das seis consultas falhavam no PostgreSQL por causa de um ROUND sobre ponto flutuante, o filtro padrão quebrava pela falta de tipo de um parâmetro nulo, e o indicador de risco multiplicava cada aluno pelo número de notas. Em painéis, o erro que não dá exceção é o mais caro: um número errado exibido com confiança para quem toma decisões. Conferir cada indicador com uma consulta independente, como foi feito aqui com o de alunos em risco, é o teste mínimo antes de publicar.
Fontes e leituras recomendadas
- Dash — documentação oficial — https://dash.plotly.com/
- Dash Bootstrap Components — https://dash-bootstrap-components.opensource.faculty.ai/
- Plotly Python — https://plotly.com/python/
- dash_table — DataTable — https://dash.plotly.com/datatable
- Gunicorn — deploy WSGI — https://gunicorn.org/
- BELORKAR, Abha et al. Interactive Data Visualization with Python. 2. ed. Packt, 2020. — visualizações interativas com Bokeh e Plotly.
Exercícios
Exercício 1
O dashboard foi desenvolvido com SQLite e, ao ser apontado para o PostgreSQL da escola, quatro das seis consultas falharam com function round(double precision, integer) does not exist. A consulta é ROUND(AVG(n.valor), 2), e a coluna valor foi declarada como Float. Por que funcionava num banco e não no outro, e qual é a correção mais sólida?
Ver resposta
✓ Resposta: No PostgreSQL, o Float do SQLAlchemy vira double precision, e a função round com número de casas decimais só existe para numeric. Sobre double precision há apenas o round de um argumento, que arredonda para inteiro. O SQLite tem tipagem flexível e um round(x, n) que aceita qualquer número, e por isso a mesma consulta passava. Há duas correções. A imediata é converter antes de arredondar: ROUND(AVG(n.valor)::numeric, 2), usada no artigo. A mais sólida é declarar a coluna como Numeric(4, 2): nota é um valor decimal exato, com duas casas, e ponto flutuante não é o tipo certo para ela. Assim, as funções agregadas já devolvem numeric. A lição mais geral é desenvolver e testar no mesmo banco da produção, o que um PostgreSQL em contêiner torna barato.
Exercício 2
Com o filtro de turma em "Todas", valor padrão da tela, a consulta de evolução bimestral falha com could not determine data type of parameter $2. Com uma turma escolhida, funciona. A condição é (:turma_id IS NULL OR t.id = :turma_id). Explique.
Ver resposta
✓ Resposta: O psycopg 3 envia os parâmetros separados do texto da consulta, e o PostgreSQL precisa deduzir o tipo de cada um. Com um valor inteiro, a dedução funciona. Com None, o parâmetro chega como nulo sem tipo, e em $2 IS NULL não há nada que indique qual tipo ele deveria ter: o servidor recusa a consulta. O psycopg2 não tinha esse problema porque montava o texto da consulta no cliente, com o NULL escrito direto. A correção é dar o tipo explicitamente: CAST(:turma_id AS integer) IS NULL OR t.id = CAST(:turma_id AS integer). Medido: com o cast, a consulta funciona com None e com ids reais. Outra saída é montar o filtro em Python, só acrescentando a condição quando há turma, o que também gera um plano de execução melhor. Em qualquer caso, o caminho padrão da tela, sem filtro, é o primeiro que precisa de teste.
Exercício 3
O cartão "Alunos em Risco" mostra 20. A coordenadora abre a página de alertas e encontra um aluno só. A consulta do KPI fazia SUM(CASE WHEN sub.media < 6 THEN 1 ELSE 0 END) numa junção de notas com alunos. Qual dos dois números está certo?
Ver resposta
✓ Resposta: O da página de alertas. A consulta do KPI parte da tabela de notas, então tem uma linha por nota. O aluno em risco tem 20 notas, cinco disciplinas em quatro bimestres, e a média baixa dele aparece nas 20 linhas: o SUM soma 1 vinte vezes. Medido: o KPI marcava 20, e uma consulta independente, agrupando por aluno, encontrava 1. A correção é contar alunos distintos: COUNT(DISTINCT CASE WHEN sub.media < 6 THEN a.id END). Esse é o erro mais comum em SQL analítico: agregar numa granularidade diferente da pergunta. Antes de somar, pergunte o que cada linha representa. Aqui, cada linha era uma nota, e a pergunta era sobre alunos. O mesmo vale para a "taxa de aprovação" do painel, que conta notas acima de 6, e não alunos aprovados; os dois números podem ser bem diferentes.
Exercício 4
O filtro de turma oferece "8A", "8B", "9B" e "9A". Ao escolher "8A", o gráfico mostra os alunos da 9A. As opções eram montadas com {"label": row["turma"], "value": str(i + 1)} sobre o resultado de media_por_turma. O que está errado, e por que o erro ficou pior no banco de produção?
Ver resposta
✓ Resposta: O valor da opção era a posição da turma na lista, e não o id dela. A lista vem ordenada pela média, e a posição 1 é a turma de melhor média, a 8A, enquanto o id 1 pertence à 9A, a primeira cadastrada. Medido num banco recém-criado: a 8A tem id 3 e recebia o valor "1". Em produção piorou porque os ids nem começam em 1. Sequências do PostgreSQL não voltam atrás num ROLLBACK, e no banco de teste deste artigo, depois de duas cargas que falharam, as turmas ficaram com ids de 9 a 12. O filtro passou a apontar para turmas inexistentes, e os gráficos vinham vazios. A correção é a consulta devolver o id, t.id AS turma_id, e o filtro usá-lo. A regra: id identifica, posição ordena, e confundir os dois só funciona enquanto os dados são os do exemplo.
Exercício 5
O Dockerfile sobe o dashboard com gunicorn app.main:server, e o contêiner reinicia sem parar, com o log dizendo que não encontrou o atributo server. Em desenvolvimento, app.run() funcionava. O que falta, e por que o Dash precisa disso?
Ver resposta
✓ Resposta: Falta a linha server = app.server em app/main.py. O Dash é construído sobre o Flask: o objeto dash.Dash cria internamente uma aplicação Flask e a expõe no atributo server. O gunicorn não conhece o Dash; ele carrega uma aplicação WSGI pelo caminho módulo:variável, e o que precisa receber é o Flask. O app.run() de desenvolvimento esconde isso, porque o próprio Dash sobe o servidor interno do Flask. Medido: com a linha, o gunicorn responde 200 na raiz. O mesmo padrão vale para outros frameworks com servidor de desenvolvimento embutido: o comando de produção precisa apontar para o objeto WSGI ou ASGI exato, e vale testá-lo localmente antes de montar a imagem. Um HEALTHCHECK no Dockerfile transformaria o reinício silencioso num status visível de "unhealthy".