Spaces:
Running
Running
| # -*- coding: utf-8 -*- | |
| import streamlit as st | |
| from dotenv import load_dotenv | |
| from datetime import date, datetime, time | |
| # ⬇️ Import correto das utils de operação | |
| from utils_operacao import obter_grupos_disponiveis, obter_modulos_para_grupo | |
| # ✅ Usa toda a largura da página (chamar antes de qualquer outro st.*) | |
| st.set_page_config(layout="wide") | |
| # Carrega variáveis de ambiente | |
| load_dotenv() | |
| # =============================== | |
| # IMPORTAÇÃO DOS MÓDULOS | |
| # =============================== | |
| import formulario | |
| import consulta | |
| import relatorio | |
| import administracao | |
| import quiz | |
| import ranking | |
| import quiz_admin | |
| import usuarios_admin | |
| import videos | |
| import auditoria | |
| import importar_excel | |
| import calendario | |
| import auditoria_cleanup | |
| import jogos | |
| import db_tools | |
| import db_admin | |
| import db_monitor | |
| import operacao | |
| import db_export_import | |
| import resposta # 📬 Admin: Caixa de Entrada IOI‑RUN (módulo interno) | |
| import outlook_relatorio | |
| import repositorio_load | |
| import Produtividade_Especialista as produtividade_especialista | |
| import rnc | |
| import rnc_listagem | |
| import rnc_relatorio | |
| import sugestoes_usuario # 💡 Usuário: Sugestões IOI‑RUN (módulo separado) | |
| import repo_rnc | |
| import recebimento | |
| from utils_info import INFO_CONTEUDO, INFO_MODULOS, INFO_MAP_PAGINA_ID | |
| from login import login | |
| from utils_permissoes import verificar_permissao | |
| from utils_layout import exibir_logo | |
| from modules_map import MODULES | |
| from banco import engine, Base, SessionLocal | |
| from models import QuizPontuacao | |
| from models import IOIRunSugestao | |
| from models import AvisoGlobal | |
| # Extras p/ sessões ativas | |
| from uuid import uuid4 | |
| from sqlalchemy import text, func, or_ | |
| # 🗄️ Banco ativo (Produção/Teste/Treinamento) | |
| try: | |
| from db_router import current_db_choice, bank_label | |
| _HAS_ROUTER = True | |
| except Exception: | |
| _HAS_ROUTER = False | |
| def current_db_choice() -> str: | |
| return "prod" | |
| def bank_label(choice: str) -> str: | |
| return "🟢 Produção" if choice == "prod" else "🔴 Teste" | |
| # ❌ REMOVIDO: não chamar nenhuma página ao importar/rodar o app principal | |
| # if __name__ == "__main__": | |
| # rnc.pagina() | |
| # =============================== | |
| # RERUN por querystring (atalho ?rr=1) | |
| # =============================== | |
| def _get_query_params(): | |
| """Compat: retorna query params como dict (Streamlit novo/antigo).""" | |
| try: | |
| # Streamlit >= 1.32 | |
| return dict(st.query_params) | |
| except Exception: | |
| # Streamlit antigo (experimental) | |
| try: | |
| return dict(st.experimental_get_query_params()) | |
| except Exception: | |
| return {} | |
| def _set_query_params(new_params: dict): | |
| """Compat: define query params (Streamlit novo/antigo).""" | |
| try: | |
| st.query_params = new_params # Streamlit >= 1.32 | |
| except Exception: | |
| try: | |
| st.experimental_set_query_params(**new_params) | |
| except Exception: | |
| pass | |
| def _check_rerun_qs(pagina_atual: str = ""): | |
| """ | |
| Se a URL contiver rr=1 (ou true), força um rerun e limpa o parâmetro para evitar loop. | |
| ✅ Não dispara quando estiver na página 'resposta' (Inbox Admin). | |
| ✅ Consome apenas uma vez por sessão. | |
| ✅ (PATCH) Também não dispara quando estiver em 'outlook_relatorio' para não interromper leitura COM. | |
| """ | |
| try: | |
| if st.session_state.get("__qs_rr_consumed__", False): | |
| return | |
| # 🔒 Evita rr=1 em módulos sensíveis a rerun/refresh | |
| # 🟩 AJUSTE: incluir 'formulario' para não aplicar rr=1 quando o formulário estiver ativo | |
| if pagina_atual in ("resposta", "outlook_relatorio", "formulario"): | |
| return # não aplicar rr=1 dentro destes módulos (evita 'piscar' e cancelamentos) | |
| params = _get_query_params() | |
| rr_raw = params.get("rr", ["0"]) | |
| rr = rr_raw[0] if isinstance(rr_raw, (list, tuple)) else str(rr_raw) | |
| if str(rr).lower() in ("1", "true"): | |
| new_params = {k: v for k, v in params.items() if k != "rr"} | |
| _set_query_params(new_params) | |
| st.session_state["__qs_rr_consumed__"] = True | |
| st.rerun() | |
| except Exception: | |
| pass | |
| # ========================================= | |
| # DB helper — sessão ciente do ambiente | |
| # ========================================= | |
| def _get_db_session(): | |
| """Retorna uma sessão de banco consistente com o ambiente atual.""" | |
| try: | |
| from db_router import get_session_for_current_db | |
| return get_session_for_current_db() | |
| except Exception: | |
| pass | |
| try: | |
| from db_router import get_engine_for_current_db | |
| from sqlalchemy.orm import sessionmaker | |
| Eng = get_engine_for_current_db() | |
| return sessionmaker(bind=Eng)() | |
| except Exception: | |
| pass | |
| return SessionLocal() | |
| # =============================== | |
| # CONFIGURAÇÃO INICIAL | |
| # =============================== | |
| Base.metadata.create_all(bind=engine) | |
| def quiz_respondido_hoje(usuario: str) -> bool: | |
| # ✅ Usar sessão ciente do ambiente | |
| db = _get_db_session() | |
| try: | |
| inicio_dia = datetime.combine(date.today(), time.min) | |
| return ( | |
| db.query(QuizPontuacao) | |
| .filter( | |
| QuizPontuacao.usuario == usuario, | |
| QuizPontuacao.data >= inicio_dia | |
| ) | |
| .first() | |
| is not None | |
| ) | |
| finally: | |
| try: | |
| db.close() | |
| except Exception: | |
| pass | |
| # =============================== | |
| # Sessões ativas (usuários logados agora) | |
| # =============================== | |
| _SESS_TTL_MIN = 5 # janela para considerar "online" | |
| def _get_session_id() -> str: | |
| if "_sid" not in st.session_state: | |
| st.session_state["_sid"] = f"{uuid4()}" | |
| return st.session_state["_sid"] | |
| def _ensure_sessao_table(db) -> None: | |
| """Cria a tabela sessao_web caso não exista (SQLite/Postgres/MySQL).""" | |
| dialect = db.bind.dialect.name | |
| if dialect == "sqlite": | |
| db.execute(text(""" | |
| CREATE TABLE IF NOT EXISTS sessao_web ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| usuario TEXT NOT NULL, | |
| session_id TEXT NOT NULL UNIQUE, | |
| last_seen TIMESTAMP NOT NULL, | |
| ativo INTEGER NOT NULL DEFAULT 1 | |
| ) | |
| """)) | |
| elif dialect in ("postgresql", "postgres"): | |
| db.execute(text(""" | |
| CREATE TABLE IF NOT EXISTS sessao_web ( | |
| id SERIAL PRIMARY KEY, | |
| usuario TEXT NOT NULL, | |
| session_id TEXT NOT NULL UNIQUE, | |
| last_seen TIMESTAMPTZ NOT NULL, | |
| ativo BOOLEAN NOT NULL DEFAULT TRUE | |
| ) | |
| """)) | |
| else: # mysql / mariadb | |
| db.execute(text(""" | |
| CREATE TABLE IF NOT EXISTS sessao_web ( | |
| id INT AUTO_INCREMENT PRIMARY KEY, | |
| usuario VARCHAR(255) NOT NULL, | |
| session_id VARCHAR(255) NOT NULL UNIQUE, | |
| last_seen TIMESTAMP NOT NULL, | |
| ativo TINYINT(1) NOT NULL DEFAULT 1 | |
| ) | |
| """)) | |
| db.commit() | |
| def _session_heartbeat(usuario: str) -> None: | |
| """Atualiza/insere a sessão ativa do usuário com last_seen = now() e faz limpeza básica.""" | |
| if not usuario: | |
| return | |
| db = _get_db_session() | |
| try: | |
| _ensure_sessao_table(db) | |
| sid = _get_session_id() | |
| now_sql = "CURRENT_TIMESTAMP" | |
| upd = db.execute( | |
| text(f"UPDATE sessao_web SET last_seen = {now_sql}, ativo = 1 WHERE session_id = :sid"), | |
| {"sid": sid} | |
| ) | |
| if upd.rowcount == 0: | |
| db.execute( | |
| text(f"INSERT INTO sessao_web (usuario, session_id, last_seen, ativo) " | |
| f"VALUES (:usuario, :sid, {now_sql}, 1)"), | |
| {"usuario": usuario, "sid": sid} | |
| ) | |
| dialect = db.bind.dialect.name | |
| if dialect in ("postgresql", "postgres"): | |
| cleanup_sql = f"UPDATE sessao_web SET ativo = FALSE WHERE last_seen < (NOW() - INTERVAL '{_SESS_TTL_MIN * 2} minutes')" | |
| elif dialect == "sqlite": | |
| cleanup_sql = f"UPDATE sessao_web SET ativo = 0 WHERE last_seen < datetime(CURRENT_TIMESTAMP, '-{_SESS_TTL_MIN * 2} minutes')" | |
| else: | |
| cleanup_sql = f"UPDATE sessao_web SET ativo = 0 WHERE last_seen < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL {_SESS_TTL_MIN * 2} MINUTE)" | |
| db.execute(text(cleanup_sql)) | |
| db.commit() | |
| except Exception: | |
| db.rollback() | |
| finally: | |
| try: | |
| db.close() | |
| except Exception: | |
| pass | |
| def _get_active_users_count() -> int: | |
| """Conta usuários distintos com last_seen dentro da janela (_SESS_TTL_MIN) e ativo=1.""" | |
| db = _get_db_session() | |
| try: | |
| _ensure_sessao_table(db) | |
| dialect = db.bind.dialect.name | |
| if dialect in ("postgresql", "postgres"): | |
| threshold = f"(NOW() - INTERVAL '{_SESS_TTL_MIN} minutes')" | |
| elif dialect == "sqlite": | |
| threshold = f"datetime(CURRENT_TIMESTAMP, '-{_SESS_TTL_MIN} minutes')" | |
| else: | |
| threshold = f"DATE_SUB(CURRENT_TIMESTAMP, INTERVAL {_SESS_TTL_MIN} MINUTE)" | |
| res = db.execute( | |
| text(f"SELECT COUNT(DISTINCT usuario) AS c FROM sessao_web WHERE ativo = 1 AND last_seen >= {threshold}") | |
| ).fetchone() | |
| return int(res[0] if res and res[0] is not None else 0) | |
| except Exception: | |
| return 0 | |
| finally: | |
| try: | |
| db.close() | |
| except Exception: | |
| pass | |
| def _mark_session_inactive() -> None: | |
| """Marca a sessão atual como inativa (chamar no logout).""" | |
| sid = st.session_state.get("_sid") | |
| if not sid: | |
| return | |
| db = _get_db_session() | |
| try: | |
| _ensure_sessao_table(db) | |
| db.execute(text("UPDATE sessao_web SET ativo = 0 WHERE session_id = :sid"), {"sid": sid}) | |
| db.commit() | |
| except Exception: | |
| db.rollback() | |
| finally: | |
| try: | |
| db.close() | |
| except Exception: | |
| pass | |
| # =============================== | |
| # Aviso Global — Util (leitura e sanitização) | |
| # =============================== | |
| def _sanitize_largura(largura_raw: str) -> str: | |
| val = (largura_raw or "").strip() | |
| if not val: | |
| return "100%" | |
| if val.endswith("%") or val.endswith("px"): | |
| return val | |
| if val.isdigit(): | |
| return f"{val}px" | |
| return "100%" | |
| def obter_aviso_ativo(db): | |
| try: | |
| aviso = ( | |
| db.query(AvisoGlobal) | |
| .filter(AvisoGlobal.ativo == True) | |
| .order_by(AvisoGlobal.updated_at.desc(), AvisoGlobal.created_at.desc()) | |
| .first() | |
| ) | |
| return aviso | |
| except Exception: | |
| return None | |
| # =============================== | |
| # Aviso Global — Render do banner superior (robusto) | |
| # =============================== | |
| def _render_aviso_global_topbar(): | |
| try: | |
| db = _get_db_session() | |
| except Exception as e: | |
| st.sidebar.warning(f"Aviso desativado: sessão indisponível ({e})") | |
| return | |
| aviso = None | |
| try: | |
| aviso = obter_aviso_ativo(db) | |
| except Exception as e: | |
| st.sidebar.warning(f"Aviso desativado: falha ao consultar ({e})") | |
| aviso = None | |
| finally: | |
| try: | |
| db.close() | |
| except Exception: | |
| pass | |
| if not aviso: | |
| return | |
| try: | |
| largura = _sanitize_largura(aviso.largura) | |
| bg = aviso.bg_color or "#FFF3CD" | |
| fg = aviso.text_color or "#664D03" | |
| efeito = (aviso.efeito or "marquee").lower() | |
| velocidade = int(aviso.velocidade or 20) | |
| try: | |
| font_size = max(10, min(int(getattr(aviso, "font_size", 14)), 48)) | |
| except Exception: | |
| font_size = 14 | |
| altura = 52 # px | |
| st.markdown( | |
| f""" | |
| <style> | |
| /* Não derrube overlays do Streamlit */ | |
| .stApp::before, | |
| header[data-testid="stHeader"], | |
| [data-testid="stToolbar"], | |
| [data-testid="stDecoration"], | |
| [data-testid="collapsedControl"], | |
| .stApp [class*="stDialog"] {{ | |
| z-index: 1 !important; | |
| }} | |
| /* Reserva espaço para a barra */ | |
| [data-testid="stAppViewContainer"] {{ | |
| padding-top: {altura + 8}px !important; | |
| }} | |
| .ag-topbar-wrap {{ | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: {largura}; | |
| z-index: 2147483647 !important; | |
| background: {bg}; | |
| color: {fg}; | |
| border-bottom: 1px solid rgba(0,0,0,.12); | |
| box-shadow: 0 2px 8px rgba(0,0,0,.15); | |
| border-radius: 0 0 10px 10px; | |
| pointer-events: none; | |
| }} | |
| .ag-topbar-inner {{ | |
| display: flex; | |
| align-items: center; | |
| height: {altura}px; | |
| padding: 0 14px; | |
| overflow: hidden; | |
| font-weight: 700; | |
| font-size: {font_size}px; | |
| letter-spacing: .2px; | |
| white-space: nowrap; | |
| }} | |
| .ag-topbar-marquee > span {{ | |
| display: inline-block; | |
| padding-left: 100%; | |
| animation: ag-marquee {velocidade}s linear infinite; | |
| }} | |
| @keyframes ag-marquee {{ | |
| 0% {{ transform: translateX(0); }} | |
| 100% {{ transform: translateX(-100%); }} | |
| }} | |
| /* Acessibilidade: reduz movimento */ | |
| @media (prefers-reduced-motion: reduce) {{ | |
| .ag-topbar-marquee > span {{ | |
| animation: none !important; | |
| padding-left: 0; | |
| }} | |
| }} | |
| @media (max-width: 500px) {{ | |
| .ag-topbar-inner {{ | |
| font-size: {max(10, font_size-3)}px; | |
| padding: 0 8px; | |
| height: 44px; | |
| }} | |
| [data-testid="stAppViewContainer"] {{ | |
| padding-top: 52px !important; | |
| }} | |
| }} | |
| </style> | |
| <div class="ag-topbar-wrap"> | |
| <div class="ag-topbar-inner {'ag-topbar-marquee' if efeito=='marquee' else ''}"> | |
| <span>{aviso.mensagem}</span> | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| except Exception as e: | |
| st.sidebar.warning(f"Aviso desativado: erro de render ({e})") | |
| return | |
| # =============================== | |
| # Logout (utilitário) | |
| # =============================== | |
| def logout(): | |
| """Finaliza a sessão do usuário, limpa estados e recarrega a aplicação.""" | |
| _mark_session_inactive() # marca esta sessão como inativa | |
| st.session_state.logado = False | |
| st.session_state.usuario = None | |
| st.session_state.perfil = None | |
| st.session_state.nome = None | |
| st.session_state.email = None | |
| st.session_state.quiz_verificado = False | |
| st.rerun() | |
| # =============================== | |
| # 🎂 Banner/efeito de aniversário | |
| # =============================== | |
| def _show_birthday_banner_if_needed(): | |
| if st.session_state.get("__show_birthday__"): | |
| st.session_state["__show_birthday__"] = False | |
| st.markdown( | |
| """ | |
| <style> | |
| .confetti-wrapper { position: relative; width: 100%; height: 0; } | |
| .confetti-area { position: fixed; top: 0; left: 0; width: 100%; height: 100%; | |
| pointer-events: none; z-index: 9999; } | |
| .confetti { position: absolute; top: -5%; font-size: 24px; animation-name: confetti-fall; | |
| animation-timing-function: linear; animation-iteration-count: 1; } | |
| @keyframes confetti-fall { | |
| 0% { transform: translateY(-5vh) rotate(0deg); opacity: 1; } | |
| 100% { transform: translateY(105vh) rotate(360deg); opacity: 0; } | |
| } | |
| .confetti:nth-child(1) { left: 5%; animation-duration: 3.5s; } | |
| .confetti:nth-child(2) { left: 12%; animation-duration: 4.0s; } | |
| .confetti:nth-child(3) { left: 20%; animation-duration: 3.2s; } | |
| .confetti:nth-child(4) { left: 28%; animation-duration: 4.3s; } | |
| .confetti:nth-child(5) { left: 36%; animation-duration: 3.8s; } | |
| .confetti:nth-child(6) { left: 44%; animation-duration: 4.1s; } | |
| .confetti:nth-child(7) { left: 52%; animation-duration: 3.4s; } | |
| .confetti:nth-child(8) { left: 60%; animation-duration: 4.4s; } | |
| .confetti:nth-child(9) { left: 68%; animation-duration: 3.9s; } | |
| .confetti:nth-child(10) { left: 76%; animation-duration: 4.2s; } | |
| .confetti:nth-child(11) { left: 84%; animation-duration: 3.6s; } | |
| .confetti:nth-child(12) { left: 92%; animation-duration: 4.0s; } | |
| </style> | |
| <div class="confetti-wrapper"> | |
| <div class="confetti-area"> | |
| <div class="confetti">🎊</div><div class="confetti">🎉</div> | |
| <div class="confetti">🎊</div><div class="confetti">🎉</div> | |
| <div class="confetti">🎊</div><div class="confetti">🎉</div> | |
| <div class="confetti">🎊</div><div class="confetti">🎉</div> | |
| <div class="confetti">🎊</div><div class="confetti">🎉</div> | |
| <div class="confetti">🎊</div><div class="confetti">🎉</div> | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| st.balloons() | |
| nome = st.session_state.get("nome") or st.session_state.get("usuario") or "Usuário" | |
| st.markdown( | |
| f""" | |
| <div style="display:flex;justify-content:center;align-items:center;text-align:center;width:100%;margin:40px 0;"> | |
| <div style="font-size: 36px; font-weight: 800; color:#A020F0; | |
| background:linear-gradient(90deg,#FFF0F6,#F0E6FF); | |
| padding:20px 30px; border-radius:16px; box-shadow:0 4px 10px rgba(0,0,0,.08);"> | |
| 🎉 Feliz Aniversário, {nome}! 🎉 | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| COR_FRASE = "#0d6efd" | |
| st.markdown( | |
| f""" | |
| <div style="display:flex;justify-content:center;align-items:center;text-align:center;width:100%;margin-top:10px;"> | |
| <div style="font-size: 20px; font-weight: 500; color:{COR_FRASE}; padding:10px;"> | |
| Desejamos a você muitas conquistas e bons embarques ao longo do ano! 💜 | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| # =============================== | |
| # MAIN | |
| # =============================== | |
| def main(): | |
| # Estados iniciais | |
| if "logado" not in st.session_state: | |
| st.session_state.logado = False | |
| if "usuario" not in st.session_state: | |
| st.session_state.usuario = None | |
| if "quiz_verificado" not in st.session_state: | |
| st.session_state.quiz_verificado = False | |
| if "user_responses_viewed" not in st.session_state: | |
| st.session_state.user_responses_viewed = False | |
| if "nav_target" not in st.session_state: | |
| st.session_state.nav_target = None | |
| # ✅ Estado do intervalo de autoatualização (padrão aumentado p/ 60s; 0 = desligado) | |
| st.session_state.setdefault("__auto_refresh_interval_sec__", 60) | |
| # LOGIN | |
| if not st.session_state.logado: | |
| st.session_state.quiz_verificado = False | |
| exibir_logo(top=True, sidebar=False) | |
| login() | |
| return | |
| # 👥 Heartbeat + Badge de usuários logados (APENAS ADMIN) | |
| _session_heartbeat(st.session_state.usuario) | |
| if (st.session_state.get("perfil") or "").strip().lower() == "admin": | |
| try: | |
| online_now = _get_active_users_count() | |
| except Exception: | |
| online_now = 0 | |
| st.sidebar.markdown( | |
| f""" | |
| <div style="padding:8px 10px;margin-top:6px;margin-bottom:6px;border-radius:8px; | |
| background:#1e293b; color:#e2e8f0; border:1px solid #334155;"> | |
| <span style="font-size:13px;">🟢 Online (últimos {_SESS_TTL_MIN} min)</span><br> | |
| <span style="font-size:22px;font-weight:800;">{online_now}</span> | |
| </div> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| # 🔄 Botão de Recarregar (mantém a sessão ativa) + ⏱️ Controle do intervalo | |
| st.sidebar.markdown("---") | |
| # Linha com botão de recarregar e popover para o intervalo | |
| col_reload, col_interval = st.sidebar.columns([1, 1]) | |
| if col_reload.button("🔄 Recarregar (sem sair)", key="__btn_reload_now__"): | |
| st.rerun() | |
| # Popover (se disponível) para configurar intervalo; fallback para expander | |
| if hasattr(st, "popover"): | |
| with col_interval.popover("⏱️ Autoatualização"): | |
| new_val = st.number_input( | |
| "Intervalo (segundos) — 0 desativa", | |
| min_value=0, max_value=3600, | |
| value=int(st.session_state["__auto_refresh_interval_sec__"]), | |
| step=5, key="__auto_refresh_input__" | |
| ) | |
| if st.button("Aplicar intervalo", key="__btn_apply_auto_refresh__"): | |
| st.session_state["__auto_refresh_interval_sec__"] = int(new_val) | |
| try: | |
| if int(new_val) > 0: | |
| st.toast(f"Autoatualização ajustada para {int(new_val)}s.", icon="⏱️") | |
| else: | |
| st.toast("Autoatualização desativada.", icon="⛔") | |
| except Exception: | |
| pass | |
| st.rerun() | |
| else: | |
| with st.sidebar.expander("⏱️ Autoatualização", expanded=False): | |
| new_val = st.number_input( | |
| "Intervalo (segundos) — 0 desativa", | |
| min_value=0, max_value=3600, | |
| value=int(st.session_state["__auto_refresh_interval_sec__"]), | |
| step=5, key="__auto_refresh_input__" | |
| ) | |
| if st.button("Aplicar intervalo", key="__btn_apply_auto_refresh__"): | |
| st.session_state["__auto_refresh_interval_sec__"] = int(new_val) | |
| try: | |
| if int(new_val) > 0: | |
| st.toast(f"Autoatualização ajustada para {int(new_val)}s.", icon="⏱️") | |
| else: | |
| st.toast("Autoatualização desativada.", icon="⛔") | |
| except Exception: | |
| pass | |
| st.rerun() | |
| usuario = st.session_state.usuario | |
| perfil = (st.session_state.get("perfil") or "usuario").strip().lower() | |
| # QUIZ | |
| if not st.session_state.quiz_verificado: | |
| if not quiz_respondido_hoje(usuario): | |
| exibir_logo(top=True, sidebar=False) | |
| quiz.main() | |
| return | |
| else: | |
| st.session_state.quiz_verificado = True | |
| st.rerun() | |
| # SISTEMA LIBERADO | |
| exibir_logo(top=True, sidebar=True) | |
| _render_aviso_global_topbar() | |
| _show_birthday_banner_if_needed() | |
| st.sidebar.markdown("### Menu | 🎉 Carnaval 🎭 2026!") | |
| # Banco ativo na sidebar | |
| try: | |
| banco_label = bank_label(current_db_choice()) if _HAS_ROUTER else ( | |
| "🟢 Produção" if current_db_choice() == "prod" else "🔴 Teste" | |
| ) | |
| st.sidebar.caption(f"🗄️ Banco ativo: {banco_label}") | |
| except Exception: | |
| pass | |
| # ========================= | |
| # Notificações no sidebar | |
| # ========================= | |
| # --- Admin: pendentes --- | |
| if perfil == "admin": | |
| try: | |
| db = _get_db_session() | |
| pendentes = db.query(IOIRunSugestao).filter(func.lower(IOIRunSugestao.status) == "pendente").count() | |
| except Exception: | |
| pendentes = 0 | |
| finally: | |
| try: db.close() | |
| except Exception: pass | |
| if pendentes > 0: | |
| st.sidebar.markdown( | |
| """ | |
| <div style="padding:8px 10px;border-radius:8px;background:#FFF3CD;color:#664D03; | |
| border:1px solid #FFECB5;margin-bottom:6px;"> | |
| <b>🔔 {pendentes} sugestão(ões) pendente(s)</b><br> | |
| <span style="font-size:12px;">Acesse a caixa de entrada para responder.</span> | |
| </div> | |
| """.format(pendentes=pendentes), | |
| unsafe_allow_html=True | |
| ) | |
| # 👉 Direciona para o MESMO módulo do menu (resposta.main()) | |
| if st.sidebar.button("📬 Abrir Caixa de Entrada (Admin)"): | |
| st.session_state.nav_target = "resposta" | |
| st.rerun() | |
| # --- Usuário: respostas novas (após último 'visto') --- | |
| if perfil != "admin": | |
| # Última vez que o usuário realmente abriu e visualizou as respostas | |
| last_seen_dt = st.session_state.get("__user_last_answer_seen__") | |
| try: | |
| db = _get_db_session() | |
| # Qual é a resposta mais recente existente | |
| last_answer_dt_row = ( | |
| db.query(IOIRunSugestao.data_resposta) | |
| .filter( | |
| IOIRunSugestao.usuario == usuario, | |
| func.lower(IOIRunSugestao.status) == "respondida", | |
| IOIRunSugestao.data_resposta != None | |
| ) | |
| .order_by(IOIRunSugestao.data_resposta.desc()) | |
| .first() | |
| ) | |
| last_answer_dt = last_answer_dt_row[0] if last_answer_dt_row else None | |
| # Se há algo mais novo do que o 'visto', marcamos como não visto | |
| if last_answer_dt and (not last_seen_dt or last_answer_dt > last_seen_dt): | |
| st.session_state.user_responses_viewed = False | |
| # ✅ Conta SOMENTE respostas novas (depois do 'last_seen_dt') | |
| novas_respostas = ( | |
| db.query(IOIRunSugestao) | |
| .filter( | |
| IOIRunSugestao.usuario == usuario, | |
| func.lower(IOIRunSugestao.status) == "respondida", | |
| (IOIRunSugestao.data_resposta > last_seen_dt) if last_seen_dt else (IOIRunSugestao.data_resposta != None) | |
| ) | |
| .count() | |
| ) | |
| except Exception: | |
| novas_respostas = 0 | |
| finally: | |
| try: db.close() | |
| except Exception: pass | |
| # ✅ Exibir card de nova mensagem até o usuário clicar em "Ver respostas" | |
| if novas_respostas > 0 and not st.session_state.get("user_responses_viewed", False): | |
| st.sidebar.markdown( | |
| """ | |
| <div style="padding:8px 10px;border-radius:8px;background:#D1E7DD;color:#0F5132; | |
| border:1px solid #BADBCC;margin-bottom:6px;"> | |
| <b>🔔 {resps} resposta(s) nova(s) para suas sugestões</b><br> | |
| <span style="font-size:12px;">Clique para ver suas respostas.</span> | |
| </div> | |
| """.format(resps=novas_respostas), | |
| unsafe_allow_html=True | |
| ) | |
| # (Opcional) Toast discreto — aparece uma única vez por sessão enquanto houver novidade | |
| if not st.session_state.get("__user_toast_shown__"): | |
| try: | |
| st.toast("Você tem novas respostas do IOI‑RUN. Clique em '📥 Ver respostas'.", icon="💬") | |
| except Exception: | |
| pass | |
| st.session_state["__user_toast_shown__"] = True | |
| if st.sidebar.button("📥 Ver respostas"): | |
| # Não atualizamos last_seen aqui; isso é feito dentro do módulo do usuário | |
| st.session_state.nav_target = "sugestoes_ioirun" | |
| st.session_state.user_responses_viewed = True | |
| st.rerun() | |
| else: | |
| # Se não há novidades, libera o toast para a próxima vez que houver | |
| st.session_state["__user_toast_shown__"] = False | |
| # ------------------------- Menu lateral ------------------------- | |
| termo_busca = st.sidebar.text_input("Pesquisar módulo:").strip().lower() | |
| try: | |
| ambiente_atual = current_db_choice() if _HAS_ROUTER else "prod" | |
| except Exception: | |
| ambiente_atual = "prod" | |
| grupos_disponiveis = obter_grupos_disponiveis( | |
| MODULES, | |
| perfil=st.session_state.get("perfil", "usuario"), | |
| usuario=st.session_state.get("usuario"), | |
| ambiente=ambiente_atual, | |
| verificar_permissao=verificar_permissao | |
| ) | |
| if not grupos_disponiveis: | |
| st.sidebar.selectbox("Selecione a operação:", ["Em desenvolvimento"], index=0) | |
| st.warning("Nenhuma operação disponível para seu perfil/ambiente neste momento.") | |
| return | |
| grupo_escolhido = st.sidebar.selectbox("Selecione a operação:", grupos_disponiveis) | |
| opcoes = obter_modulos_para_grupo( | |
| MODULES, grupo_escolhido, termo_busca, | |
| perfil=st.session_state.get("perfil", "usuario"), | |
| usuario=st.session_state.get("usuario"), | |
| ambiente=ambiente_atual, | |
| verificar_permissao=verificar_permissao | |
| ) | |
| with st.sidebar.expander("🔧 Diagnóstico do menu", expanded=False): | |
| st.caption(f"Perfil: **{st.session_state.get('perfil', '—')}** | Grupo: **{grupo_escolhido}** | Busca: **{termo_busca or '∅'}** | Ambiente: **{ambiente_atual}**") | |
| try: | |
| mods_dbg = [{"id": mid, "label": lbl} for mid, lbl in (opcoes or [])] | |
| except Exception: | |
| mods_dbg = [] | |
| st.write("Módulos visíveis (após regras):", mods_dbg if mods_dbg else "—") | |
| # Failsafe outlook_relatorio | |
| try: | |
| mod_outlook = MODULES.get("outlook_relatorio") | |
| if mod_outlook: | |
| mesmo_grupo = (mod_outlook.get("grupo") == grupo_escolhido) | |
| perfil_ok = verificar_permissao( | |
| perfil=st.session_state.get("perfil", "usuario"), | |
| modulo_key="outlook_relatorio", | |
| usuario=st.session_state.get("usuario"), | |
| ambiente=ambiente_atual | |
| ) | |
| ja_nas_opcoes = any(mid == "outlook_relatorio" for mid, _ in (opcoes or [])) | |
| passa_busca = (not termo_busca) or (termo_busca in mod_outlook.get("label", "").strip().lower()) | |
| if mesmo_grupo and perfil_ok and not ja_nas_opcoes and passa_busca: | |
| opcoes = (opcoes or []) + [("outlook_relatorio", mod_outlook.get("label", "Relatorio portaria"))] | |
| except Exception: | |
| pass | |
| # Failsafe repositorio_load | |
| try: | |
| mod_repo = MODULES.get("repositorio_load") | |
| if mod_repo: | |
| mesmo_grupo_r = (mod_repo.get("grupo") == grupo_escolhido) | |
| perfil_ok_r = verificar_permissao( | |
| perfil=st.session_state.get("perfil", "usuario"), | |
| modulo_key="repositorio_load", | |
| usuario=st.session_state.get("usuario"), | |
| ambiente=ambiente_atual | |
| ) | |
| ja_nas_opcoes_r = any(mid == "repositorio_load" for mid, _ in (opcoes or [])) | |
| passa_busca_r = (not termo_busca) or (termo_busca in mod_repo.get("label", "").strip().lower()) | |
| if mesmo_grupo_r and perfil_ok_r and not ja_nas_opcoes_r and passa_busca_r: | |
| opcoes = (opcoes or []) + [("repositorio_load", mod_repo.get("label", "Repositório Load"))] | |
| except Exception: | |
| pass | |
| if not opcoes: | |
| st.sidebar.selectbox("Selecione o módulo:", ["Em desenvolvimento"], index=0) | |
| st.warning(f"A operação '{grupo_escolhido}' está em desenvolvimento.") | |
| return | |
| # ============================================================ | |
| # 🔒 Fix: selectbox com 'key' + seleção forçada para 'resposta' | |
| # quando vier de nav_target (sidebar) ou quando já estivermos na página. | |
| # ============================================================ | |
| labels = [label for _, label in opcoes] | |
| # Se foi solicitado nav_target, injeta a label alvo antes do selectbox | |
| if st.session_state.get("nav_target"): | |
| target = st.session_state["nav_target"] | |
| try: | |
| target_label = next(lbl for mid, lbl in opcoes if mid == target) | |
| st.session_state["mod_select_label"] = target_label | |
| except StopIteration: | |
| pass | |
| # Inicializa/persiste seleção | |
| if "mod_select_label" not in st.session_state or st.session_state["mod_select_label"] not in labels: | |
| st.session_state["mod_select_label"] = labels[0] | |
| escolha_label = st.sidebar.selectbox( | |
| "Selecione o módulo:", | |
| labels, | |
| index=labels.index(st.session_state["mod_select_label"]), | |
| key="mod_select_label" | |
| ) | |
| pagina_id = next(mod_id for mod_id, label in opcoes if label == escolha_label) | |
| # ✅ Navegação com lock (evita disputa com outros reruns) | |
| if st.session_state.get("nav_target"): | |
| pagina_id = st.session_state.nav_target | |
| st.session_state["__nav_lock__"] = True | |
| else: | |
| st.session_state["__nav_lock__"] = False | |
| # 🔎 Agora que sabemos a página atual, tratamos rr=1 com segurança | |
| _check_rerun_qs(pagina_atual=pagina_id) | |
| # ⏱️ Auto-refresh leve do sidebar — NÃO quando em Inbox/Admin/Outlook/Formulário/Recebimento | |
| try: | |
| from streamlit_autorefresh import st_autorefresh | |
| is_inbox_admin = (pagina_id == "resposta") | |
| is_outlook_rel = (pagina_id == "outlook_relatorio") | |
| is_formulario = (pagina_id == "formulario") | |
| is_recebimento = (pagina_id == "recebimento") | |
| interval_sec = int(st.session_state.get("__auto_refresh_interval_sec__", 60)) | |
| if (interval_sec > 0) and not (is_inbox_admin or is_outlook_rel or is_formulario or is_recebimento): | |
| # key dinâmica por intervalo evita conflitos ao trocar o valor | |
| st_autorefresh(interval=interval_sec * 1000, key=f"sidebar_autorefresh_{interval_sec}s") | |
| except Exception: | |
| pass | |
| # Logout | |
| st.sidebar.markdown("---") | |
| if st.session_state.get("logado"): | |
| if st.sidebar.button("🚪 Sair (Logout)"): | |
| logout() | |
| st.divider() | |
| # ------------------------- Roteamento ------------------------- | |
| if pagina_id == "formulario": | |
| formulario.main() | |
| elif pagina_id == "consulta": | |
| consulta.main() | |
| elif pagina_id == "relatorio": | |
| relatorio.main() | |
| elif pagina_id == "ranking": | |
| ranking.main() | |
| elif pagina_id == "quiz": | |
| quiz.main() | |
| ranking.main() | |
| elif pagina_id == "quiz_admin": | |
| quiz_admin.main() | |
| elif pagina_id == "usuarios": | |
| usuarios_admin.main() | |
| elif pagina_id == "administracao": | |
| administracao.main() | |
| elif pagina_id == "videos": | |
| videos.main() | |
| elif pagina_id == "auditoria": | |
| auditoria.main() | |
| elif pagina_id == "auditoria_cleanup": | |
| auditoria_cleanup.main() | |
| elif pagina_id == "importacao": | |
| importar_excel.main() | |
| elif pagina_id == "calendario": | |
| calendario.main() | |
| elif pagina_id == "jogos": | |
| st.session_state.setdefault("pontuacao", 0) | |
| st.session_state.setdefault("rodadas", 0) | |
| st.session_state.setdefault("ultimo_resultado", None) | |
| jogos.main() | |
| elif pagina_id == "temporario": | |
| db_tools.main() | |
| elif pagina_id == "db_admin": | |
| db_admin.main() | |
| elif pagina_id == "db_monitor": | |
| db_monitor.main() | |
| elif pagina_id == "operacao": | |
| operacao.main() | |
| elif pagina_id == "resposta": # 📬 Admin | |
| resposta.main() | |
| elif pagina_id == "db_export_import": | |
| db_export_import.main() | |
| elif pagina_id == "produtividade_especialista": | |
| produtividade_especialista.main() | |
| elif pagina_id == "outlook_relatorio": | |
| outlook_relatorio.main() | |
| elif pagina_id == "sugestoes_ioirun": # 💡 Usuário | |
| if st.session_state.get("perfil") == "admin": | |
| st.info("Use a **📬 Caixa de Entrada (Admin)** para responder sugestões.") | |
| else: | |
| sugestoes_usuario.main() | |
| elif pagina_id == "repositorio_load": | |
| repositorio_load.main() | |
| elif pagina_id == "rnc": | |
| rnc.pagina() | |
| elif pagina_id == "rnc_listagem": | |
| rnc_listagem.pagina() | |
| elif pagina_id == "rnc_relatorio": | |
| rnc_relatorio.pagina() | |
| elif pagina_id == "repo_rnc": | |
| repo_rnc.pagina() | |
| elif pagina_id == "recebimento": | |
| recebimento.main() | |
| # ------------------------------------------------------ | |
| # ℹ️ INFO — Guia passo a passo de uso (no sidebar) | |
| # ------------------------------------------------------ | |
| info_mod_default = INFO_MAP_PAGINA_ID.get(pagina_id, "Geral") | |
| with st.sidebar.expander("ℹ️ Info • Como usar o sistema", expanded=False): | |
| st.markdown(""" | |
| **Bem-vindo!** | |
| Este painel reúne instruções rápidas para utilizar cada módulo e seus campos. | |
| Selecione o módulo abaixo ou navegue pelo menu — o conteúdo ajusta automaticamente. | |
| """) | |
| mod_info_sel = st.selectbox( | |
| "Escolha o módulo para ver instruções:", | |
| INFO_MODULOS, | |
| index=INFO_MODULOS.index(info_mod_default) if info_mod_default in INFO_MODULOS else 0, | |
| key="info_mod_sel" | |
| ) | |
| st.markdown(INFO_CONTEUDO.get(mod_info_sel, "_Conteúdo não disponível para este módulo._")) | |
| # ✅ Libera o nav_target após a 1ª render da página de destino | |
| if st.session_state.get("__nav_lock__"): | |
| st.session_state["nav_target"] = None | |
| st.session_state["__nav_lock__"] = False | |
| if __name__ == "__main__": | |
| main() | |
| # ------------------------- | |
| # Desenvolvedor e versão | |
| # ------------------------- | |
| if st.session_state.get("logado") and st.session_state.get("email"): | |
| st.sidebar.markdown( | |
| f""" | |
| <div style="display:inline-flex;align-items:center;gap:8px; padding:4px 8px;border-radius:8px; | |
| background:#e7f1ff;color:#0d6efd;font-size:13px;line-height:1.2;"> | |
| <span style="font-size:16px;">👤</span> | |
| <span>{st.session_state.email}</span> | |
| </div> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| st.sidebar.markdown( | |
| """ | |
| <hr style="margin-top: 10px; margin-bottom: 6px;"> | |
| <p style="font-size: 12px; color: #6c757d;"> | |
| Versão: <strong>1.0.0</strong> • Desenvolvedor: <strong>Rodrigo Silva - Ideiasystem | 2026</strong> | |
| </p> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |