Python

Artigo 51 — Projeto 4: Dashboard Analítico com Dash e Banco de Dados Real Já leu

22 min de leitura

Artigo 51 — Projeto 4: Dashboard Analítico com Dash e Banco de Dados Real
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

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

Prof. Ricardo Matos Módulo 9 · Projetos Reais e Carreira · Artigo 51 de 52


Introdução

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 + queries
│   ├── dados/
│   │   ├── __init__.py
│   │   ├── queries.py           ← consultas SQL/ORM
│   │   └── cache.py             ← Redis cache
│   ├── componentes/
│   │   ├── __init__.py
│   │   ├── cartoes.py           ← KPI cards
│   │   ├── graficos.py          ← Plotly figures
│   │   └── tabelas.py           ← DataTables
│   ├── layout/
│   │   ├── __init__.py
│   │   ├── sidebar.py
│   │   ├── header.py
│   │   └── paginas/
│   │       ├── visao_geral.py
│   │       ├── turmas.py
│   │       ├── alunos.py
│   │       └── alertas.py
│   └── callbacks/
│       ├── __init__.py
│       ├── filtros.py
│       └── graficos.py
├── migrations/
├── scripts/
│   └── popular_banco.py         ← dados de demonstração
├── tests/
├── assets/
│   └── style.css
├── Dockerfile
└── docker-compose.yml

Banco de Dados e Queries

# 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.nome                           AS turma,
            t.turno,
            COUNT(DISTINCT a.id)             AS n_alunos,
            ROUND(AVG(n.valor), 2)           AS media,
            ROUND(MIN(n.valor), 2)           AS menor,
            ROUND(MAX(n.valor), 2)           AS maior,
            ROUND(STDDEV(n.valor), 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), 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
          AND (:turma_id IS NULL OR t.id = :turma_id)
          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
          AND (:turma_id IS NULL OR t.id = :turma_id)
          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), 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
          AND (:turma_id IS NULL OR t.id = :turma_id)
          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), 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,
            SUM(CASE WHEN sub.media < 6 THEN 1 ELSE 0 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 {}

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):
    kpis    = kpis_gerais(ano)
    turmas  = media_por_turma(ano)
    notas   = distribuicao_notas(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
)

# ── 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"), "Turmas"
            ], href="/turmas", active="exact", className="nav-item-custom"),

            dbc.NavLink([
                html.Span("👥", className="me-2"), "Alunos"
            ], href="/alunos", 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",      layout_visao_geral(ano)),
        "/alertas": ("Alertas de Risco", layout_alertas(ano)),
    }

    titulo, conteudo = rotas.get(pathname, ("Página não encontrada", html.Div("404")))
    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"}]
    opcoes += [{"label": row["turma"], "value": str(i + 1)}
               for i, (_, row) in enumerate(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')}"

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
from passlib.context import CryptContext
import random

pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
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",
            senha_hash=pwd.hash("Senha123"),
            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!")

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
version: "3.9"
services:
  dashboard:
    build: .
    ports:
      - "8050:8050"
    environment:
      - DATABASE_URL=postgresql://escola:senha@db:5432/escola
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=escola
      - POSTGRES_PASSWORD=senha
      - 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:

Resumo do Projeto

Componente Tecnologias
Frontend Dash, Plotly, Bootstrap
Backend Python, pandas, SQLAlchemy
Banco PostgreSQL, SQL analítico
Visualizações Barras, Linha, Heatmap, Radar, Pizza
Interatividade Callbacks reativos, filtros, DataTable
Deploy Docker, Gunicorn, docker-compose
Dados demo SQLAlchemy ORM, NumPy para geração

Referências e Leituras Complementares

  • 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/
  • HOTZ, Adam; SCHEIDEGGER, Carlos. Interactive Data Visualization with Python. Packt, 2020. — visualizações interativas com Plotly e Dash.

Prof. Ricardo Matos — Dominando o Python em 1 Ano · Artigo 51 de 52 Próximo: Artigo 52 — Carreira: portfólio, GitHub, open source e o mercado Python


you asked

Sim


claude response

Dominando o Python em 1 Ano

Comentários

Mais em Python

Banco de Dados com SQLite e SQLAlchemy
Banco de Dados com SQLite e SQLAlchemy

Arquivos JSON e CSV funcionam bem para dados simples, mas quando o volume cre...

Classes e Objetos: os fundamentos da Orientação a Objetos
Classes e Objetos: os fundamentos da Orientação a Objetos

At&eacute; aqui trabalhamos com fun&ccedil;&otilde;es e estruturas de dados s...

Envio de E-mails e Integração com APIs de Comunicação
Envio de E-mails e Integração com APIs de Comunicação

Comunicação programática é um pilar de qualquer sistema moderno — confirmaçõe...