Projeto 4: Dashboard Analítico com Dash e Banco de Dados Real

Projeto 4: Dashboard Analítico com Dash e Banco de Dados Real

Dashboard analítico com Dash, Plotly, SQLAlchemy e PostgreSQL, conferido num banco real — com o que só aparece lá: ROUND sobre double precision, parâmetro nulo sem tipo no psycopg 3, KPI que conta notas em vez de alunos e filtro que confunde posição com id.
Python

• • 26 min de leitura

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

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".

Comentários

Mais em Python

Processamento de Linguagem Natural (NLP)
Processamento de Linguagem Natural (NLP)

NLP em Python com NLTK, spaCy, TF-IDF, Word2Vec e Hugging Face Transformers no…

Templates HTML com Jinja2 e Deploy com Docker
Templates HTML com Jinja2 e Deploy com Docker

Jinja2 no Flask e no FastAPI, e o deploy com Docker e Compose: os filtros que…

Dominando o Python
Dominando o Python

A série começa pelo terreno: o que Python é hoje, onde a linguagem de fato…