Spaces:
Running
Running
Upload 81 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .audit_report.json +219 -0
- .env +79 -0
- .gitattributes +2 -0
- .gitignore +42 -132
- .txt +2 -0
- 20260129_add_po_alt_pn_lot_batch.py +40 -0
- Inbox_Admin.py +197 -0
- Info.txt +20 -0
- Load.db +3 -0
- Load.db.bak +3 -0
- Load.py +0 -0
- Produtividade_Especialista.py +778 -0
- add_pergunta.py +129 -0
- administracao.py +883 -0
- app.py +1015 -0
- app_outlook.py +315 -0
- audit_streamlit_project.py +512 -0
- auditoria.py +100 -0
- auditoria_cleanup.py +103 -0
- auto_capture.py +319 -0
- banco.py +121 -0
- bi.py +74 -0
- cadastro_py.py +28 -0
- calendario.py +708 -0
- calendario_mensal.py +70 -0
- componentes.py +35 -0
- consulta.py +277 -0
- db_admin.py +387 -0
- db_export_import.py +359 -0
- db_monitor.py +278 -0
- db_router.py +152 -0
- db_tools.py +65 -0
- env_audit.py +321 -0
- fix_schema.py +34 -0
- form_equipamento.py +98 -0
- formulario.py +519 -0
- importar_excel.py +301 -0
- init_admin.py +33 -0
- init_db.py +38 -0
- jogos.py +384 -0
- listar_perguntas.py +22 -0
- log.py +63 -0
- login.py +175 -0
- models.py +463 -0
- module_loader.py +68 -0
- modules_map.py +263 -0
- operacao.py +1564 -0
- outlook_relatorio.py +1624 -0
- passenger_wsgi.py +13 -0
- quiz.py +188 -0
.audit_report.json
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"duplicate_keys": {},
|
| 3 |
+
"widgets_without_key": {
|
| 4 |
+
".\\app.py": {
|
| 5 |
+
"button_no_key": [
|
| 6 |
+
445
|
| 7 |
+
]
|
| 8 |
+
},
|
| 9 |
+
".\\app_outlook.py": {
|
| 10 |
+
"download_no_key": [
|
| 11 |
+
24,
|
| 12 |
+
36,
|
| 13 |
+
82
|
| 14 |
+
]
|
| 15 |
+
},
|
| 16 |
+
".\\auditoria.py": {
|
| 17 |
+
"download_no_key": [
|
| 18 |
+
91
|
| 19 |
+
]
|
| 20 |
+
},
|
| 21 |
+
".\\auditoria_cleanup.py": {
|
| 22 |
+
"button_no_key": [
|
| 23 |
+
65
|
| 24 |
+
]
|
| 25 |
+
},
|
| 26 |
+
".\\consulta.py": {
|
| 27 |
+
"download_no_key": [
|
| 28 |
+
171
|
| 29 |
+
]
|
| 30 |
+
},
|
| 31 |
+
".\\db_admin.py": {
|
| 32 |
+
"button_no_key": [
|
| 33 |
+
213,
|
| 34 |
+
241,
|
| 35 |
+
263,
|
| 36 |
+
316,
|
| 37 |
+
360
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
".\\db_export_import.py": {
|
| 41 |
+
"button_no_key": [
|
| 42 |
+
273,
|
| 43 |
+
285,
|
| 44 |
+
301,
|
| 45 |
+
320,
|
| 46 |
+
333
|
| 47 |
+
],
|
| 48 |
+
"download_no_key": [
|
| 49 |
+
277,
|
| 50 |
+
289,
|
| 51 |
+
305
|
| 52 |
+
]
|
| 53 |
+
},
|
| 54 |
+
".\\db_monitor.py": {
|
| 55 |
+
"button_no_key": [
|
| 56 |
+
261,
|
| 57 |
+
265
|
| 58 |
+
]
|
| 59 |
+
},
|
| 60 |
+
".\\db_tools.py": {
|
| 61 |
+
"button_no_key": [
|
| 62 |
+
57
|
| 63 |
+
]
|
| 64 |
+
},
|
| 65 |
+
".\\importar_excel.py": {
|
| 66 |
+
"download_no_key": [
|
| 67 |
+
77
|
| 68 |
+
]
|
| 69 |
+
},
|
| 70 |
+
".\\jogos.py": {
|
| 71 |
+
"button_no_key": [
|
| 72 |
+
104,
|
| 73 |
+
256,
|
| 74 |
+
356
|
| 75 |
+
]
|
| 76 |
+
},
|
| 77 |
+
".\\login.py": {
|
| 78 |
+
"button_no_key": [
|
| 79 |
+
83
|
| 80 |
+
]
|
| 81 |
+
},
|
| 82 |
+
".\\operacao.py": {
|
| 83 |
+
"button_no_key": [
|
| 84 |
+
1444,
|
| 85 |
+
1452,
|
| 86 |
+
1471,
|
| 87 |
+
1487
|
| 88 |
+
],
|
| 89 |
+
"download_no_key": [
|
| 90 |
+
1543,
|
| 91 |
+
1547
|
| 92 |
+
]
|
| 93 |
+
},
|
| 94 |
+
".\\outlook_relatorio.py": {
|
| 95 |
+
"download_no_key": [
|
| 96 |
+
30,
|
| 97 |
+
42,
|
| 98 |
+
79
|
| 99 |
+
]
|
| 100 |
+
},
|
| 101 |
+
".\\Produtividade_Especialista.py": {
|
| 102 |
+
"button_no_key": [
|
| 103 |
+
52
|
| 104 |
+
],
|
| 105 |
+
"download_no_key": [
|
| 106 |
+
506,
|
| 107 |
+
518
|
| 108 |
+
]
|
| 109 |
+
},
|
| 110 |
+
".\\quiz.py": {
|
| 111 |
+
"button_no_key": [
|
| 112 |
+
107
|
| 113 |
+
]
|
| 114 |
+
},
|
| 115 |
+
".\\quiz_admin.py": {
|
| 116 |
+
"button_no_key": [
|
| 117 |
+
57
|
| 118 |
+
]
|
| 119 |
+
},
|
| 120 |
+
".\\ranking.py": {
|
| 121 |
+
"download_no_key": [
|
| 122 |
+
109
|
| 123 |
+
]
|
| 124 |
+
},
|
| 125 |
+
".\\repositorio_load.py": {
|
| 126 |
+
"download_no_key": [
|
| 127 |
+
251,
|
| 128 |
+
344
|
| 129 |
+
]
|
| 130 |
+
},
|
| 131 |
+
".\\videos.py": {
|
| 132 |
+
"button_no_key": [
|
| 133 |
+
65,
|
| 134 |
+
85
|
| 135 |
+
]
|
| 136 |
+
}
|
| 137 |
+
},
|
| 138 |
+
"missing_imports_in_app": [],
|
| 139 |
+
"routing_vs_modules": {
|
| 140 |
+
"routes_without_modules_entry": [],
|
| 141 |
+
"modules_entry_without_route": [
|
| 142 |
+
"administracao",
|
| 143 |
+
"auditoria",
|
| 144 |
+
"auditoria_cleanup",
|
| 145 |
+
"backload_consulta",
|
| 146 |
+
"calendario",
|
| 147 |
+
"calendario_mensal",
|
| 148 |
+
"consulta",
|
| 149 |
+
"db_admin",
|
| 150 |
+
"db_export_import",
|
| 151 |
+
"db_monitor",
|
| 152 |
+
"formulario",
|
| 153 |
+
"importacao",
|
| 154 |
+
"indicadores",
|
| 155 |
+
"jogos",
|
| 156 |
+
"operacao",
|
| 157 |
+
"outlook_relatorio",
|
| 158 |
+
"produtividade_especialista",
|
| 159 |
+
"quiz",
|
| 160 |
+
"quiz_admin",
|
| 161 |
+
"ranking",
|
| 162 |
+
"relatorio",
|
| 163 |
+
"repositorio_load",
|
| 164 |
+
"resposta",
|
| 165 |
+
"sugestoes_ioirun",
|
| 166 |
+
"terceiros_gestao",
|
| 167 |
+
"usuarios",
|
| 168 |
+
"videos"
|
| 169 |
+
]
|
| 170 |
+
},
|
| 171 |
+
"module_files_missing": [],
|
| 172 |
+
"modules_without_main": [],
|
| 173 |
+
"unused_imports": {
|
| 174 |
+
".\\auto_capture.py": [
|
| 175 |
+
"TimeoutError"
|
| 176 |
+
],
|
| 177 |
+
".\\calendario_mensal.py": [
|
| 178 |
+
"formatar_data_br"
|
| 179 |
+
],
|
| 180 |
+
".\\db_admin.py": [
|
| 181 |
+
"SessionLocal"
|
| 182 |
+
],
|
| 183 |
+
".\\db_export_import.py": [
|
| 184 |
+
"SessionLocal"
|
| 185 |
+
],
|
| 186 |
+
".\\db_monitor.py": [
|
| 187 |
+
"time",
|
| 188 |
+
"SessionLocal",
|
| 189 |
+
"verificar_permissao"
|
| 190 |
+
],
|
| 191 |
+
".\\env_audit.py": [
|
| 192 |
+
"Tuple"
|
| 193 |
+
],
|
| 194 |
+
".\\init_db.py": [
|
| 195 |
+
"models"
|
| 196 |
+
],
|
| 197 |
+
".\\modules_map.py": [
|
| 198 |
+
"calendario",
|
| 199 |
+
"calendario_mensal"
|
| 200 |
+
],
|
| 201 |
+
".\\utils_auditoria.py": [
|
| 202 |
+
"db_info"
|
| 203 |
+
],
|
| 204 |
+
".\\utils_campos.py": [
|
| 205 |
+
"Equipamento"
|
| 206 |
+
],
|
| 207 |
+
".\\utils_datas.py": [
|
| 208 |
+
"date"
|
| 209 |
+
],
|
| 210 |
+
".\\utils_lembretes.py": [
|
| 211 |
+
"date"
|
| 212 |
+
],
|
| 213 |
+
".\\utils_operacao.py": [
|
| 214 |
+
"annotations",
|
| 215 |
+
"st"
|
| 216 |
+
]
|
| 217 |
+
},
|
| 218 |
+
"import_cycles": []
|
| 219 |
+
}
|
.env
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# --- Mayasuite API (Operação) ---
|
| 3 |
+
OP_ACCESS_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTc2ODQ4NjI5NywianRpIjoiNTQ1NjdkYmUtZGUxZi00ZDAxLTkzYzktZGRiYzk4MGJmYWNlIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6IjgzNDAxM2QzLWJhNTMtNDQ1MC1hZmJlLTc4ODZhZjQ5MjJiNCIsIm5iZiI6MTc2ODQ4NjI5NywiY3NyZiI6IjQ3OGNjM2RiLTU5ZGItNDU5NS04ZjdjLWQzM2RhMDAzMjZhMCIsImV4cCI6MTc2ODUyOTQ5N30.R3Bi6c9uxjv8ehvT6JqIshgQqiTJIP8Lm4XlmY-bStg
|
| 4 |
+
# Alternativas (opcionais, usadas só se a primária falhar)
|
| 5 |
+
OP_LOGIN_EMAIL_ALT=api@armmatriz.com.br
|
| 6 |
+
OP_LOGIN_PASSWORD_ALT=Arm@2025
|
| 7 |
+
|
| 8 |
+
# Ativa logs de corpo de erro (apenas DIAGNÓSTICO TEMPORÁRIO)
|
| 9 |
+
OP_LOGIN_DEBUG=true
|
| 10 |
+
# Configurações de requisição
|
| 11 |
+
OP_READ_TIMEOUT=60 # p.ex., 60s (alguns endpoints demoram mais)
|
| 12 |
+
OP_RATE_DELAY_SEC=0.5 # atraso menor entre páginas
|
| 13 |
+
OP_MAX_PAGES=1 # padrão apenas 1 página (você controla na UI)
|
| 14 |
+
OP_MAX_RETRIES_5XX=3 # menos tentativas para 5xx
|
| 15 |
+
OP_5XX_BACKOFF_BASE=2 # backoff mais curto
|
| 16 |
+
OP_RETRY_TIMEOUT_TOTAL=90 # timeout total menor para retries
|
| 17 |
+
# --- Fim Mayasuite API (Operação) ---
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ================================
|
| 21 |
+
# 🔀 Bancos (Multi‑ambiente SQLite)
|
| 22 |
+
# ================================
|
| 23 |
+
# Utilize estes URLs caso deseje ler os caminhos pelo .env.
|
| 24 |
+
# Para ativar no db_router.py, DESCOMENTE o bloco de dotenv nele.
|
| 25 |
+
DB1_PROD_URL=sqlite:///C:/Users/rodrigo.silva/OneDrive - ARM ARMAZENS GERAIS & LOGISTICA LTDA/Load/LoadApp/Load.db
|
| 26 |
+
DB2_TEST_URL=sqlite:///C:/Users/rodrigo.silva/OneDrive - ARM ARMAZENS GERAIS & LOGISTICA LTDA/Load/LoadApp/Load_teste.db
|
| 27 |
+
DB3_TREINAMENTO_URL=sqlite:///C:/Users/rodrigo.silva/OneDrive - ARM ARMAZENS GERAIS & LOGISTICA LTDA/Load/LoadApp/Load_treinamento.db
|
| 28 |
+
|
| 29 |
+
# (Opcional) rótulos amigáveis por ambiente (se quiser ler via .env)
|
| 30 |
+
DB1_LABEL=Banco 1 (📗 Produção)
|
| 31 |
+
DB2_LABEL=Banco 2 (📕 Teste)
|
| 32 |
+
DB3_LABEL=Banco 3 (📘 Treinamento)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ==================================
|
| 36 |
+
# 🤖 Automação de captura/apresentação
|
| 37 |
+
# ==================================
|
| 38 |
+
# Usado pelo script auto_capture.py (Playwright + python-pptx)
|
| 39 |
+
APP_URL=http://localhost:8501
|
| 40 |
+
|
| 41 |
+
# Usuário/senha para login automático (recomendado perfil admin em Teste/Treinamento)
|
| 42 |
+
LOGIN_USER=admin
|
| 43 |
+
LOGIN_PASS=admin123
|
| 44 |
+
|
| 45 |
+
# Ambiente alvo para captura: prod | test | treinamento
|
| 46 |
+
BANK_CHOICE=prod
|
| 47 |
+
|
| 48 |
+
# Saídas de captura e apresentação
|
| 49 |
+
SCREEN_DIR=./screenshots
|
| 50 |
+
OUTPUT_PPTX=./demo_funcionalidades.pptx
|
| 51 |
+
|
| 52 |
+
# (Opcional) parâmetros da captura
|
| 53 |
+
AUTOCAPTURE_HEADLESS=false # true = sem abrir janela; false = visível
|
| 54 |
+
AUTOCAPTURE_VIEWPORT_W=1440
|
| 55 |
+
AUTOCAPTURE_VIEWPORT_H=900
|
| 56 |
+
|
| 57 |
+
# (Opcional) pular quiz durante captura (se seu login exigir quiz)
|
| 58 |
+
AUTOCAPTURE_SKIP_QUIZ=true
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ==========================
|
| 62 |
+
# 🧰 Monitor/Backup do banco
|
| 63 |
+
# ==========================
|
| 64 |
+
# Diretório padrão de backups (db_monitor.py)
|
| 65 |
+
BACKUP_DIR=./backups
|
| 66 |
+
BACKUP_RETAIN=10 # manter N arquivos mais recentes
|
| 67 |
+
BACKUP_FREQ_DAYS=7 # frequência "prevista" em dias
|
| 68 |
+
|
| 69 |
+
# (Opcional) mostrar URL do engine na sidebar (se usar no app.py)
|
| 70 |
+
SHOW_ENGINE_URL_IN_SIDEBAR=true
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ==========================
|
| 74 |
+
# 🔧 Streamlit (opcional)
|
| 75 |
+
# ==========================
|
| 76 |
+
# STREAMLIT_SERVER_ADDRESS=0.0.0.0
|
| 77 |
+
# STREAMLIT_SERVER_PORT=8501
|
| 78 |
+
# STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
|
| 79 |
+
# STREAMLIT_THEME_BASE="light"
|
.gitattributes
CHANGED
|
@@ -32,3 +32,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 32 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 33 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 32 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 33 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
Load.db filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
Load.db.bak filter=lfs diff=lfs merge=lfs -text
|
.gitignore
CHANGED
|
@@ -1,132 +1,42 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
.
|
| 8 |
-
|
| 9 |
-
#
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
.
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
#
|
| 34 |
-
#
|
| 35 |
-
#
|
| 36 |
-
*.
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
#
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
# Unit test / coverage reports
|
| 44 |
-
htmlcov/
|
| 45 |
-
.tox/
|
| 46 |
-
.nox/
|
| 47 |
-
.coverage
|
| 48 |
-
.coverage.*
|
| 49 |
-
.cache
|
| 50 |
-
nosetests.xml
|
| 51 |
-
coverage.xml
|
| 52 |
-
*.cover
|
| 53 |
-
*.py,cover
|
| 54 |
-
.hypothesis/
|
| 55 |
-
.pytest_cache/
|
| 56 |
-
|
| 57 |
-
# Translations
|
| 58 |
-
*.mo
|
| 59 |
-
*.pot
|
| 60 |
-
|
| 61 |
-
# Django stuff:
|
| 62 |
-
*.log
|
| 63 |
-
local_settings.py
|
| 64 |
-
db.sqlite3
|
| 65 |
-
db.sqlite3-journal
|
| 66 |
-
|
| 67 |
-
# Flask stuff:
|
| 68 |
-
instance/
|
| 69 |
-
.webassets-cache
|
| 70 |
-
|
| 71 |
-
# Scrapy stuff:
|
| 72 |
-
.scrapy
|
| 73 |
-
|
| 74 |
-
# Sphinx documentation
|
| 75 |
-
docs/_build/
|
| 76 |
-
|
| 77 |
-
# PyBuilder
|
| 78 |
-
target/
|
| 79 |
-
|
| 80 |
-
# Jupyter Notebook
|
| 81 |
-
.ipynb_checkpoints
|
| 82 |
-
|
| 83 |
-
# IPython
|
| 84 |
-
profile_default/
|
| 85 |
-
ipython_config.py
|
| 86 |
-
|
| 87 |
-
# pyenv
|
| 88 |
-
.python-version
|
| 89 |
-
|
| 90 |
-
# pipenv
|
| 91 |
-
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 92 |
-
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 93 |
-
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 94 |
-
# install all needed dependencies.
|
| 95 |
-
#Pipfile.lock
|
| 96 |
-
|
| 97 |
-
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
| 98 |
-
__pypackages__/
|
| 99 |
-
|
| 100 |
-
# Celery stuff
|
| 101 |
-
celerybeat-schedule
|
| 102 |
-
celerybeat.pid
|
| 103 |
-
|
| 104 |
-
# SageMath parsed files
|
| 105 |
-
*.sage.py
|
| 106 |
-
|
| 107 |
-
# Environments
|
| 108 |
-
.env
|
| 109 |
-
.venv
|
| 110 |
-
env/
|
| 111 |
-
venv/
|
| 112 |
-
ENV/
|
| 113 |
-
env.bak/
|
| 114 |
-
venv.bak/
|
| 115 |
-
|
| 116 |
-
# Spyder project settings
|
| 117 |
-
.spyderproject
|
| 118 |
-
.spyproject
|
| 119 |
-
|
| 120 |
-
# Rope project settings
|
| 121 |
-
.ropeproject
|
| 122 |
-
|
| 123 |
-
# mkdocs documentation
|
| 124 |
-
/site
|
| 125 |
-
|
| 126 |
-
# mypy
|
| 127 |
-
.mypy_cache/
|
| 128 |
-
.dmypy.json
|
| 129 |
-
dmypy.json
|
| 130 |
-
|
| 131 |
-
# Pyre type checker
|
| 132 |
-
.pyre/
|
|
|
|
| 1 |
+
# ========================
|
| 2 |
+
# Python
|
| 3 |
+
# ========================
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
*.pyo
|
| 7 |
+
*.pyd
|
| 8 |
+
|
| 9 |
+
# ========================
|
| 10 |
+
# Ambiente virtual
|
| 11 |
+
# ========================
|
| 12 |
+
venv/
|
| 13 |
+
env/
|
| 14 |
+
.venv/
|
| 15 |
+
|
| 16 |
+
# ========================
|
| 17 |
+
# Variáveis de ambiente
|
| 18 |
+
# ========================
|
| 19 |
+
.env
|
| 20 |
+
|
| 21 |
+
# ========================
|
| 22 |
+
# Banco de dados
|
| 23 |
+
# ========================
|
| 24 |
+
*.db
|
| 25 |
+
*.sqlite
|
| 26 |
+
*.sqlite3
|
| 27 |
+
|
| 28 |
+
# ========================
|
| 29 |
+
# Streamlit
|
| 30 |
+
# ========================
|
| 31 |
+
.streamlit/
|
| 32 |
+
|
| 33 |
+
# ========================
|
| 34 |
+
# Logs
|
| 35 |
+
# ========================
|
| 36 |
+
*.log
|
| 37 |
+
|
| 38 |
+
# ========================
|
| 39 |
+
# Sistema operacional
|
| 40 |
+
# ========================
|
| 41 |
+
.DS_Store
|
| 42 |
+
Thumbs.db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
DATABASE_URL=postgresql://...
|
| 2 |
+
SENHA_ADMIN=admin123
|
20260129_add_po_alt_pn_lot_batch.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add po_alt, pn, lot_batch to recebimento_registros
|
| 2 |
+
|
| 3 |
+
Revision ID: 8f7c3e5a9b21
|
| 4 |
+
Revises: <COLOQUE_AQUI_O_REVISION_ID_ANTERIOR>
|
| 5 |
+
Create Date: 2026-01-29 13:20:00.000000
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
|
| 11 |
+
# Revisão atual e anterior
|
| 12 |
+
revision = '8f7c3e5a9b21'
|
| 13 |
+
down_revision = '<COLOQUE_AQUI_O_REVISION_ID_ANTERIOR>'
|
| 14 |
+
branch_labels = None
|
| 15 |
+
depends_on = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def upgrade():
|
| 19 |
+
# Usamos batch_alter_table p/ compatibilidade (SQLite etc.)
|
| 20 |
+
with op.batch_alter_table('recebimento_registros', schema=None) as batch_op:
|
| 21 |
+
batch_op.add_column(sa.Column('po_alt', sa.String(length=60), nullable=True))
|
| 22 |
+
batch_op.add_column(sa.Column('pn', sa.String(length=120), nullable=True))
|
| 23 |
+
batch_op.add_column(sa.Column('lot_batch', sa.String(length=120), nullable=True))
|
| 24 |
+
|
| 25 |
+
# Se desejar índices (opcionais), descomente:
|
| 26 |
+
# batch_op.create_index('ix_receb_po_alt', ['po_alt'])
|
| 27 |
+
# batch_op.create_index('ix_receb_pn', ['pn'])
|
| 28 |
+
# batch_op.create_index('ix_receb_lot_batch', ['lot_batch'])
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def downgrade():
|
| 32 |
+
with op.batch_alter_table('recebimento_registros', schema=None) as batch_op:
|
| 33 |
+
# Se criou índices acima, primeiro drope-os:
|
| 34 |
+
# batch_op.drop_index('ix_receb_lot_batch')
|
| 35 |
+
# batch_op.drop_index('ix_receb_pn')
|
| 36 |
+
# batch_op.drop_index('ix_receb_po_alt')
|
| 37 |
+
|
| 38 |
+
batch_op.drop_column('lot_batch')
|
| 39 |
+
batch_op.drop_column('pn')
|
| 40 |
+
batch_op.drop_column('po_alt')
|
Inbox_Admin.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# pages/Inbox_Admin.py
|
| 3 |
+
# -*- coding: utf-8 -*-
|
| 4 |
+
import streamlit as st
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from sqlalchemy import func
|
| 7 |
+
|
| 8 |
+
# Model
|
| 9 |
+
from models import IOIRunSugestao
|
| 10 |
+
|
| 11 |
+
# (Opcional) auditoria
|
| 12 |
+
try:
|
| 13 |
+
from utils_auditoria import registrar_log
|
| 14 |
+
except Exception:
|
| 15 |
+
registrar_log = None
|
| 16 |
+
|
| 17 |
+
# ------------- CONFIG BÁSICA -------------
|
| 18 |
+
st.set_page_config(page_title="📬 Inbox Admin • IOI-RUN", layout="wide")
|
| 19 |
+
|
| 20 |
+
STATUS_PENDENTE = "pendente"
|
| 21 |
+
STATUS_RESPONDIDA = "respondida"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ------------- Sessão de banco ciente do ambiente -------------
|
| 25 |
+
def _get_db_session():
|
| 26 |
+
"""
|
| 27 |
+
Retorna uma sessão de banco consistente com o ambiente atual.
|
| 28 |
+
Tenta usar o db_router (se presente); senão, cai para SessionLocal().
|
| 29 |
+
"""
|
| 30 |
+
try:
|
| 31 |
+
from db_router import get_session_for_current_db
|
| 32 |
+
return get_session_for_current_db()
|
| 33 |
+
except Exception:
|
| 34 |
+
pass
|
| 35 |
+
try:
|
| 36 |
+
from banco import SessionLocal
|
| 37 |
+
return SessionLocal()
|
| 38 |
+
except Exception as e:
|
| 39 |
+
st.error(f"Banco indisponível: {e}")
|
| 40 |
+
raise
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _debug_banco_caption():
|
| 44 |
+
"""Mostra em qual banco estamos (Produção/Teste/Treinamento)."""
|
| 45 |
+
try:
|
| 46 |
+
from db_router import current_db_choice, bank_label
|
| 47 |
+
choice = current_db_choice()
|
| 48 |
+
label = bank_label(choice)
|
| 49 |
+
st.caption(f"🗄️ Banco ativo: **{label}**")
|
| 50 |
+
except Exception:
|
| 51 |
+
st.caption("🗄️ Banco ativo: **default**")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ------------- Guarda de rota (somente admin) -------------
|
| 55 |
+
def _ensure_admin():
|
| 56 |
+
perfil = (st.session_state.get("perfil") or "").strip().lower()
|
| 57 |
+
if perfil != "admin":
|
| 58 |
+
st.error("Acesso negado. Esta página é restrita a administradores.")
|
| 59 |
+
st.stop()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ------------- Página -------------
|
| 63 |
+
def main():
|
| 64 |
+
_ensure_admin()
|
| 65 |
+
|
| 66 |
+
st.title("📬 Caixa de Entrada • IOI‑RUN (Admin)")
|
| 67 |
+
st.caption("Responda sugestões dos usuários em uma página separada, sem interferência do app principal.")
|
| 68 |
+
_debug_banco_caption()
|
| 69 |
+
|
| 70 |
+
# Estados persistentes exclusivos desta página (prefixo 'adm_inbox_')
|
| 71 |
+
st.session_state.setdefault("adm_inbox_area", "todos")
|
| 72 |
+
st.session_state.setdefault("adm_inbox_status", STATUS_PENDENTE)
|
| 73 |
+
st.session_state.setdefault("adm_inbox_usuario", "")
|
| 74 |
+
st.session_state.setdefault("adm_inbox_nonce", 0)
|
| 75 |
+
|
| 76 |
+
AREAS = ["todos", "WMS", "FPSO", "UI/UX", "Relatórios", "Integrações", "Performance", "Segurança", "Outros"]
|
| 77 |
+
STATUS = [STATUS_PENDENTE, STATUS_RESPONDIDA, "todos"]
|
| 78 |
+
|
| 79 |
+
# ------------- Filtros -------------
|
| 80 |
+
col_f1, col_f2, col_f3, col_f4 = st.columns([1, 1, 1, 0.6])
|
| 81 |
+
col_f1.selectbox(
|
| 82 |
+
"Área/Tema",
|
| 83 |
+
AREAS,
|
| 84 |
+
key="adm_inbox_area",
|
| 85 |
+
index=AREAS.index(st.session_state["adm_inbox_area"]) if st.session_state["adm_inbox_area"] in AREAS else 0
|
| 86 |
+
)
|
| 87 |
+
col_f2.selectbox(
|
| 88 |
+
"Status",
|
| 89 |
+
STATUS,
|
| 90 |
+
key="adm_inbox_status",
|
| 91 |
+
index=STATUS.index(st.session_state["adm_inbox_status"]) if st.session_state["adm_inbox_status"] in STATUS else 0
|
| 92 |
+
)
|
| 93 |
+
col_f3.text_input(
|
| 94 |
+
"Filtrar por usuário (login exato)",
|
| 95 |
+
key="adm_inbox_usuario",
|
| 96 |
+
value=st.session_state["adm_inbox_usuario"]
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
if col_f4.button("🔄 Atualizar lista"):
|
| 100 |
+
st.session_state["adm_inbox_nonce"] += 1
|
| 101 |
+
st.rerun()
|
| 102 |
+
|
| 103 |
+
# ------------- Consulta -------------
|
| 104 |
+
db = _get_db_session()
|
| 105 |
+
try:
|
| 106 |
+
q = db.query(IOIRunSugestao)
|
| 107 |
+
if st.session_state["adm_inbox_area"] != "todos":
|
| 108 |
+
q = q.filter(IOIRunSugestao.area == st.session_state["adm_inbox_area"])
|
| 109 |
+
if st.session_state["adm_inbox_status"] != "todos":
|
| 110 |
+
q = q.filter(func.lower(IOIRunSugestao.status) == st.session_state["adm_inbox_status"])
|
| 111 |
+
if (st.session_state["adm_inbox_usuario"] or "").strip():
|
| 112 |
+
q = q.filter(IOIRunSugestao.usuario == (st.session_state["adm_inbox_usuario"] or "").strip())
|
| 113 |
+
|
| 114 |
+
sugestoes = q.order_by(IOIRunSugestao.data_envio.desc()).all()
|
| 115 |
+
except Exception as e:
|
| 116 |
+
st.error(f"Erro ao consultar sugestões: {e}")
|
| 117 |
+
sugestoes = []
|
| 118 |
+
|
| 119 |
+
# ------------- Lista / Edição -------------
|
| 120 |
+
if not sugestoes:
|
| 121 |
+
st.info("Nenhuma sugestão encontrada para os filtros aplicados.")
|
| 122 |
+
else:
|
| 123 |
+
for s in sugestoes:
|
| 124 |
+
dt_envio = s.data_envio.strftime("%d/%m/%Y %H:%M") if s.data_envio else "—"
|
| 125 |
+
titulo = f"📩 {dt_envio} — {s.usuario} — Status: {s.status or '—'}"
|
| 126 |
+
if s.area:
|
| 127 |
+
titulo += f" — Área: {s.area}"
|
| 128 |
+
|
| 129 |
+
with st.expander(titulo, expanded=False):
|
| 130 |
+
st.markdown("**Sugestão:**")
|
| 131 |
+
st.write(s.mensagem or "—")
|
| 132 |
+
|
| 133 |
+
with st.form(key=f"adm_inbox_form_{s.id}", clear_on_submit=False):
|
| 134 |
+
resposta_txt = st.text_area(
|
| 135 |
+
f"Responder ao usuário ({s.usuario}) — ID {s.id}",
|
| 136 |
+
value=s.resposta or "",
|
| 137 |
+
key=f"adm_inbox_resposta_{s.id}",
|
| 138 |
+
placeholder="Digite sua resposta para este usuário…",
|
| 139 |
+
height=140
|
| 140 |
+
)
|
| 141 |
+
col_a1, col_a2 = st.columns([1, 1])
|
| 142 |
+
enviar = col_a1.form_submit_button("📤 Enviar resposta")
|
| 143 |
+
pendenciar = col_a2.form_submit_button("⏳ Marcar como pendente")
|
| 144 |
+
|
| 145 |
+
if enviar:
|
| 146 |
+
try:
|
| 147 |
+
s.resposta = (resposta_txt or "").strip()
|
| 148 |
+
s.status = STATUS_RESPONDIDA if s.resposta else STATUS_PENDENTE
|
| 149 |
+
s.data_resposta = datetime.now() if s.resposta else None
|
| 150 |
+
s.responsavel = st.session_state.get("usuario")
|
| 151 |
+
|
| 152 |
+
db.add(s)
|
| 153 |
+
db.commit()
|
| 154 |
+
|
| 155 |
+
# Auditoria (opcional)
|
| 156 |
+
if registrar_log and s.resposta:
|
| 157 |
+
try:
|
| 158 |
+
registrar_log(
|
| 159 |
+
usuario=st.session_state.get("usuario"),
|
| 160 |
+
acao=f"Respondeu sugestão IOI‑RUN (ID {s.id}) para {s.usuario}",
|
| 161 |
+
tabela="ioirun_sugestao",
|
| 162 |
+
registro_id=s.id
|
| 163 |
+
)
|
| 164 |
+
except Exception:
|
| 165 |
+
pass
|
| 166 |
+
|
| 167 |
+
st.success("Resposta registrada com sucesso! (Agora em 'respondida')")
|
| 168 |
+
st.rerun()
|
| 169 |
+
except Exception as e:
|
| 170 |
+
db.rollback()
|
| 171 |
+
st.error(f"Erro ao salvar resposta: {e}")
|
| 172 |
+
|
| 173 |
+
if pendenciar:
|
| 174 |
+
try:
|
| 175 |
+
s.status = STATUS_PENDENTE
|
| 176 |
+
s.resposta = None
|
| 177 |
+
s.data_resposta = None
|
| 178 |
+
s.responsavel = None
|
| 179 |
+
db.add(s)
|
| 180 |
+
db.commit()
|
| 181 |
+
st.info("Sugestão marcada como pendente novamente.")
|
| 182 |
+
st.rerun()
|
| 183 |
+
except Exception as e:
|
| 184 |
+
db.rollback()
|
| 185 |
+
st.error(f"Erro ao alterar status: {e}")
|
| 186 |
+
|
| 187 |
+
st.markdown("---")
|
| 188 |
+
st.caption("Use o **menu lateral** para navegar para outros módulos.")
|
| 189 |
+
|
| 190 |
+
try:
|
| 191 |
+
db.close()
|
| 192 |
+
except Exception:
|
| 193 |
+
pass
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
if __name__ == "__main__":
|
| 197 |
+
main()
|
Info.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
LoadApp/
|
| 2 |
+
│
|
| 3 |
+
├── app.py # Arquivo principal
|
| 4 |
+
├── login.py # Login
|
| 5 |
+
├── administracao.py # Área admin
|
| 6 |
+
├── formulario.py # Inclusão
|
| 7 |
+
├── consulta.py # Consulta
|
| 8 |
+
├── relatorios.py # Relatórios
|
| 9 |
+
│
|
| 10 |
+
├── banco.py # Conexão com banco
|
| 11 |
+
├── models.py # Modelos SQLAlchemy
|
| 12 |
+
├── utils_fpso.py
|
| 13 |
+
├── utils_permissoes.py
|
| 14 |
+
│
|
| 15 |
+
├── assets/
|
| 16 |
+
│ └── logo.png # Logo do sistema
|
| 17 |
+
│
|
| 18 |
+
├── requirements.txt
|
| 19 |
+
├── .gitignore
|
| 20 |
+
└── README.md
|
Load.db
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1b624b9d0c5160a67fb95de8a6d21ebe03389aa8029273d8bc86216e51eec470
|
| 3 |
+
size 9220096
|
Load.db.bak
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:40613ff0f3898cf9307261a0b2bc2ec4a393e8315395ef0e1c116c7d6f49bde9
|
| 3 |
+
size 1196032
|
Load.py
ADDED
|
File without changes
|
Produtividade_Especialista.py
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
import streamlit as st
|
| 4 |
+
import pandas as pd
|
| 5 |
+
from io import BytesIO
|
| 6 |
+
from banco import SessionLocal
|
| 7 |
+
from models import Equipamento
|
| 8 |
+
|
| 9 |
+
# Auto-refresh
|
| 10 |
+
from streamlit_autorefresh import st_autorefresh
|
| 11 |
+
from datetime import datetime, timedelta
|
| 12 |
+
|
| 13 |
+
# SQL util
|
| 14 |
+
from sqlalchemy import text
|
| 15 |
+
|
| 16 |
+
# ====== Gráficos: Altair (preferência) + fallback Matplotlib ======
|
| 17 |
+
ALT_AVAILABLE = True
|
| 18 |
+
try:
|
| 19 |
+
import altair as alt
|
| 20 |
+
try:
|
| 21 |
+
alt.data_transformers.disable_max_rows()
|
| 22 |
+
except Exception:
|
| 23 |
+
pass
|
| 24 |
+
except Exception:
|
| 25 |
+
ALT_AVAILABLE = False
|
| 26 |
+
|
| 27 |
+
import matplotlib
|
| 28 |
+
matplotlib.use("Agg")
|
| 29 |
+
import matplotlib.pyplot as plt
|
| 30 |
+
|
| 31 |
+
# NumPy para cálculos numéricos robustos
|
| 32 |
+
import numpy as np
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ===============================
|
| 36 |
+
# Fotos de Responsáveis — Helpers (DB)
|
| 37 |
+
# ===============================
|
| 38 |
+
def _ensure_foto_table(db) -> None:
|
| 39 |
+
"""Cria a tabela responsavel_foto se não existir (SQLite/PostgreSQL/MySQL)."""
|
| 40 |
+
dialect = db.bind.dialect.name
|
| 41 |
+
|
| 42 |
+
if dialect == "sqlite":
|
| 43 |
+
sql = """
|
| 44 |
+
CREATE TABLE IF NOT EXISTS responsavel_foto (
|
| 45 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 46 |
+
tipo TEXT NOT NULL, -- 'especialista' | 'conferente'
|
| 47 |
+
nome TEXT NOT NULL,
|
| 48 |
+
imagem BLOB NOT NULL, -- bytes
|
| 49 |
+
mimetype TEXT,
|
| 50 |
+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 51 |
+
UNIQUE (tipo, nome)
|
| 52 |
+
)
|
| 53 |
+
"""
|
| 54 |
+
elif dialect in ("postgresql", "postgres"):
|
| 55 |
+
sql = """
|
| 56 |
+
CREATE TABLE IF NOT EXISTS responsavel_foto (
|
| 57 |
+
id SERIAL PRIMARY KEY,
|
| 58 |
+
tipo TEXT NOT NULL, -- 'especialista' | 'conferente'
|
| 59 |
+
nome TEXT NOT NULL,
|
| 60 |
+
imagem BYTEA NOT NULL, -- bytes
|
| 61 |
+
mimetype TEXT,
|
| 62 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 63 |
+
UNIQUE (tipo, nome)
|
| 64 |
+
)
|
| 65 |
+
"""
|
| 66 |
+
else: # mysql/mariadb
|
| 67 |
+
sql = """
|
| 68 |
+
CREATE TABLE IF NOT EXISTS responsavel_foto (
|
| 69 |
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
| 70 |
+
tipo VARCHAR(32) NOT NULL,
|
| 71 |
+
nome VARCHAR(255) NOT NULL,
|
| 72 |
+
imagem LONGBLOB NOT NULL,
|
| 73 |
+
mimetype VARCHAR(64),
|
| 74 |
+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
| 75 |
+
UNIQUE KEY uq_tipo_nome (tipo, nome)
|
| 76 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 77 |
+
"""
|
| 78 |
+
db.execute(text(sql))
|
| 79 |
+
db.commit()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _get_foto(db, tipo: str, nome: str):
|
| 83 |
+
"""Retorna (bytes_imagem, mimetype, updated_at) ou (None, None, None)."""
|
| 84 |
+
if not (tipo and nome):
|
| 85 |
+
return None, None, None
|
| 86 |
+
_ensure_foto_table(db)
|
| 87 |
+
row = db.execute(
|
| 88 |
+
text(
|
| 89 |
+
"SELECT imagem, mimetype, updated_at "
|
| 90 |
+
"FROM responsavel_foto WHERE tipo = :t AND nome = :n LIMIT 1"
|
| 91 |
+
),
|
| 92 |
+
{"t": tipo, "n": nome},
|
| 93 |
+
).fetchone()
|
| 94 |
+
if row:
|
| 95 |
+
return row[0], (row[1] or "image/jpeg"), row[2]
|
| 96 |
+
return None, None, None
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _set_foto(db, tipo: str, nome: str, content: bytes, mimetype: str) -> None:
|
| 100 |
+
"""Upsert simples por (tipo, nome)."""
|
| 101 |
+
if not (tipo and nome and content):
|
| 102 |
+
return
|
| 103 |
+
_ensure_foto_table(db)
|
| 104 |
+
upd = db.execute(
|
| 105 |
+
text(
|
| 106 |
+
"UPDATE responsavel_foto "
|
| 107 |
+
"SET imagem=:img, mimetype=:mt, updated_at=CURRENT_TIMESTAMP "
|
| 108 |
+
"WHERE tipo=:t AND nome=:n"
|
| 109 |
+
),
|
| 110 |
+
{"img": content, "mt": mimetype or "image/jpeg", "t": tipo, "n": nome},
|
| 111 |
+
)
|
| 112 |
+
if upd.rowcount == 0:
|
| 113 |
+
db.execute(
|
| 114 |
+
text(
|
| 115 |
+
"INSERT INTO responsavel_foto (tipo, nome, imagem, mimetype) "
|
| 116 |
+
"VALUES (:t, :n, :img, :mt)"
|
| 117 |
+
),
|
| 118 |
+
{"t": tipo, "n": nome, "img": content, "mt": mimetype or "image/jpeg"},
|
| 119 |
+
)
|
| 120 |
+
db.commit()
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _del_foto(db, tipo: str, nome: str) -> None:
|
| 124 |
+
if not (tipo and nome):
|
| 125 |
+
return
|
| 126 |
+
_ensure_foto_table(db)
|
| 127 |
+
db.execute(text("DELETE FROM responsavel_foto WHERE tipo=:t AND nome=:n"), {"t": tipo, "n": nome})
|
| 128 |
+
db.commit()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ===============================
|
| 132 |
+
# Estado
|
| 133 |
+
# ===============================
|
| 134 |
+
def limpar_estado_prod_esp():
|
| 135 |
+
"""Remove do session_state qualquer dado do módulo Produtividade_Especialista."""
|
| 136 |
+
for key in list(st.session_state.keys()):
|
| 137 |
+
if key.startswith("prod_esp_"):
|
| 138 |
+
del st.session_state[key]
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ===============================
|
| 142 |
+
# UI – Gerenciar fotos de responsáveis
|
| 143 |
+
# ===============================
|
| 144 |
+
def _ui_fotos_responsaveis(df: pd.DataFrame):
|
| 145 |
+
"""Bloco para cadastrar/atualizar/remover fotos de Especialistas e Conferentes."""
|
| 146 |
+
st.subheader("📸 Fotos dos Responsáveis")
|
| 147 |
+
|
| 148 |
+
especialistas = sorted([x for x in df["Especialista"].dropna().astype(str).unique() if x.strip()])
|
| 149 |
+
conferentes = sorted([x for x in df["Conferente"].dropna().astype(str).unique() if x.strip()])
|
| 150 |
+
|
| 151 |
+
tab_esp, tab_conf = st.tabs(["Especialista", "Conferente"])
|
| 152 |
+
|
| 153 |
+
# ---------- Especialista ----------
|
| 154 |
+
with tab_esp:
|
| 155 |
+
col_e1, col_e2 = st.columns([1, 2])
|
| 156 |
+
with col_e1:
|
| 157 |
+
nome_esp = st.selectbox("Especialista", options=["(selecione)"] + especialistas, index=0, key="prod_esp_foto_esp_sel")
|
| 158 |
+
file_esp = st.file_uploader(
|
| 159 |
+
"Carregar foto (PNG/JPG/JPEG/GIF/WEBP) — Especialista",
|
| 160 |
+
type=["png", "jpg", "jpeg", "gif", "webp"],
|
| 161 |
+
key="prod_esp_foto_esp_up"
|
| 162 |
+
)
|
| 163 |
+
salvar_esp = st.button("💾 Salvar/Atualizar foto (Especialista)", key="prod_esp_foto_esp_salvar")
|
| 164 |
+
remover_esp = st.button("🗑️ Remover foto (Especialista)", key="prod_esp_foto_esp_remover")
|
| 165 |
+
|
| 166 |
+
with col_e2:
|
| 167 |
+
db = SessionLocal()
|
| 168 |
+
try:
|
| 169 |
+
if nome_esp and nome_esp != "(selecione)":
|
| 170 |
+
img_bytes, mt, updt = _get_foto(db, "especialista", nome_esp)
|
| 171 |
+
if img_bytes:
|
| 172 |
+
st.caption(f"Foto atual de **{nome_esp}** (atualizada em {updt})")
|
| 173 |
+
st.image(img_bytes, caption=nome_esp, use_container_width=False, width=220)
|
| 174 |
+
else:
|
| 175 |
+
st.info("Nenhuma foto cadastrada para este Especialista.")
|
| 176 |
+
finally:
|
| 177 |
+
db.close()
|
| 178 |
+
|
| 179 |
+
if salvar_esp:
|
| 180 |
+
if not (nome_esp and nome_esp != "(selecione)"):
|
| 181 |
+
st.warning("Selecione um Especialista.")
|
| 182 |
+
elif not file_esp:
|
| 183 |
+
st.warning("Escolha um arquivo de imagem para enviar.")
|
| 184 |
+
else:
|
| 185 |
+
content = file_esp.read()
|
| 186 |
+
mt = file_esp.type or "image/jpeg"
|
| 187 |
+
db = SessionLocal()
|
| 188 |
+
try:
|
| 189 |
+
_set_foto(db, "especialista", nome_esp, content, mt)
|
| 190 |
+
st.success("Foto salva/atualizada com sucesso!")
|
| 191 |
+
st.rerun()
|
| 192 |
+
except Exception as e:
|
| 193 |
+
db.rollback()
|
| 194 |
+
st.error(f"Erro ao salvar foto: {e}")
|
| 195 |
+
finally:
|
| 196 |
+
db.close()
|
| 197 |
+
|
| 198 |
+
if remover_esp:
|
| 199 |
+
if not (nome_esp and nome_esp != "(selecione)"):
|
| 200 |
+
st.warning("Selecione um Especialista.")
|
| 201 |
+
else:
|
| 202 |
+
db = SessionLocal()
|
| 203 |
+
try:
|
| 204 |
+
_del_foto(db, "especialista", nome_esp)
|
| 205 |
+
st.info("Foto removida.")
|
| 206 |
+
st.rerun()
|
| 207 |
+
except Exception as e:
|
| 208 |
+
db.rollback()
|
| 209 |
+
st.error(f"Erro ao remover foto: {e}")
|
| 210 |
+
finally:
|
| 211 |
+
db.close()
|
| 212 |
+
|
| 213 |
+
# ---------- Conferente ----------
|
| 214 |
+
with tab_conf:
|
| 215 |
+
col_c1, col_c2 = st.columns([1, 2])
|
| 216 |
+
with col_c1:
|
| 217 |
+
nome_conf = st.selectbox("Conferente", options=["(selecione)"] + conferentes, index=0, key="prod_esp_foto_conf_sel")
|
| 218 |
+
file_conf = st.file_uploader(
|
| 219 |
+
"Carregar foto (PNG/JPG/JPEG/GIF/WEBP) — Conferente",
|
| 220 |
+
type=["png", "jpg", "jpeg", "gif", "webp"],
|
| 221 |
+
key="prod_esp_foto_conf_up"
|
| 222 |
+
)
|
| 223 |
+
salvar_conf = st.button("💾 Salvar/Atualizar foto (Conferente)", key="prod_esp_foto_conf_salvar")
|
| 224 |
+
remover_conf = st.button("🗑️ Remover foto (Conferente)", key="prod_esp_foto_conf_remover")
|
| 225 |
+
|
| 226 |
+
with col_c2:
|
| 227 |
+
db = SessionLocal()
|
| 228 |
+
try:
|
| 229 |
+
if nome_conf and nome_conf != "(selecione)":
|
| 230 |
+
img_bytes, mt, updt = _get_foto(db, "conferente", nome_conf)
|
| 231 |
+
if img_bytes:
|
| 232 |
+
st.caption(f"Foto atual de **{nome_conf}** (atualizada em {updt})")
|
| 233 |
+
st.image(img_bytes, caption=nome_conf, use_container_width=False, width=220)
|
| 234 |
+
else:
|
| 235 |
+
st.info("Nenhuma foto cadastrada para este Conferente.")
|
| 236 |
+
finally:
|
| 237 |
+
db.close()
|
| 238 |
+
|
| 239 |
+
if salvar_conf:
|
| 240 |
+
if not (nome_conf and nome_conf != "(selecione)"):
|
| 241 |
+
st.warning("Selecione um Conferente.")
|
| 242 |
+
elif not file_conf:
|
| 243 |
+
st.warning("Escolha um arquivo de imagem para enviar.")
|
| 244 |
+
else:
|
| 245 |
+
content = file_conf.read()
|
| 246 |
+
mt = file_conf.type or "image/jpeg"
|
| 247 |
+
db = SessionLocal()
|
| 248 |
+
try:
|
| 249 |
+
_set_foto(db, "conferente", nome_conf, content, mt)
|
| 250 |
+
st.success("Foto salva/atualizada com sucesso!")
|
| 251 |
+
st.rerun()
|
| 252 |
+
except Exception as e:
|
| 253 |
+
db.rollback()
|
| 254 |
+
st.error(f"Erro ao salvar foto: {e}")
|
| 255 |
+
finally:
|
| 256 |
+
db.close()
|
| 257 |
+
|
| 258 |
+
if remover_conf:
|
| 259 |
+
if not (nome_conf and nome_conf != "(selecione)"):
|
| 260 |
+
st.warning("Selecione um Conferente.")
|
| 261 |
+
else:
|
| 262 |
+
db = SessionLocal()
|
| 263 |
+
try:
|
| 264 |
+
_del_foto(db, "conferente", nome_conf)
|
| 265 |
+
st.info("Foto removida.")
|
| 266 |
+
st.rerun()
|
| 267 |
+
except Exception as e:
|
| 268 |
+
db.rollback()
|
| 269 |
+
st.error(f"Erro ao remover foto: {e}")
|
| 270 |
+
finally:
|
| 271 |
+
db.close()
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ===============================
|
| 275 |
+
# Mini-gráfico mensal (% acertos) — Helpers
|
| 276 |
+
# ===============================
|
| 277 |
+
def _normalize_responsaveis(df: pd.DataFrame) -> pd.DataFrame:
|
| 278 |
+
"""Normaliza nomes (remove espaços/None) para evitar falhas de comparação."""
|
| 279 |
+
for col in ["Especialista", "Conferente"]:
|
| 280 |
+
df[col] = df[col].astype(str).fillna("").str.strip()
|
| 281 |
+
df[col] = df[col].replace({"None": ""})
|
| 282 |
+
return df
|
| 283 |
+
|
| 284 |
+
def _month_labels_last_n(n: int) -> pd.DataFrame:
|
| 285 |
+
"""Retorna DataFrame com os últimos n meses e rótulos MES/AA, em ordem cronológica."""
|
| 286 |
+
base = pd.Timestamp(datetime.now().replace(day=1))
|
| 287 |
+
months = [base - pd.DateOffset(months=i) for i in range(n-1, -1, -1)]
|
| 288 |
+
return pd.DataFrame({
|
| 289 |
+
"YM": [pd.Period(m, freq="M") for m in months],
|
| 290 |
+
"mes": [m.strftime("%b/%y").upper() for m in months]
|
| 291 |
+
})
|
| 292 |
+
|
| 293 |
+
def _serie_pct_mensal(df: pd.DataFrame, resp_col: str, nome: str, months: int = 6) -> pd.DataFrame:
|
| 294 |
+
"""
|
| 295 |
+
Série mensal (últimos 'months' meses) de % acertos (MROB) para um responsável.
|
| 296 |
+
Retorna DataFrame com ['mes', 'pct', 'MROB', 'ERROS'] (meses sem dados => 0).
|
| 297 |
+
Corrige dtype para evitar TypeError: Expected numeric dtype, got object instead.
|
| 298 |
+
"""
|
| 299 |
+
if not (nome and resp_col in df.columns and "Data Coleta (dt)" in df.columns):
|
| 300 |
+
return pd.DataFrame(columns=["mes", "pct", "MROB", "ERROS"])
|
| 301 |
+
|
| 302 |
+
nome = str(nome).strip()
|
| 303 |
+
d = df[df[resp_col].astype(str).str.strip() == nome].copy()
|
| 304 |
+
d = d.dropna(subset=["Data Coleta (dt)"])
|
| 305 |
+
|
| 306 |
+
# Linha do tempo alvo (sempre haverá N meses)
|
| 307 |
+
base = _month_labels_last_n(months)
|
| 308 |
+
|
| 309 |
+
# Se não há dados, devolve zeros
|
| 310 |
+
if d.empty:
|
| 311 |
+
base["MROB"] = 0.0
|
| 312 |
+
base["ERROS"] = 0.0
|
| 313 |
+
base["pct"] = 0.0
|
| 314 |
+
return base[["mes", "pct", "MROB", "ERROS"]]
|
| 315 |
+
|
| 316 |
+
d["YM"] = d["Data Coleta (dt)"].dt.to_period("M")
|
| 317 |
+
|
| 318 |
+
g = (
|
| 319 |
+
d.groupby("YM", as_index=False)
|
| 320 |
+
.agg(MROB=("Linhas MROB", "sum"), ERROS=("Linhas Erros MROB", "sum"))
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
# Merge garante a linha do tempo completa — aqui o dtype pode virar 'object'
|
| 324 |
+
m = base.merge(g, on="YM", how="left")
|
| 325 |
+
|
| 326 |
+
# Coerção numérica robusta pós-merge (evita object -> round error)
|
| 327 |
+
m["MROB"] = pd.to_numeric(m["MROB"], errors="coerce").fillna(0).astype("float64")
|
| 328 |
+
m["ERROS"] = pd.to_numeric(m["ERROS"], errors="coerce").fillna(0).astype("float64")
|
| 329 |
+
|
| 330 |
+
# % acertos (evita divisão por zero, resultado sempre float)
|
| 331 |
+
m["pct"] = np.where(
|
| 332 |
+
m["MROB"] > 0,
|
| 333 |
+
((m["MROB"] - m["ERROS"]) / m["MROB"]) * 100.0,
|
| 334 |
+
0.0
|
| 335 |
+
)
|
| 336 |
+
m["pct"] = pd.to_numeric(m["pct"], errors="coerce").fillna(0).astype("float64").round(2)
|
| 337 |
+
|
| 338 |
+
# Seleciona e ordena colunas finais
|
| 339 |
+
out = m[["mes", "pct", "MROB", "ERROS"]].copy()
|
| 340 |
+
|
| 341 |
+
# Garantia de dtype correto (evita regressões futuras)
|
| 342 |
+
out["MROB"] = out["MROB"].astype("float64")
|
| 343 |
+
out["ERROS"] = out["ERROS"].astype("float64")
|
| 344 |
+
out["pct"] = out["pct"].astype("float64")
|
| 345 |
+
|
| 346 |
+
return out
|
| 347 |
+
|
| 348 |
+
def _mini_grafico_pct_mensal(df_m: pd.DataFrame, meta: float, chart_type: str = "Linha", show_meta: bool = True, titulo: str = "% Acertos por mês"):
|
| 349 |
+
"""
|
| 350 |
+
Renderiza mini‑gráfico compacto (% acertos | 0–100) com fallback:
|
| 351 |
+
1) Altair (linha/barras + meta) 2) Matplotlib 3) Tabela
|
| 352 |
+
"""
|
| 353 |
+
if df_m.empty:
|
| 354 |
+
st.caption("Sem dados mensais para o período/seleção atual.")
|
| 355 |
+
return
|
| 356 |
+
|
| 357 |
+
# 1) ALTair
|
| 358 |
+
if ALT_AVAILABLE:
|
| 359 |
+
try:
|
| 360 |
+
base = alt.Chart(df_m).encode(
|
| 361 |
+
x=alt.X("mes:N", title="Mês"),
|
| 362 |
+
y=alt.Y("pct:Q", title="% Acertos", scale=alt.Scale(domain=[0, 100])),
|
| 363 |
+
tooltip=[
|
| 364 |
+
alt.Tooltip("mes:N", title="Mês"),
|
| 365 |
+
alt.Tooltip("pct:Q", title="% Acertos (%)"),
|
| 366 |
+
alt.Tooltip("MROB:Q", title="MROB (Σ)"),
|
| 367 |
+
alt.Tooltip("ERROS:Q", title="Erros MROB (Σ)")
|
| 368 |
+
]
|
| 369 |
+
)
|
| 370 |
+
chart = base.mark_line(point=True, interpolate="monotone", color="#0d6efd") if chart_type == "Linha" \
|
| 371 |
+
else base.mark_bar(size=18, color="#0d6efd")
|
| 372 |
+
|
| 373 |
+
final = chart.properties(width=260, height=150, title=titulo)
|
| 374 |
+
|
| 375 |
+
if show_meta:
|
| 376 |
+
meta_df = pd.DataFrame({"y": [meta]})
|
| 377 |
+
meta_rule = alt.Chart(meta_df).mark_rule(color="#16a34a", strokeDash=[6, 4]).encode(y="y:Q")
|
| 378 |
+
final = final + meta_rule
|
| 379 |
+
|
| 380 |
+
st.altair_chart(final, use_container_width=False)
|
| 381 |
+
return
|
| 382 |
+
except Exception as e:
|
| 383 |
+
st.info(f"Render ALTair indisponível, usando fallback (detalhe: {e})")
|
| 384 |
+
|
| 385 |
+
# 2) Matplotlib fallback
|
| 386 |
+
try:
|
| 387 |
+
fig, ax = plt.subplots(figsize=(3.2, 1.6), dpi=150)
|
| 388 |
+
x = list(range(len(df_m["mes"])))
|
| 389 |
+
if chart_type == "Linha":
|
| 390 |
+
ax.plot(x, df_m["pct"].values, marker="o", color="#0d6efd", linewidth=1.5)
|
| 391 |
+
else:
|
| 392 |
+
ax.bar(x, df_m["pct"].values, color="#0d6efd", width=0.6)
|
| 393 |
+
if show_meta:
|
| 394 |
+
ax.axhline(y=meta, color="#16a34a", linestyle="--", linewidth=1)
|
| 395 |
+
|
| 396 |
+
ax.set_ylim(0, 100)
|
| 397 |
+
ax.set_xticks(x)
|
| 398 |
+
ax.set_xticklabels(df_m["mes"].tolist(), rotation=0, fontsize=7)
|
| 399 |
+
ax.set_yticks([0, 20, 40, 60, 80, 100])
|
| 400 |
+
ax.set_title(titulo, fontsize=9)
|
| 401 |
+
ax.grid(alpha=0.15, axis="y")
|
| 402 |
+
|
| 403 |
+
plt.tight_layout()
|
| 404 |
+
st.pyplot(fig, use_container_width=False)
|
| 405 |
+
plt.close(fig)
|
| 406 |
+
return
|
| 407 |
+
except Exception as e:
|
| 408 |
+
st.warning(f"Não foi possível renderizar o mini‑gráfico (fallback MPL): {e}")
|
| 409 |
+
|
| 410 |
+
# 3) Último recurso
|
| 411 |
+
st.caption("Exibindo dados da série por impossibilidade de gráfico:")
|
| 412 |
+
st.dataframe(df_m, use_container_width=True)
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
# ===============================
|
| 416 |
+
# MAIN
|
| 417 |
+
# ===============================
|
| 418 |
+
def main():
|
| 419 |
+
|
| 420 |
+
# 🧹 LIMPA ESTADO AO ENTRAR
|
| 421 |
+
if not st.session_state.get("_prod_esp_inicializado"):
|
| 422 |
+
limpar_estado_prod_esp()
|
| 423 |
+
st.session_state["_prod_esp_inicializado"] = True
|
| 424 |
+
|
| 425 |
+
st.title("🏆 Produtividade por Especialista e Conferente")
|
| 426 |
+
|
| 427 |
+
# 🔧 CONTROLES NA SIDEBAR
|
| 428 |
+
with st.sidebar:
|
| 429 |
+
st.markdown("### 🔄 Atualização automática")
|
| 430 |
+
auto_on = st.checkbox("Ativar atualização automática", value=True, key="prod_esp_auto_on")
|
| 431 |
+
auto_interval_s = st.slider("Intervalo (segundos)", min_value=10, max_value=300, value=30, step=5, key="prod_esp_auto_int")
|
| 432 |
+
|
| 433 |
+
if "prod_esp_auto_int_effective" not in st.session_state:
|
| 434 |
+
st.session_state["prod_esp_auto_int_effective"] = auto_interval_s
|
| 435 |
+
|
| 436 |
+
if st.button("✅ Aplicar intervalo"):
|
| 437 |
+
st.session_state["prod_esp_auto_int_effective"] = auto_interval_s
|
| 438 |
+
st.success(f"Intervalo atualizado para {auto_interval_s}s")
|
| 439 |
+
st.rerun()
|
| 440 |
+
|
| 441 |
+
intervalo_efetivo = st.session_state.get("prod_esp_auto_int_effective", auto_interval_s)
|
| 442 |
+
st.caption(f"⏲️ Intervalo atual: **{intervalo_efetivo}s**")
|
| 443 |
+
|
| 444 |
+
st.markdown("---")
|
| 445 |
+
st.markdown("### 🎯 Metas e Série")
|
| 446 |
+
meta_pct_especialistas = st.number_input("Meta (% MROB/Geral) — Especialistas", min_value=0.0, max_value=100.0, value=98.8, step=0.5, key="prod_esp_meta_pct_esp")
|
| 447 |
+
meta_pct_conferentes = st.number_input("Meta (% MROB/Geral) — Conferentes", min_value=0.0, max_value=100.0, value=98.8, step=0.5, key="prod_esp_meta_pct_conf")
|
| 448 |
+
serie_meses = st.slider("Meses no mini‑gráfico", min_value=3, max_value=12, value=6, step=1, key="prod_esp_serie_meses")
|
| 449 |
+
tipo_grafico = st.selectbox("Tipo do mini‑gráfico", ["Linha", "Barras"], index=0, key="prod_esp_tipo_grafico")
|
| 450 |
+
linha_meta = st.checkbox("Mostrar linha de meta", value=True, key="prod_esp_show_meta")
|
| 451 |
+
|
| 452 |
+
st.markdown("---")
|
| 453 |
+
last_dt = st.session_state.get("prod_esp_last_update_dt")
|
| 454 |
+
if last_dt:
|
| 455 |
+
last_str = last_dt.strftime("%d/%m/%Y %H:%M:%S")
|
| 456 |
+
st.caption(f"🕒 Última atualização: **{last_str}**")
|
| 457 |
+
delta = datetime.now() - last_dt
|
| 458 |
+
if delta < timedelta(minutes=1):
|
| 459 |
+
ago_str = f"{delta.seconds}s"
|
| 460 |
+
elif delta < timedelta(hours=1):
|
| 461 |
+
mins = delta.seconds // 60
|
| 462 |
+
secs = delta.seconds % 60
|
| 463 |
+
ago_str = f"{mins}min {secs}s"
|
| 464 |
+
else:
|
| 465 |
+
hours = delta.seconds // 3600
|
| 466 |
+
mins = (delta.seconds % 3600) // 60
|
| 467 |
+
ago_str = f"{hours}h {mins}min"
|
| 468 |
+
st.caption(f"⏱️ Atualizado há **{ago_str}**")
|
| 469 |
+
if auto_on:
|
| 470 |
+
try:
|
| 471 |
+
nxt = (datetime.now() + timedelta(seconds=intervalo_efetivo)).strftime("%d/%m/%Y %H:%M:%S")
|
| 472 |
+
st.caption(f"🔁 Próximo refresh: **{nxt}**")
|
| 473 |
+
except Exception:
|
| 474 |
+
pass
|
| 475 |
+
else:
|
| 476 |
+
st.caption("🕒 Última atualização: **—**")
|
| 477 |
+
|
| 478 |
+
if auto_on:
|
| 479 |
+
st_autorefresh(interval=intervalo_efetivo * 1000, limit=None, key="prod_esp_autorefresh")
|
| 480 |
+
|
| 481 |
+
db = SessionLocal()
|
| 482 |
+
try:
|
| 483 |
+
registros = db.query(Equipamento).all()
|
| 484 |
+
st.session_state["prod_esp_last_update_dt"] = datetime.now()
|
| 485 |
+
|
| 486 |
+
if not registros:
|
| 487 |
+
st.info("Nenhum registro encontrado.")
|
| 488 |
+
return
|
| 489 |
+
|
| 490 |
+
# ========== BASE DF ==========
|
| 491 |
+
df = pd.DataFrame([{
|
| 492 |
+
"FPSO": getattr(r, "fpso", None),
|
| 493 |
+
"Data Coleta": getattr(r, "data_coleta", None),
|
| 494 |
+
"Modal": getattr(r, "modal", None),
|
| 495 |
+
"Especialista": getattr(r, "especialista", None),
|
| 496 |
+
"Conferente": getattr(r, "conferente", None),
|
| 497 |
+
"Linhas OSM": getattr(r, "linhas_osm", 0),
|
| 498 |
+
"Linhas MROB": getattr(r, "linhas_mrob", 0),
|
| 499 |
+
"Linhas Erros MROB": getattr(r, "linhas_erros_mrob", None),
|
| 500 |
+
"Linhas Erros (Genérico)": getattr(r, "linhas_erros", None),
|
| 501 |
+
} for r in registros])
|
| 502 |
+
|
| 503 |
+
# Conversão robusta de datas
|
| 504 |
+
df["Data Coleta (dt)"] = pd.to_datetime(df["Data Coleta"], errors="coerce", dayfirst=True)
|
| 505 |
+
if df["Data Coleta (dt)"].isna().all():
|
| 506 |
+
# tenta novamente sem dayfirst
|
| 507 |
+
df["Data Coleta (dt)"] = pd.to_datetime(df["Data Coleta"], errors="coerce", dayfirst=False)
|
| 508 |
+
|
| 509 |
+
# Tipos numéricos
|
| 510 |
+
for col in ["Linhas OSM", "Linhas MROB", "Linhas Erros MROB", "Linhas Erros (Genérico)"]:
|
| 511 |
+
if col in df.columns:
|
| 512 |
+
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype("int64")
|
| 513 |
+
|
| 514 |
+
# Fallback de erros MROB
|
| 515 |
+
if ("Linhas Erros MROB" not in df.columns) or (df["Linhas Erros MROB"].sum() == 0 and df["Linhas Erros (Genérico)"].sum() > 0):
|
| 516 |
+
df["Linhas Erros MROB"] = df.get("Linhas Erros (Genérico)", pd.Series([0] * len(df)))
|
| 517 |
+
|
| 518 |
+
# Normaliza nomes
|
| 519 |
+
df = _normalize_responsaveis(df)
|
| 520 |
+
|
| 521 |
+
# ======== Fotos (cadastro/visualização) ========
|
| 522 |
+
_ui_fotos_responsaveis(df)
|
| 523 |
+
|
| 524 |
+
# ========== FILTROS ==========
|
| 525 |
+
st.subheader("🔎 Filtros")
|
| 526 |
+
col1, col2, col3 = st.columns(3)
|
| 527 |
+
with col1:
|
| 528 |
+
filtro_fpso = st.multiselect("FPSO", sorted(df["FPSO"].dropna().unique()), key="prod_esp_fpso")
|
| 529 |
+
with col2:
|
| 530 |
+
filtro_modal = st.multiselect("Modal", sorted(df["Modal"].dropna().unique()), key="prod_esp_modal")
|
| 531 |
+
with col3:
|
| 532 |
+
periodo = st.date_input("Período de Coleta", value=None, key="prod_esp_periodo")
|
| 533 |
+
|
| 534 |
+
df_filt = df.copy()
|
| 535 |
+
if filtro_fpso:
|
| 536 |
+
df_filt = df_filt[df_filt["FPSO"].isin(filtro_fpso)]
|
| 537 |
+
if filtro_modal:
|
| 538 |
+
df_filt = df_filt[df_filt["Modal"].isin(filtro_modal)]
|
| 539 |
+
if isinstance(periodo, (list, tuple)) and len(periodo) == 2:
|
| 540 |
+
data_inicio, data_fim = periodo
|
| 541 |
+
if pd.notna(data_inicio):
|
| 542 |
+
df_filt = df_filt[df_filt["Data Coleta (dt)"] >= pd.to_datetime(data_inicio)]
|
| 543 |
+
if pd.notna(data_fim):
|
| 544 |
+
df_filt = df_filt[df_filt["Data Coleta (dt)"] <= pd.to_datetime(data_fim) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)]
|
| 545 |
+
|
| 546 |
+
# ======== Mapeamentos por responsável ========
|
| 547 |
+
fpsos_por_especialista = (
|
| 548 |
+
df_filt.groupby("Especialista", dropna=False)["FPSO"]
|
| 549 |
+
.apply(lambda x: ", ".join(sorted(set(x.dropna()))))
|
| 550 |
+
.to_dict()
|
| 551 |
+
)
|
| 552 |
+
fpsos_por_conferente = (
|
| 553 |
+
df_filt.groupby("Conferente", dropna=False)["FPSO"]
|
| 554 |
+
.apply(lambda x: ", ".join(sorted(set(x.dropna()))))
|
| 555 |
+
.to_dict()
|
| 556 |
+
)
|
| 557 |
+
|
| 558 |
+
# ======== Agregações ========
|
| 559 |
+
grp_esp = (df_filt.groupby("Especialista", dropna=False)
|
| 560 |
+
.agg({"Linhas OSM":"sum","Linhas MROB":"sum","Linhas Erros MROB":"sum"})
|
| 561 |
+
.reset_index())
|
| 562 |
+
grp_esp["FPSO Responsável"] = grp_esp["Especialista"].map(lambda e: fpsos_por_especialista.get(e, ""))
|
| 563 |
+
grp_esp["Especialista (FPSO)"] = grp_esp.apply(
|
| 564 |
+
lambda r: f"{r['Especialista']} ({r['FPSO Responsável']})" if r["FPSO Responsável"] else str(r["Especialista"]), axis=1)
|
| 565 |
+
grp_esp["Total de Erros (MROB - Erros MROB)"] = (grp_esp["Linhas MROB"] - grp_esp["Linhas Erros MROB"]).clip(lower=0)
|
| 566 |
+
|
| 567 |
+
# ✅ Denominador numérico (float) para evitar dtype object
|
| 568 |
+
denom_mrob_esp = pd.to_numeric(grp_esp["Linhas MROB"], errors="coerce").replace(0, np.nan).astype("float64")
|
| 569 |
+
num_acertos_esp = pd.to_numeric(grp_esp["Linhas MROB"], errors="coerce") - pd.to_numeric(grp_esp["Linhas Erros MROB"], errors="coerce")
|
| 570 |
+
num_erros_esp = pd.to_numeric(grp_esp["Linhas Erros MROB"], errors="coerce")
|
| 571 |
+
|
| 572 |
+
grp_esp["% Acertos (MROB)"] = (num_acertos_esp / denom_mrob_esp * 100.0).round(2)
|
| 573 |
+
grp_esp["% Erros (MROB)"] = (num_erros_esp / denom_mrob_esp * 100.0).round(2)
|
| 574 |
+
|
| 575 |
+
grp_esp = grp_esp.sort_values(by="Linhas OSM", ascending=False)
|
| 576 |
+
grp_esp = grp_esp[[
|
| 577 |
+
"Especialista (FPSO)","Especialista","FPSO Responsável",
|
| 578 |
+
"Linhas OSM","Linhas MROB","Linhas Erros MROB",
|
| 579 |
+
"Total de Erros (MROB - Erros MROB)","% Acertos (MROB)","% Erros (MROB)"
|
| 580 |
+
]]
|
| 581 |
+
|
| 582 |
+
grp_conf = (df_filt.groupby("Conferente", dropna=False)
|
| 583 |
+
.agg({"Linhas OSM":"sum","Linhas MROB":"sum","Linhas Erros MROB":"sum"})
|
| 584 |
+
.reset_index())
|
| 585 |
+
grp_conf["FPSO Responsável"] = grp_conf["Conferente"].map(lambda c: fpsos_por_conferente.get(c, ""))
|
| 586 |
+
grp_conf["Conferente (FPSO)"] = grp_conf.apply(
|
| 587 |
+
lambda r: f"{r['Conferente']} ({r['FPSO Responsável']})" if r["FPSO Responsável"] else str(r["Conferente"]), axis=1)
|
| 588 |
+
grp_conf["Total de Erros (MROB - Erros MROB)"] = (grp_conf["Linhas MROB"] - grp_conf["Linhas Erros MROB"]).clip(lower=0)
|
| 589 |
+
|
| 590 |
+
# ✅ Denominador numérico (float) para evitar dtype object
|
| 591 |
+
denom_mrob_conf = pd.to_numeric(grp_conf["Linhas MROB"], errors="coerce").replace(0, np.nan).astype("float64")
|
| 592 |
+
num_acertos_conf = pd.to_numeric(grp_conf["Linhas MROB"], errors="coerce") - pd.to_numeric(grp_conf["Linhas Erros MROB"], errors="coerce")
|
| 593 |
+
num_erros_conf = pd.to_numeric(grp_conf["Linhas Erros MROB"], errors="coerce")
|
| 594 |
+
|
| 595 |
+
grp_conf["% Acertos (MROB)"] = (num_acertos_conf / denom_mrob_conf * 100.0).round(2)
|
| 596 |
+
grp_conf["% Erros (MROB)"] = (num_erros_conf / denom_mrob_conf * 100.0).round(2)
|
| 597 |
+
|
| 598 |
+
grp_conf = grp_conf.sort_values(by="Linhas OSM", ascending=False)
|
| 599 |
+
grp_conf = grp_conf[[
|
| 600 |
+
"Conferente (FPSO)","Conferente","FPSO Responsável",
|
| 601 |
+
"Linhas OSM","Linhas MROB","Linhas Erros MROB",
|
| 602 |
+
"Total de Erros (MROB - Erros MROB)","% Acertos (MROB)","% Erros (MROB)"
|
| 603 |
+
]]
|
| 604 |
+
|
| 605 |
+
# ======== KPIs Gerais ========
|
| 606 |
+
st.subheader("📈 KPIs (dados filtrados) — Geral (Todos)")
|
| 607 |
+
total_especialistas = grp_esp["Especialista"].nunique()
|
| 608 |
+
total_conferentes = grp_conf["Conferente"].nunique()
|
| 609 |
+
total_osm_geral = int(df_filt["Linhas OSM"].sum())
|
| 610 |
+
total_mrob_geral = int(df_filt["Linhas MROB"].sum())
|
| 611 |
+
total_erros_mrob_geral = int(df_filt["Linhas Erros MROB"].sum())
|
| 612 |
+
total_acertos_mrob_geral = (total_mrob_geral - total_erros_mrob_geral)
|
| 613 |
+
pct_acertos_geral = round((total_acertos_mrob_geral / total_mrob_geral * 100), 2) if total_mrob_geral > 0 else 0.0
|
| 614 |
+
pct_erros_geral = round((total_erros_mrob_geral / total_mrob_geral * 100), 2) if total_mrob_geral > 0 else 0.0
|
| 615 |
+
|
| 616 |
+
k1,k2,k3,k4,k5 = st.columns(5)
|
| 617 |
+
k1.metric("Especialistas", f"{total_especialistas}")
|
| 618 |
+
k2.metric("Conferentes", f"{total_conferentes}")
|
| 619 |
+
k3.metric("Linhas OSM (Σ)", f"{total_osm_geral:,}".replace(",", "."))
|
| 620 |
+
k4.metric("Linhas MROB (Σ)", f"{total_mrob_geral:,}".replace(",", "."))
|
| 621 |
+
color_geral = "#198754" if pct_acertos_geral >= meta_pct_especialistas else "#dc3545"
|
| 622 |
+
k5.metric("% Acertos (MROB/Geral)", f"{pct_acertos_geral}%")
|
| 623 |
+
# 🔧 HTML deve usar <span>...<span>, não entidades <>
|
| 624 |
+
st.markdown(
|
| 625 |
+
f"<span style='color:{color_geral}'>Meta (Especialistas): {meta_pct_especialistas}% • "
|
| 626 |
+
f"{'✅ Dentro da meta' if pct_acertos_geral >= meta_pct_especialistas else '⚠️ Abaixo da meta'}</span>",
|
| 627 |
+
unsafe_allow_html=True
|
| 628 |
+
)
|
| 629 |
+
st.markdown(f"<span style='color:#dc3545'>% Erros (MROB/Geral): {pct_erros_geral}%</span>", unsafe_allow_html=True)
|
| 630 |
+
st.divider()
|
| 631 |
+
|
| 632 |
+
# ======== KPIs por Especialista (foto + mini‑gráfico) ========
|
| 633 |
+
st.subheader("🎯 KPIs por Especialista")
|
| 634 |
+
especialistas_lista = ["(selecione)"] + list(grp_esp["Especialista"].astype(str).unique())
|
| 635 |
+
esp_sel = st.selectbox("Especialista:", especialistas_lista, index=0, key="prod_esp_kpi_esp")
|
| 636 |
+
|
| 637 |
+
if esp_sel and esp_sel != "(selecione)":
|
| 638 |
+
linha_esp = grp_esp[grp_esp["Especialista"] == esp_sel]
|
| 639 |
+
if not linha_esp.empty:
|
| 640 |
+
le_osm = int(linha_esp["Linhas OSM"].iloc[0])
|
| 641 |
+
le_mrob = int(linha_esp["Linhas MROB"].iloc[0])
|
| 642 |
+
le_err_mrob = int(linha_esp["Linhas Erros MROB"].iloc[0])
|
| 643 |
+
le_total_err = int(linha_esp["Total de Erros (MROB - Erros MROB)"].iloc[0])
|
| 644 |
+
le_pct_acertos = float(linha_esp["% Acertos (MROB)"].iloc[0]) if pd.notna(linha_esp["% Acertos (MROB)"].iloc[0]) else 0.0
|
| 645 |
+
le_pct_erros = float(linha_esp["% Erros (MROB)"].iloc[0]) if pd.notna(linha_esp["% Erros (MROB)"].iloc[0]) else 0.0
|
| 646 |
+
|
| 647 |
+
col_pic, col_chart, col_metrics = st.columns([1, 1.4, 3])
|
| 648 |
+
with col_pic:
|
| 649 |
+
dbp = SessionLocal()
|
| 650 |
+
try:
|
| 651 |
+
img_b, mt, updt = _get_foto(dbp, "especialista", esp_sel)
|
| 652 |
+
if img_b:
|
| 653 |
+
st.image(img_b, caption=f"{esp_sel}", use_container_width=False, width=220)
|
| 654 |
+
else:
|
| 655 |
+
st.caption("Sem foto cadastrada.")
|
| 656 |
+
finally:
|
| 657 |
+
dbp.close()
|
| 658 |
+
with col_chart:
|
| 659 |
+
serie = _serie_pct_mensal(df_filt, "Especialista", esp_sel, months=serie_meses)
|
| 660 |
+
_mini_grafico_pct_mensal(serie, meta=meta_pct_especialistas, chart_type=tipo_grafico, show_meta=linha_meta)
|
| 661 |
+
with st.expander("🔧 Diagnóstico da série (Especialista)", expanded=False):
|
| 662 |
+
st.dataframe(serie, use_container_width=True)
|
| 663 |
+
with col_metrics:
|
| 664 |
+
s1,s2,s3,s4,s5 = st.columns(5)
|
| 665 |
+
s1.metric("Linhas OSM", f"{le_osm:,}".replace(",", "."))
|
| 666 |
+
s2.metric("Linhas MROB", f"{le_mrob:,}".replace(",", "."))
|
| 667 |
+
s3.metric("Erros MROB", f"{le_err_mrob:,}".replace(",", "."))
|
| 668 |
+
s4.metric("Total Erros (MROB−Erros)", f"{le_total_err:,}".replace(",", "."))
|
| 669 |
+
s5.metric("% Acertos (MROB)", f"{le_pct_acertos}%")
|
| 670 |
+
# 🔧 HTML deve usar <span>...<span>, não entidades <>
|
| 671 |
+
st.markdown(f"<span style='color:#dc3545'>% Erros (MROB): {le_pct_erros}%</span>", unsafe_allow_html=True)
|
| 672 |
+
st.divider()
|
| 673 |
+
|
| 674 |
+
# ======== KPIs por Conferente (foto + mini‑gráfico) ========
|
| 675 |
+
st.subheader("🎯 KPIs por Conferente")
|
| 676 |
+
conferentes_lista = ["(selecione)"] + list(grp_conf["Conferente"].astype(str).unique())
|
| 677 |
+
conf_sel = st.selectbox("Conferente:", conferentes_lista, index=0, key="prod_esp_kpi_conf")
|
| 678 |
+
|
| 679 |
+
if conf_sel and conf_sel != "(selecione)":
|
| 680 |
+
linha_conf = grp_conf[grp_conf["Conferente"] == conf_sel]
|
| 681 |
+
if not linha_conf.empty:
|
| 682 |
+
lc_osm = int(linha_conf["Linhas OSM"].iloc[0])
|
| 683 |
+
lc_mrob = int(linha_conf["Linhas MROB"].iloc[0])
|
| 684 |
+
lc_err_mrob = int(linha_conf["Linhas Erros MROB"].iloc[0])
|
| 685 |
+
lc_total_err = int(linha_conf["Total de Erros (MROB - Erros MROB)"].iloc[0])
|
| 686 |
+
lc_pct_acertos = float(linha_conf["% Acertos (MROB)"].iloc[0]) if pd.notna(linha_conf["% Acertos (MROB)"].iloc[0]) else 0.0
|
| 687 |
+
lc_pct_erros = float(linha_conf["% Erros (MROB)"].iloc[0]) if pd.notna(linha_conf["% Erros (MROB)"].iloc[0]) else 0.0
|
| 688 |
+
|
| 689 |
+
col_pic2, col_chart2, col_metrics2 = st.columns([1, 1.4, 3])
|
| 690 |
+
with col_pic2:
|
| 691 |
+
dbp = SessionLocal()
|
| 692 |
+
try:
|
| 693 |
+
img_b, mt, updt = _get_foto(dbp, "conferente", conf_sel)
|
| 694 |
+
if img_b:
|
| 695 |
+
st.image(img_b, caption=f"{conf_sel}", use_container_width=False, width=220)
|
| 696 |
+
else:
|
| 697 |
+
st.caption("Sem foto cadastrada.")
|
| 698 |
+
finally:
|
| 699 |
+
dbp.close()
|
| 700 |
+
with col_chart2:
|
| 701 |
+
serie2 = _serie_pct_mensal(df_filt, "Conferente", conf_sel, months=serie_meses)
|
| 702 |
+
_mini_grafico_pct_mensal(serie2, meta=meta_pct_conferentes, chart_type=tipo_grafico, show_meta=linha_meta)
|
| 703 |
+
with st.expander("🔧 Diagnóstico da série (Conferente)", expanded=False):
|
| 704 |
+
st.dataframe(serie2, use_container_width=True)
|
| 705 |
+
with col_metrics2:
|
| 706 |
+
d1,d2,d3,d4,d5 = st.columns(5)
|
| 707 |
+
d1.metric("Linhas OSM", f"{lc_osm:,}".replace(",", "."))
|
| 708 |
+
d2.metric("Linhas MROB", f"{lc_mrob:,}".replace(",", "."))
|
| 709 |
+
d3.metric("Erros MROB", f"{lc_err_mrob:,}".replace(",", "."))
|
| 710 |
+
d4.metric("Total Erros (MROB−Erros)", f"{lc_total_err:,}".replace(",", "."))
|
| 711 |
+
d5.metric("% Acertos (MROB)", f"{lc_pct_acertos}%")
|
| 712 |
+
# 🔧 HTML deve usar <span>...<span>, não entidades <>
|
| 713 |
+
st.markdown(f"<span style='color:#dc3545'>% Erros (MROB): {lc_pct_erros}%</span>", unsafe_allow_html=True)
|
| 714 |
+
|
| 715 |
+
st.divider()
|
| 716 |
+
|
| 717 |
+
# ======== Listas e Gráficos maiores ========
|
| 718 |
+
st.subheader("🧾 Lista por Especialista (com métricas)")
|
| 719 |
+
st.dataframe(grp_esp, use_container_width=True)
|
| 720 |
+
|
| 721 |
+
st.subheader("🧾 Lista por Conferente (com métricas)")
|
| 722 |
+
st.dataframe(grp_conf, use_container_width=True)
|
| 723 |
+
|
| 724 |
+
st.subheader("📊 Gráficos")
|
| 725 |
+
try:
|
| 726 |
+
st.caption("Linhas OSM por Especialista (FPSO)")
|
| 727 |
+
st.bar_chart(data=grp_esp.set_index("Especialista (FPSO)")["Linhas OSM"])
|
| 728 |
+
st.caption("Linhas MROB por Especialista (FPSO)")
|
| 729 |
+
st.bar_chart(data=grp_esp.set_index("Especialista (FPSO)")["Linhas MROB"])
|
| 730 |
+
st.caption("Linhas de Erros MROB por Especialista (FPSO)")
|
| 731 |
+
st.bar_chart(data=grp_esp.set_index("Especialista (FPSO)")["Linhas Erros MROB"])
|
| 732 |
+
st.caption("Linhas OSM por Conferente (FPSO)")
|
| 733 |
+
st.bar_chart(data=grp_conf.set_index("Conferente (FPSO)")["Linhas OSM"])
|
| 734 |
+
st.caption("Linhas MROB por Conferente (FPSO)")
|
| 735 |
+
st.bar_chart(data=grp_conf.set_index("Conferente (FPSO)")["Linhas MROB"])
|
| 736 |
+
st.caption("Linhas de Erros MROB por Conferente (FPSO)")
|
| 737 |
+
st.bar_chart(data=grp_conf.set_index("Conferente (FPSO)")["Linhas Erros MROB"])
|
| 738 |
+
except Exception as e:
|
| 739 |
+
st.warning(f"Não foi possível renderizar alguns gráficos: {e}")
|
| 740 |
+
|
| 741 |
+
st.divider()
|
| 742 |
+
|
| 743 |
+
# ======== Exportação ========
|
| 744 |
+
st.subheader("⬇️ Exportar")
|
| 745 |
+
buffer_esp = BytesIO()
|
| 746 |
+
with pd.ExcelWriter(buffer_esp, engine="openpyxl") as writer:
|
| 747 |
+
grp_esp.to_excel(writer, index=False, sheet_name="Prod_Especialista")
|
| 748 |
+
buffer_esp.seek(0)
|
| 749 |
+
st.download_button(
|
| 750 |
+
label="⬇️ Exportar produtividade por Especialista (Excel)",
|
| 751 |
+
data=buffer_esp,
|
| 752 |
+
file_name="produtividade_especialista.xlsx",
|
| 753 |
+
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 754 |
+
key="prod_esp_export"
|
| 755 |
+
)
|
| 756 |
+
|
| 757 |
+
buffer_conf = BytesIO()
|
| 758 |
+
with pd.ExcelWriter(buffer_conf, engine="openpyxl") as writer:
|
| 759 |
+
grp_conf.to_excel(writer, index=False, sheet_name="Prod_Conferente")
|
| 760 |
+
buffer_conf.seek(0)
|
| 761 |
+
st.download_button(
|
| 762 |
+
label="⬇️ Exportar produtividade por Conferente (Excel)",
|
| 763 |
+
data=buffer_conf,
|
| 764 |
+
file_name="produtividade_conferente.xlsx",
|
| 765 |
+
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 766 |
+
key="prod_conf_export"
|
| 767 |
+
)
|
| 768 |
+
|
| 769 |
+
finally:
|
| 770 |
+
db.close()
|
| 771 |
+
|
| 772 |
+
|
| 773 |
+
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
|
| 777 |
+
|
| 778 |
+
|
add_pergunta.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from banco import SessionLocal
|
| 2 |
+
from models import QuizPergunta, QuizResposta
|
| 3 |
+
|
| 4 |
+
def adicionar_pergunta(pergunta_texto, respostas, correta_index):
|
| 5 |
+
db = SessionLocal()
|
| 6 |
+
try:
|
| 7 |
+
# Cria a pergunta
|
| 8 |
+
pergunta = QuizPergunta(pergunta=pergunta_texto)
|
| 9 |
+
db.add(pergunta)
|
| 10 |
+
db.commit() # Gera o ID da pergunta para usar nas respostas
|
| 11 |
+
db.refresh(pergunta) # Atualiza o objeto com o ID do banco
|
| 12 |
+
|
| 13 |
+
# Cria as respostas vinculadas à pergunta
|
| 14 |
+
for i, texto in enumerate(respostas):
|
| 15 |
+
resposta = QuizResposta(
|
| 16 |
+
pergunta_id=pergunta.id,
|
| 17 |
+
texto=texto,
|
| 18 |
+
correta=(i == correta_index)
|
| 19 |
+
)
|
| 20 |
+
db.add(resposta)
|
| 21 |
+
|
| 22 |
+
db.commit()
|
| 23 |
+
print(f"Pergunta '{pergunta_texto}' adicionada com sucesso.")
|
| 24 |
+
except Exception as e:
|
| 25 |
+
db.rollback()
|
| 26 |
+
print(f"Erro ao adicionar pergunta: {e}")
|
| 27 |
+
finally:
|
| 28 |
+
db.close()
|
| 29 |
+
|
| 30 |
+
if __name__ == "__main__":
|
| 31 |
+
adicionar_pergunta(
|
| 32 |
+
"O que significa FPSO?",
|
| 33 |
+
[
|
| 34 |
+
"Floating Production Storage and Offloading",
|
| 35 |
+
"Fixed Production Storage Offshore",
|
| 36 |
+
"Floating Processing Supply Operation"
|
| 37 |
+
],
|
| 38 |
+
0
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
adicionar_pergunta(
|
| 42 |
+
"Qual é a principal função de um FPSO?",
|
| 43 |
+
[
|
| 44 |
+
"Armazenar contêineres",
|
| 45 |
+
"Produzir, armazenar e transferir petróleo",
|
| 46 |
+
"Transporte de passageiros"
|
| 47 |
+
],
|
| 48 |
+
1
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
adicionar_pergunta(
|
| 52 |
+
"Onde normalmente um FPSO opera?",
|
| 53 |
+
[
|
| 54 |
+
"Em portos comerciais",
|
| 55 |
+
"Em rios navegáveis",
|
| 56 |
+
"Em águas profundas e ultraprofundas"
|
| 57 |
+
],
|
| 58 |
+
2
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
adicionar_pergunta(
|
| 62 |
+
"Qual produto NÃO é normalmente processado em um FPSO?",
|
| 63 |
+
[
|
| 64 |
+
"Petróleo",
|
| 65 |
+
"Gás natural",
|
| 66 |
+
"Carvão mineral"
|
| 67 |
+
],
|
| 68 |
+
2
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
adicionar_pergunta(
|
| 72 |
+
"Qual etapa vem após a produção do petróleo em um FPSO?",
|
| 73 |
+
[
|
| 74 |
+
"Refino completo",
|
| 75 |
+
"Armazenamento e offloading",
|
| 76 |
+
"Transporte rodoviário"
|
| 77 |
+
],
|
| 78 |
+
1
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
adicionar_pergunta(
|
| 82 |
+
"O que significa o termo 'offloading'?",
|
| 83 |
+
[
|
| 84 |
+
"Processo de perfuração",
|
| 85 |
+
"Transferência de petróleo para navios aliviadores",
|
| 86 |
+
"Separação de óleo e gás"
|
| 87 |
+
],
|
| 88 |
+
1
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
adicionar_pergunta(
|
| 92 |
+
"Qual profissional é mais associado à operação diária de um FPSO?",
|
| 93 |
+
[
|
| 94 |
+
"Piloto de avião",
|
| 95 |
+
"Operador de produção offshore",
|
| 96 |
+
"Motorista de caminhão"
|
| 97 |
+
],
|
| 98 |
+
1
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
adicionar_pergunta(
|
| 102 |
+
"Qual risco é mais comum em operações offshore?",
|
| 103 |
+
[
|
| 104 |
+
"Congestionamento urbano",
|
| 105 |
+
"Derramamento de óleo",
|
| 106 |
+
"Falta de energia elétrica urbana"
|
| 107 |
+
],
|
| 108 |
+
1
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
adicionar_pergunta(
|
| 112 |
+
"Por que FPSOs são preferidos em campos distantes da costa?",
|
| 113 |
+
[
|
| 114 |
+
"Menor custo de construção",
|
| 115 |
+
"Dispensam oleodutos longos",
|
| 116 |
+
"Exigem menos tripulação"
|
| 117 |
+
],
|
| 118 |
+
1
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
adicionar_pergunta(
|
| 122 |
+
"Qual é um requisito essencial de segurança em FPSOs?",
|
| 123 |
+
[
|
| 124 |
+
"Plano de evacuação e emergência",
|
| 125 |
+
"Seguro veicular",
|
| 126 |
+
"Licença rodoviária"
|
| 127 |
+
],
|
| 128 |
+
0
|
| 129 |
+
)
|
administracao.py
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
import streamlit as st
|
| 4 |
+
from datetime import datetime, date
|
| 5 |
+
from banco import SessionLocal
|
| 6 |
+
from models import Equipamento
|
| 7 |
+
from log import registrar_log
|
| 8 |
+
from utils_fpso import campo_fpso
|
| 9 |
+
from utils_permissoes import verificar_permissao
|
| 10 |
+
|
| 11 |
+
# 🔎 Utilitários SQLAlchemy para diagnóstico e migração simples
|
| 12 |
+
from sqlalchemy import inspect, text
|
| 13 |
+
|
| 14 |
+
# ⬇️ Import seguro do modelo AvisoGlobal (não quebra se ainda não existir)
|
| 15 |
+
try:
|
| 16 |
+
from models import AvisoGlobal
|
| 17 |
+
_HAS_AVISO_GLOBAL = True
|
| 18 |
+
except Exception:
|
| 19 |
+
_HAS_AVISO_GLOBAL = False
|
| 20 |
+
|
| 21 |
+
# =====================================================
|
| 22 |
+
# LISTAS FIXAS
|
| 23 |
+
# =====================================================
|
| 24 |
+
MODAL_LISTA = ["", "AÉREO", "MARÍTIMO", "EXPRESSO"]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# =====================================================
|
| 28 |
+
# MENU INFO (DOCUMENTAÇÃO INTERNA DO SISTEMA)
|
| 29 |
+
# =====================================================
|
| 30 |
+
def menu_info():
|
| 31 |
+
|
| 32 |
+
# ✅ Apêndice de documentação: novas funcionalidades e módulos (adicional)
|
| 33 |
+
doc_appendix()
|
| 34 |
+
|
| 35 |
+
st.info("📌 Documentação interna do sistema. Acesso restrito a administradores.")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# =====================================================
|
| 40 |
+
# APÊNDICE DE DOCUMENTAÇÃO (NOVAS FUNCIONALIDADES)
|
| 41 |
+
# =====================================================
|
| 42 |
+
def doc_appendix():
|
| 43 |
+
"""
|
| 44 |
+
Adendo de documentação profissional que descreve
|
| 45 |
+
as novas funcionalidades, módulos e diretrizes sem
|
| 46 |
+
alterar o comportamento existente.
|
| 47 |
+
"""
|
| 48 |
+
st.divider()
|
| 49 |
+
st.subheader("📘 Atualizações e Diretrizes Profissionais")
|
| 50 |
+
|
| 51 |
+
# ✅ NOVO: documentação padronizada do Módulo Formulário dentro do apêndice
|
| 52 |
+
with st.expander("🧾 Módulo Formulário (padrão)", expanded=False):
|
| 53 |
+
st.markdown("""
|
| 54 |
+
**Objetivo**
|
| 55 |
+
Registrar, de forma padronizada, os dados operacionais de equipamentos (FPSO, Modal, OSM, MROB, métricas e administrativos), garantindo rastreabilidade e qualidade das informações.
|
| 56 |
+
|
| 57 |
+
**Funcionalidades**
|
| 58 |
+
- Sugestões para **FPSO** e **FPSO1** via `campo_fpso`
|
| 59 |
+
- Campo controlado **“Outro”** quando aplicável
|
| 60 |
+
- Validação de **campos obrigatórios** (ex.: FPSO, Modal, OSM, MROB)
|
| 61 |
+
- Registro automático de **data/hora** (`data_hora_input`)
|
| 62 |
+
- Persistência completa em **banco de dados** (tabela `equipamentos`)
|
| 63 |
+
- **Auditoria**: ações de criação/edição/exclusão registradas
|
| 64 |
+
|
| 65 |
+
**Campos Principais (Operacionais)**
|
| 66 |
+
- **FPSO / FPSO1**: identificação
|
| 67 |
+
- **Data de Coleta**
|
| 68 |
+
- **Especialista / Conferente / OSM**
|
| 69 |
+
- **Modal / Quantidade de Equipamentos / MROB**
|
| 70 |
+
- **Métricas**: Linhas OSM, Linhas MROB, Linhas com Erro
|
| 71 |
+
- **Erros**: Storekeeper, Operação WH, Especialista WH, Outros
|
| 72 |
+
- **Inclusão / Exclusão** (D1, D2, D3)
|
| 73 |
+
|
| 74 |
+
**Dados Administrativos**
|
| 75 |
+
- **PO**, **Part Number**, **Material**, **Nota Fiscal**
|
| 76 |
+
- **Solicitante / Requisitante**
|
| 77 |
+
- **Impacto / Dimensão**
|
| 78 |
+
- **Motivo** (Inclusão/Exclusão)
|
| 79 |
+
- **Observações** (campo livre)
|
| 80 |
+
|
| 81 |
+
**Validações**
|
| 82 |
+
- Checagem de obrigatoriedade em campos críticos
|
| 83 |
+
- Tratamento de valores ausentes (fallback seguro)
|
| 84 |
+
- Índices/sugestões pré-carregados (FPSO/Modal/OSM)
|
| 85 |
+
|
| 86 |
+
**Fluxo de Dados**
|
| 87 |
+
1. Usuário preenche o formulário com apoio de listas/sugestões
|
| 88 |
+
2. Sistema valida campos e persiste em `equipamentos`
|
| 89 |
+
3. Ação administrativa é registrada em **auditoria** (`log_acesso`)
|
| 90 |
+
4. Registros editáveis posteriormente via **Administração de Registros**
|
| 91 |
+
|
| 92 |
+
**Perfis / Permissões**
|
| 93 |
+
- Acesso controlado por **perfil** (admin / usuario / consulta) via `verificar_permissao`
|
| 94 |
+
|
| 95 |
+
**Impacto**
|
| 96 |
+
- **Padronização** dos cadastros
|
| 97 |
+
- **Redução de erros** operacionais
|
| 98 |
+
- **Rastreabilidade** completa (auditoria + carimbo de data/hora)
|
| 99 |
+
""")
|
| 100 |
+
|
| 101 |
+
with st.expander("📚 Estrutura de Módulos e Grupos (modules_map.py)", expanded=False):
|
| 102 |
+
st.markdown("""
|
| 103 |
+
- **Grupos suportados**: Operação Load, Backload, Operação, Terceiros, BI.
|
| 104 |
+
- Cada módulo deve ter: `key`, `label`, `descricao`, `perfis`, `grupo`.
|
| 105 |
+
- O **menu lateral** exibe: `Pesquisar módulo` → `Selecione a operação (grupo)` → `Selecione o módulo`.
|
| 106 |
+
- Grupos **sem módulos** (ou sem permissão) exibem: _“Em desenvolvimento”_.
|
| 107 |
+
- **Boas práticas**: labels padronizados, `key` único (sem acentos e espaços), controle de acesso via `perfis`.
|
| 108 |
+
""")
|
| 109 |
+
|
| 110 |
+
with st.expander("🧭 Navegação e UI (menu lateral)", expanded=False):
|
| 111 |
+
st.markdown("""
|
| 112 |
+
- **Pesquisa**: filtra módulos pelo `label`.
|
| 113 |
+
- **Selectbox de Operação**: lista grupos disponíveis.
|
| 114 |
+
- **Selectbox de Módulo**: exibe módulos filtrados por grupo e permissões.
|
| 115 |
+
- **Rodapé da sidebar**: apresenta **e-mail do usuário logado** (badge alinhado) e bloco de **versão + desenvolvedor**.
|
| 116 |
+
- **Layout**: `st.set_page_config(layout="wide")` habilitado, área de conteúdo responsiva.
|
| 117 |
+
""")
|
| 118 |
+
|
| 119 |
+
with st.expander("📧 E-mail do Usuário Logado (login + sidebar)", expanded=False):
|
| 120 |
+
st.markdown("""
|
| 121 |
+
- `login.py` grava na sessão: `st.session_state.email` e `st.session_state.nome` (se disponíveis).
|
| 122 |
+
- Rodapé da sidebar exibe o e-mail em **formato badge** com ícone e alinhamento (`inline-flex`).
|
| 123 |
+
- Caso o e-mail não apareça: verifique se o usuário possui e-mail cadastrado e/ou revalide o login.
|
| 124 |
+
""")
|
| 125 |
+
|
| 126 |
+
with st.expander("🧾 Auditoria com E-mail", expanded=False):
|
| 127 |
+
st.markdown("""
|
| 128 |
+
- O módulo de auditoria realiza **JOIN** com `Usuario` e agora inclui **E-mail** na consulta.
|
| 129 |
+
- Exportação para Excel também leva a coluna **E-mail**.
|
| 130 |
+
- Observação: `JOIN` padrão é interno; para logs órfãos, use `outerjoin` (se necessário).
|
| 131 |
+
""")
|
| 132 |
+
|
| 133 |
+
with st.expander("🛠️ Banco de Dados e Ferramentas (db_tools)", expanded=False):
|
| 134 |
+
st.markdown("""
|
| 135 |
+
- Em **SQLite** e **PostgreSQL**, as alterações (ex.: adicionar `nome` e `email` em `usuarios`) podem ser aplicadas via módulo **`db_tools`** com `ALTER TABLE` e criação de índice único (`email`).
|
| 136 |
+
- **Atenção**: `Base.metadata.create_all()` **não migra** tabelas existentes; para mudanças de esquema use `ALTER TABLE`, **Alembic** (recomendado) ou recrie o banco (backup antes).
|
| 137 |
+
- **Verificação de colunas**: `PRAGMA table_info(usuarios)` (SQLite) ou `information_schema.columns` (Postgres/MySQL).
|
| 138 |
+
""")
|
| 139 |
+
|
| 140 |
+
with st.expander("🎮 Jogos / Treinamento (módulo jogos)", expanded=False):
|
| 141 |
+
st.markdown("""
|
| 142 |
+
- **Jogo da Forca (Treinamento)**: perguntas por categoria, avanço de nível, contagem de tentativas.
|
| 143 |
+
- **Caça ao Tesouro (Níveis)**: pistas Sim/Não com feedback visual e avanço até o limite de perguntas.
|
| 144 |
+
- **Dado (Curiosidades)**: número de lados configurável, curiosidades de FPSO/Estoque/Óleo e Gás.
|
| 145 |
+
- **Pontuação e balões**: opção de efeitos visuais e pontuação acumulada.
|
| 146 |
+
""")
|
| 147 |
+
|
| 148 |
+
with st.expander("🧠 Quiz e Ranking", expanded=False):
|
| 149 |
+
st.markdown("""
|
| 150 |
+
- **Quiz**: perguntas dinâmicas via banco; fluxo ajustável (sem limitadores) e com opção de **Voltar ao sistema**.
|
| 151 |
+
- **Ranking**: consolida pontuação por rodada/período e oferece exportação.
|
| 152 |
+
""")
|
| 153 |
+
|
| 154 |
+
with st.expander("🎨 Diretrizes de Layout e Acessibilidade", expanded=False):
|
| 155 |
+
st.markdown("""
|
| 156 |
+
- **Responsividade**: usar `use_container_width=True` em tabelas/gráficos.
|
| 157 |
+
- **Colunas fluidas**: `st.columns()` para KPIs (ajuste automático em telas menores).
|
| 158 |
+
- **Expansores**: `st.expander()` para reduzir poluição visual.
|
| 159 |
+
- **Temas**: arquivo `.streamlit/config.toml` pode definir `primaryColor`, `secondaryBackgroundColor`, etc.
|
| 160 |
+
""")
|
| 161 |
+
|
| 162 |
+
with st.expander("🔐 Segurança e Boas Práticas", expanded=False):
|
| 163 |
+
st.markdown("""
|
| 164 |
+
- **Senhas**: sempre criptografadas (ex.: `utils_seguranca`), nunca armazenar em texto claro.
|
| 165 |
+
- **Perfis**: `verificar_permissao(mod_id)` controla acesso; mantenha perfis atualizados.
|
| 166 |
+
- **Auditoria**: registrar ações administrativas via `registrar_log(...)`.
|
| 167 |
+
""")
|
| 168 |
+
|
| 169 |
+
with st.expander("📦 Versionamento e Suporte", expanded=False):
|
| 170 |
+
st.markdown("""
|
| 171 |
+
- **Versão atual**: exibida no rodapé da sidebar.
|
| 172 |
+
- **Desenvolvedor**: contato visível na sidebar | Rodrigo Silva.
|
| 173 |
+
- **Próximos passos**: documentação dos novos grupos/módulos, criação de migrations com Alembic, e manuais por equipe (Operação, Backload, Terceiros, BI).
|
| 174 |
+
""")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# =====================================================
|
| 178 |
+
# 🔔 Aviso Global — helpers
|
| 179 |
+
# =====================================================
|
| 180 |
+
def _get_db_session_admin():
|
| 181 |
+
"""
|
| 182 |
+
Sessão ciente do ambiente atual (via db_router, quando disponível).
|
| 183 |
+
Fallback para SessionLocal().
|
| 184 |
+
"""
|
| 185 |
+
try:
|
| 186 |
+
from db_router import get_session_for_current_db # ajuste o nome se necessário
|
| 187 |
+
return get_session_for_current_db()
|
| 188 |
+
except Exception:
|
| 189 |
+
return SessionLocal()
|
| 190 |
+
|
| 191 |
+
def _sanitize_largura(largura_raw: str) -> str:
|
| 192 |
+
val = (largura_raw or "").strip()
|
| 193 |
+
if not val:
|
| 194 |
+
return "100%"
|
| 195 |
+
if val.endswith("%") or val.endswith("px"):
|
| 196 |
+
return val
|
| 197 |
+
if val.isdigit():
|
| 198 |
+
return f"{val}px"
|
| 199 |
+
return "100%"
|
| 200 |
+
|
| 201 |
+
def _obter_aviso_ativo_admin():
|
| 202 |
+
if not _HAS_AVISO_GLOBAL:
|
| 203 |
+
return None
|
| 204 |
+
db = _get_db_session_admin()
|
| 205 |
+
try:
|
| 206 |
+
return (
|
| 207 |
+
db.query(AvisoGlobal)
|
| 208 |
+
.filter(AvisoGlobal.ativo == True)
|
| 209 |
+
.order_by(AvisoGlobal.updated_at.desc(), AvisoGlobal.created_at.desc())
|
| 210 |
+
.first()
|
| 211 |
+
)
|
| 212 |
+
except Exception:
|
| 213 |
+
return None
|
| 214 |
+
finally:
|
| 215 |
+
try:
|
| 216 |
+
db.close()
|
| 217 |
+
except Exception:
|
| 218 |
+
pass
|
| 219 |
+
|
| 220 |
+
# 🔧 Diagnóstico e correção de schema (colunas) da tabela aviso_global
|
| 221 |
+
def _verificar_schema_aviso_global(show_ui: bool = True) -> bool:
|
| 222 |
+
"""
|
| 223 |
+
Retorna True se o schema está OK (inclui font_size).
|
| 224 |
+
Se show_ui=True, exibe UI com botão para criar coluna ausente.
|
| 225 |
+
"""
|
| 226 |
+
if not _HAS_AVISO_GLOBAL:
|
| 227 |
+
if show_ui:
|
| 228 |
+
st.error("Modelo AvisoGlobal não encontrado.")
|
| 229 |
+
return False
|
| 230 |
+
|
| 231 |
+
db = _get_db_session_admin()
|
| 232 |
+
try:
|
| 233 |
+
insp = inspect(db.bind)
|
| 234 |
+
cols = [c["name"] for c in insp.get_columns("aviso_global")]
|
| 235 |
+
falta_font = "font_size" not in cols
|
| 236 |
+
|
| 237 |
+
if show_ui:
|
| 238 |
+
with st.expander("🧪 Diagnóstico do schema (aviso_global)", expanded=False):
|
| 239 |
+
st.caption("Colunas atuais: " + (", ".join(cols) if cols else "—"))
|
| 240 |
+
if falta_font:
|
| 241 |
+
st.warning("A coluna **font_size** não existe neste banco/ambiente.")
|
| 242 |
+
col_btn1, col_btn2 = st.columns([1, 3])
|
| 243 |
+
if col_btn1.button("⚙️ Criar coluna font_size (DEFAULT 14)"):
|
| 244 |
+
try:
|
| 245 |
+
dialect = db.bind.dialect.name
|
| 246 |
+
if dialect == "sqlite":
|
| 247 |
+
sql = "ALTER TABLE aviso_global ADD COLUMN font_size INTEGER DEFAULT 14"
|
| 248 |
+
elif dialect == "postgresql":
|
| 249 |
+
sql = "ALTER TABLE aviso_global ADD COLUMN font_size integer DEFAULT 14"
|
| 250 |
+
elif dialect in ("mysql", "mariadb"):
|
| 251 |
+
sql = "ALTER TABLE aviso_global ADD COLUMN font_size INT DEFAULT 14"
|
| 252 |
+
else:
|
| 253 |
+
st.error(f"Dialeto não suportado para criação automática: {dialect}")
|
| 254 |
+
return False
|
| 255 |
+
db.execute(text(sql))
|
| 256 |
+
db.commit()
|
| 257 |
+
st.success("Coluna 'font_size' criada com sucesso. Recarregando...")
|
| 258 |
+
st.rerun()
|
| 259 |
+
except Exception as e:
|
| 260 |
+
db.rollback()
|
| 261 |
+
st.error(f"Erro ao criar coluna: {e}")
|
| 262 |
+
else:
|
| 263 |
+
st.success("Schema OK ✔ (coluna 'font_size' presente).")
|
| 264 |
+
return not falta_font
|
| 265 |
+
|
| 266 |
+
except Exception as e:
|
| 267 |
+
if show_ui:
|
| 268 |
+
st.error(f"Falha ao inspecionar o schema: {e}")
|
| 269 |
+
return False
|
| 270 |
+
finally:
|
| 271 |
+
try:
|
| 272 |
+
db.close()
|
| 273 |
+
except Exception:
|
| 274 |
+
pass
|
| 275 |
+
|
| 276 |
+
def _publicar_aviso_admin(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size) -> bool:
|
| 277 |
+
if not _HAS_AVISO_GLOBAL:
|
| 278 |
+
return False
|
| 279 |
+
db = _get_db_session_admin()
|
| 280 |
+
try:
|
| 281 |
+
# desativa os ativos
|
| 282 |
+
db.query(AvisoGlobal).filter(AvisoGlobal.ativo == True).update({AvisoGlobal.ativo: False})
|
| 283 |
+
novo = AvisoGlobal(
|
| 284 |
+
mensagem=(mensagem or "").strip(),
|
| 285 |
+
bg_color=(bg_color or "#FFF3CD").strip(),
|
| 286 |
+
text_color=(text_color or "#664D03").strip(),
|
| 287 |
+
largura=_sanitize_largura(largura),
|
| 288 |
+
efeito=efeito if efeito in ("marquee", "fixo") else "marquee",
|
| 289 |
+
velocidade=max(5, min(int(velocidade or 20), 120)),
|
| 290 |
+
ativo=True,
|
| 291 |
+
updated_at=datetime.now(),
|
| 292 |
+
)
|
| 293 |
+
# salva font_size quando o atributo/coluna existir (fallback seguro)
|
| 294 |
+
try:
|
| 295 |
+
setattr(novo, "font_size", max(10, min(int(font_size or 14), 48)))
|
| 296 |
+
except Exception:
|
| 297 |
+
pass
|
| 298 |
+
|
| 299 |
+
db.add(novo)
|
| 300 |
+
db.commit()
|
| 301 |
+
db.expire_all()
|
| 302 |
+
return True
|
| 303 |
+
except Exception as e:
|
| 304 |
+
db.rollback()
|
| 305 |
+
# Diagnóstico visível para o admin
|
| 306 |
+
st.error(f"Falha ao publicar o aviso: {e}")
|
| 307 |
+
try:
|
| 308 |
+
insp = inspect(db.bind)
|
| 309 |
+
cols = [c["name"] for c in insp.get_columns("aviso_global")]
|
| 310 |
+
st.caption("Colunas em aviso_global: " + ", ".join(cols))
|
| 311 |
+
except Exception:
|
| 312 |
+
pass
|
| 313 |
+
return False
|
| 314 |
+
finally:
|
| 315 |
+
try:
|
| 316 |
+
db.close()
|
| 317 |
+
except Exception:
|
| 318 |
+
pass
|
| 319 |
+
|
| 320 |
+
def _desativar_aviso_admin() -> bool:
|
| 321 |
+
if not _HAS_AVISO_GLOBAL:
|
| 322 |
+
return False
|
| 323 |
+
db = _get_db_session_admin()
|
| 324 |
+
try:
|
| 325 |
+
db.query(AvisoGlobal).filter(AvisoGlobal.ativo == True)\
|
| 326 |
+
.update({AvisoGlobal.ativo: False, AvisoGlobal.updated_at: datetime.now()})
|
| 327 |
+
db.commit()
|
| 328 |
+
db.expire_all()
|
| 329 |
+
return True
|
| 330 |
+
except Exception:
|
| 331 |
+
db.rollback()
|
| 332 |
+
return False
|
| 333 |
+
finally:
|
| 334 |
+
try:
|
| 335 |
+
db.close()
|
| 336 |
+
except Exception:
|
| 337 |
+
pass
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
# ===============================
|
| 341 |
+
# 🔎 Pré-visualização do Aviso Global (somente render local)
|
| 342 |
+
# ===============================
|
| 343 |
+
def _render_preview_aviso_topbar(mensagem: str, bg_color: str, text_color: str, largura: str, efeito: str, velocidade: int, font_size: int):
|
| 344 |
+
largura = _sanitize_largura(largura)
|
| 345 |
+
bg = (bg_color or "#FFF3CD").strip()
|
| 346 |
+
fg = (text_color or "#664D03").strip()
|
| 347 |
+
efeito = (efeito or "marquee").lower()
|
| 348 |
+
try:
|
| 349 |
+
velocidade = int(velocidade or 20)
|
| 350 |
+
except Exception:
|
| 351 |
+
velocidade = 20
|
| 352 |
+
try:
|
| 353 |
+
font_size = max(10, min(int(font_size or 14), 48))
|
| 354 |
+
except Exception:
|
| 355 |
+
font_size = 14
|
| 356 |
+
|
| 357 |
+
st.markdown(
|
| 358 |
+
f"""
|
| 359 |
+
<style>
|
| 360 |
+
.ag-topbar-wrap-preview {{
|
| 361 |
+
position: relative; /* preview não fixa no topo global */
|
| 362 |
+
width: {largura};
|
| 363 |
+
margin: 8px auto 10px auto;
|
| 364 |
+
z-index: 10;
|
| 365 |
+
background: {bg}; color: {fg};
|
| 366 |
+
border: 1px solid rgba(0,0,0,.08);
|
| 367 |
+
box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
| 368 |
+
border-radius: 10px;
|
| 369 |
+
}}
|
| 370 |
+
.ag-topbar-inner-preview {{
|
| 371 |
+
display: flex; align-items: center;
|
| 372 |
+
min-height: 44px; padding: 8px 14px; overflow: hidden;
|
| 373 |
+
font-weight: 700; font-size: {font_size}px; letter-spacing: .2px;
|
| 374 |
+
white-space: nowrap;
|
| 375 |
+
}}
|
| 376 |
+
.ag-topbar-marquee-preview > span {{
|
| 377 |
+
display: inline-block; padding-left: 100%;
|
| 378 |
+
animation: ag-marquee-preview {velocidade}s linear infinite;
|
| 379 |
+
}}
|
| 380 |
+
@keyframes ag-marquee-preview {{
|
| 381 |
+
0% {{ transform: translateX(0); }}
|
| 382 |
+
100% {{ transform: translateX(-100%); }}
|
| 383 |
+
}}
|
| 384 |
+
</style>
|
| 385 |
+
<div class="ag-topbar-wrap-preview">
|
| 386 |
+
<div class="ag-topbar-inner-preview {'ag-topbar-marquee-preview' if efeito=='marquee' else ''}">
|
| 387 |
+
<span>{mensagem}</span>
|
| 388 |
+
</div>
|
| 389 |
+
</div>
|
| 390 |
+
""",
|
| 391 |
+
unsafe_allow_html=True
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# =====================================================
|
| 396 |
+
# 🔔 Menu: Aviso Global (Topo)
|
| 397 |
+
# =====================================================
|
| 398 |
+
def menu_aviso_global():
|
| 399 |
+
st.subheader("📣 Aviso Global (Topo)")
|
| 400 |
+
st.caption("Envie um aviso global exibido no topo para todos os usuários.")
|
| 401 |
+
|
| 402 |
+
perfil = (st.session_state.get("perfil") or "usuario").strip().lower()
|
| 403 |
+
if perfil != "admin":
|
| 404 |
+
st.warning("Apenas administradores podem publicar avisos globais.")
|
| 405 |
+
return
|
| 406 |
+
|
| 407 |
+
if not _HAS_AVISO_GLOBAL:
|
| 408 |
+
st.error(
|
| 409 |
+
"O modelo `AvisoGlobal` não foi encontrado em `models.py`."
|
| 410 |
+
)
|
| 411 |
+
with st.expander("📄 Modelo necessário (copie para models.py)"):
|
| 412 |
+
st.code(
|
| 413 |
+
"""from banco import Base
|
| 414 |
+
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
|
| 415 |
+
from sqlalchemy.sql import func
|
| 416 |
+
|
| 417 |
+
class AvisoGlobal(Base):
|
| 418 |
+
__tablename__ = "aviso_global"
|
| 419 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 420 |
+
mensagem = Column(Text, nullable=False)
|
| 421 |
+
bg_color = Column(String(32), default="#FFF3CD")
|
| 422 |
+
text_color = Column(String(32), default="#664D03")
|
| 423 |
+
largura = Column(String(16), default="100%")
|
| 424 |
+
efeito = Column(String(16), default="marquee")
|
| 425 |
+
velocidade = Column(Integer, default=20)
|
| 426 |
+
font_size = Column(Integer, default=14) # tamanho da fonte (px)
|
| 427 |
+
ativo = Column(Boolean, default=True, index=True)
|
| 428 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 429 |
+
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())""",
|
| 430 |
+
language="python",
|
| 431 |
+
)
|
| 432 |
+
return
|
| 433 |
+
|
| 434 |
+
# 🔎 Diagnóstico/migração simples do schema (font_size)
|
| 435 |
+
_verificar_schema_aviso_global(show_ui=True)
|
| 436 |
+
|
| 437 |
+
aviso_atual = _obter_aviso_ativo_admin()
|
| 438 |
+
|
| 439 |
+
msg_default = aviso_atual.mensagem if aviso_atual else ""
|
| 440 |
+
bg_default = aviso_atual.bg_color if aviso_atual else "#FFF3CD"
|
| 441 |
+
fg_default = aviso_atual.text_color if aviso_atual else "#664D03"
|
| 442 |
+
w_default = aviso_atual.largura if aviso_atual else "100%"
|
| 443 |
+
ef_default = (aviso_atual.efeito if aviso_atual else "marquee")
|
| 444 |
+
vel_default = int(aviso_atual.velocidade if aviso_atual else 20)
|
| 445 |
+
fs_default = int(getattr(aviso_atual, "font_size", 14)) if aviso_atual else 14 # ⬅️ NOVO
|
| 446 |
+
|
| 447 |
+
mensagem = st.text_input("Mensagem do aviso:", value=msg_default, placeholder="Ex.: Manutenção hoje às 18h...")
|
| 448 |
+
colc1, colc2 = st.columns(2)
|
| 449 |
+
bg_color = colc1.color_picker("Cor de fundo", value=bg_default)
|
| 450 |
+
text_color = colc2.color_picker("Cor do texto", value=fg_default)
|
| 451 |
+
|
| 452 |
+
colw1, colw2 = st.columns([2,1])
|
| 453 |
+
largura = colw1.text_input("Largura (ex.: 100% ou 1200px)", value=w_default)
|
| 454 |
+
efeito = colw2.selectbox("Efeito", ["marquee", "fixo"], index=(0 if ef_default=="marquee" else 1))
|
| 455 |
+
|
| 456 |
+
colv1, colv2 = st.columns(2)
|
| 457 |
+
velocidade = colv1.slider("Velocidade (segundos por ciclo)", min_value=5, max_value=120, value=vel_default, step=1, help="Usado apenas no modo 'marquee'.")
|
| 458 |
+
font_size = colv2.slider("Tamanho da fonte (px)", min_value=10, max_value=48, value=fs_default, step=1) # ⬅️ NOVO
|
| 459 |
+
|
| 460 |
+
# --- Pré-visualização ao vivo (sem salvar) ---
|
| 461 |
+
st.markdown("**Pré-visualização:**")
|
| 462 |
+
if (mensagem or "").strip():
|
| 463 |
+
_render_preview_aviso_topbar(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size)
|
| 464 |
+
else:
|
| 465 |
+
st.info("Digite a mensagem para ver a pré-visualização aqui.")
|
| 466 |
+
|
| 467 |
+
colb1, colb2, colb3 = st.columns(3)
|
| 468 |
+
publicar = colb1.button("📢 Publicar/Atualizar aviso")
|
| 469 |
+
desativar = colb2.button("🛑 Desativar aviso atual")
|
| 470 |
+
atualizar_preview = colb3.button("🔄 Atualizar prévia")
|
| 471 |
+
|
| 472 |
+
# Botão opcional de refresh da prévia (não salva nada; rerenderiza a página).
|
| 473 |
+
if atualizar_preview:
|
| 474 |
+
st.rerun()
|
| 475 |
+
|
| 476 |
+
if publicar:
|
| 477 |
+
if not (mensagem or "").strip():
|
| 478 |
+
st.warning("Digite a mensagem do aviso.")
|
| 479 |
+
else:
|
| 480 |
+
ok = _publicar_aviso_admin(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size)
|
| 481 |
+
if ok:
|
| 482 |
+
try:
|
| 483 |
+
registrar_log(
|
| 484 |
+
usuario=st.session_state.get("usuario"),
|
| 485 |
+
acao="PUBLICAR_AVISO_GLOBAL",
|
| 486 |
+
tabela="aviso_global",
|
| 487 |
+
registro_id=None
|
| 488 |
+
)
|
| 489 |
+
except Exception:
|
| 490 |
+
pass
|
| 491 |
+
st.success("Aviso publicado/atualizado!")
|
| 492 |
+
st.rerun()
|
| 493 |
+
else:
|
| 494 |
+
st.error("Não foi possível publicar o aviso. Verifique o banco/logs.")
|
| 495 |
+
|
| 496 |
+
if desativar:
|
| 497 |
+
ok = _desativar_aviso_admin()
|
| 498 |
+
if ok:
|
| 499 |
+
try:
|
| 500 |
+
registrar_log(
|
| 501 |
+
usuario=st.session_state.get("usuario"),
|
| 502 |
+
acao="DESATIVAR_AVISO_GLOBAL",
|
| 503 |
+
tabela="aviso_global",
|
| 504 |
+
registro_id=None
|
| 505 |
+
)
|
| 506 |
+
except Exception:
|
| 507 |
+
pass
|
| 508 |
+
st.info("Aviso desativado.")
|
| 509 |
+
st.rerun()
|
| 510 |
+
else:
|
| 511 |
+
st.error("Não foi possível desativar o aviso.")
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
# =====================================================
|
| 515 |
+
# ADMINISTRAÇÃO (variação com abas/tabs)
|
| 516 |
+
# =====================================================
|
| 517 |
+
def main():
|
| 518 |
+
|
| 519 |
+
# ✅ Detecta se usuário é admin; abas administrativas aparecem apenas para admin.
|
| 520 |
+
is_admin = verificar_permissao("administracao")
|
| 521 |
+
|
| 522 |
+
# Título conforme perfil
|
| 523 |
+
if is_admin:
|
| 524 |
+
st.title("🔒 Administração")
|
| 525 |
+
# Admin vê todas as abas
|
| 526 |
+
tab_editar, tab_aviso, tab_info = st.tabs([
|
| 527 |
+
"✏️ Editar / Excluir Registros",
|
| 528 |
+
"📣 Aviso Global (Topo)",
|
| 529 |
+
"📘 Info do Sistema"
|
| 530 |
+
])
|
| 531 |
+
else:
|
| 532 |
+
st.title("✏️ Edição de Registros")
|
| 533 |
+
# Não-admin vê apenas a aba de edição
|
| 534 |
+
(tab_editar,) = st.tabs(["✏️ Editar Registros"])
|
| 535 |
+
|
| 536 |
+
# =====================================================
|
| 537 |
+
# BLOCO: INFO DO SISTEMA (apenas admin)
|
| 538 |
+
# =====================================================
|
| 539 |
+
if is_admin:
|
| 540 |
+
with tab_info:
|
| 541 |
+
menu_info()
|
| 542 |
+
|
| 543 |
+
# =====================================================
|
| 544 |
+
# BLOCO: AVISO GLOBAL (apenas admin)
|
| 545 |
+
# =====================================================
|
| 546 |
+
with tab_aviso:
|
| 547 |
+
menu_aviso_global()
|
| 548 |
+
|
| 549 |
+
# =====================================================
|
| 550 |
+
# BLOCO: EDIÇÃO / EXCLUSÃO (excluir só admin)
|
| 551 |
+
# =====================================================
|
| 552 |
+
with tab_editar:
|
| 553 |
+
|
| 554 |
+
# =====================================================
|
| 555 |
+
# FUNÇÃO UTILITÁRIA
|
| 556 |
+
# =====================================================
|
| 557 |
+
def safe_index(lista, valor):
|
| 558 |
+
"""Evita erro quando o valor salvo no banco não existe na lista"""
|
| 559 |
+
try:
|
| 560 |
+
return lista.index(valor)
|
| 561 |
+
except ValueError:
|
| 562 |
+
return 0
|
| 563 |
+
|
| 564 |
+
db = SessionLocal()
|
| 565 |
+
try:
|
| 566 |
+
# =====================================================
|
| 567 |
+
# 🔎 FILTROS OPCIONAIS COM SUGESTÕES DO BANCO
|
| 568 |
+
# (disponível para todos os perfis)
|
| 569 |
+
# =====================================================
|
| 570 |
+
st.subheader("🔎 Filtro de Busca (opcional)")
|
| 571 |
+
|
| 572 |
+
# IMPORTANTE: usar .distinct() sobre a coluna, como já estava
|
| 573 |
+
fpsos = [""] + sorted({r.fpso for r in db.query(Equipamento.fpso).distinct() if r.fpso})
|
| 574 |
+
modais = [""] + sorted({r.modal for r in db.query(Equipamento.modal).distinct() if r.modal})
|
| 575 |
+
osms = [""] + sorted({r.osm for r in db.query(Equipamento.osm).distinct() if r.osm})
|
| 576 |
+
# 🟩 NOVO: lista de Nota Fiscal para multiselect assistido
|
| 577 |
+
notas_dist = [""] + sorted({str(r.nota_fiscal) for r in db.query(Equipamento.nota_fiscal).distinct() if r.nota_fiscal})
|
| 578 |
+
|
| 579 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 580 |
+
|
| 581 |
+
with col1:
|
| 582 |
+
filtro_fpso = st.selectbox("FPSO", fpsos)
|
| 583 |
+
|
| 584 |
+
with col2:
|
| 585 |
+
filtro_modal = st.selectbox("Modal", modais)
|
| 586 |
+
|
| 587 |
+
with col3:
|
| 588 |
+
filtro_osm = st.selectbox("OSM", osms)
|
| 589 |
+
|
| 590 |
+
with col4:
|
| 591 |
+
filtro_data = st.date_input("Data Coleta", value=None)
|
| 592 |
+
|
| 593 |
+
# 🟩 NOVO: filtros de Nota Fiscal + opção de ver só duplicadas
|
| 594 |
+
st.markdown("**🧾 Filtro por Nota Fiscal**")
|
| 595 |
+
nf_col1, nf_col2, nf_col3 = st.columns([2, 2, 1.2])
|
| 596 |
+
with nf_col1:
|
| 597 |
+
filtro_nf_text = st.text_input(
|
| 598 |
+
"Digite uma ou mais NFs (separadas por vírgula)",
|
| 599 |
+
value=""
|
| 600 |
+
)
|
| 601 |
+
with nf_col2:
|
| 602 |
+
filtro_nf_multi = st.multiselect(
|
| 603 |
+
"Ou selecione",
|
| 604 |
+
options=[x for x in notas_dist if x != ""]
|
| 605 |
+
)
|
| 606 |
+
with nf_col3:
|
| 607 |
+
mostrar_apenas_nf_duplicadas = st.checkbox(
|
| 608 |
+
"Somente duplicadas",
|
| 609 |
+
value=False
|
| 610 |
+
)
|
| 611 |
+
|
| 612 |
+
# =====================================================
|
| 613 |
+
# QUERY BASE (COMPORTAMENTO ORIGINAL) + NOVO FILTRO NF
|
| 614 |
+
# =====================================================
|
| 615 |
+
query = db.query(Equipamento)
|
| 616 |
+
|
| 617 |
+
if filtro_fpso:
|
| 618 |
+
query = query.filter(Equipamento.fpso == filtro_fpso)
|
| 619 |
+
|
| 620 |
+
if filtro_modal:
|
| 621 |
+
query = query.filter(Equipamento.modal == filtro_modal)
|
| 622 |
+
|
| 623 |
+
if filtro_osm:
|
| 624 |
+
query = query.filter(Equipamento.osm == filtro_osm)
|
| 625 |
+
|
| 626 |
+
if filtro_data:
|
| 627 |
+
query = query.filter(Equipamento.data_coleta == filtro_data)
|
| 628 |
+
|
| 629 |
+
# 🟩 NOVO: aplica filtro de Nota Fiscal (tratando como string)
|
| 630 |
+
notas_escolhidas = set()
|
| 631 |
+
if filtro_nf_text.strip():
|
| 632 |
+
partes = [p.strip() for p in filtro_nf_text.split(",") if p.strip()]
|
| 633 |
+
notas_escolhidas.update(partes)
|
| 634 |
+
if filtro_nf_multi:
|
| 635 |
+
notas_escolhidas.update([str(x).strip() for x in filtro_nf_multi if str(x).strip()])
|
| 636 |
+
|
| 637 |
+
if notas_escolhidas:
|
| 638 |
+
# Como a coluna é do tipo texto no modelo, filtramos por igualdade textual.
|
| 639 |
+
# Para outros dialetos/formatos numéricos, garantir cast adequado.
|
| 640 |
+
query = query.filter(Equipamento.nota_fiscal.in_(list(notas_escolhidas)))
|
| 641 |
+
|
| 642 |
+
registros = query.order_by(Equipamento.id.desc()).all()
|
| 643 |
+
|
| 644 |
+
if not registros:
|
| 645 |
+
st.info("Nenhum registro encontrado.")
|
| 646 |
+
return
|
| 647 |
+
|
| 648 |
+
# =====================================================
|
| 649 |
+
# 🧭 SINALIZAÇÃO DE NF DUPLICADA (no conjunto filtrado)
|
| 650 |
+
# =====================================================
|
| 651 |
+
# Monta DF auxiliar só com campos relevantes para contagem de NF
|
| 652 |
+
import pandas as pd
|
| 653 |
+
df_aux = pd.DataFrame([{
|
| 654 |
+
"ID": r.id,
|
| 655 |
+
"Nota Fiscal": ("" if r.nota_fiscal is None else str(r.nota_fiscal).strip())
|
| 656 |
+
} for r in registros])
|
| 657 |
+
|
| 658 |
+
# Contagem de ocorrências por NF (string, ignorando vazias)
|
| 659 |
+
if not df_aux.empty:
|
| 660 |
+
contagem = df_aux.loc[df_aux["Nota Fiscal"] != "", "Nota Fiscal"].value_counts()
|
| 661 |
+
notas_duplicadas = contagem[contagem > 1]
|
| 662 |
+
else:
|
| 663 |
+
notas_duplicadas = pd.Series(dtype=int)
|
| 664 |
+
|
| 665 |
+
# Aviso e expander com a lista das duplicadas
|
| 666 |
+
if len(notas_duplicadas.index) > 0:
|
| 667 |
+
total_ocorrencias = int(notas_duplicadas.sum())
|
| 668 |
+
st.warning(
|
| 669 |
+
f"⚠️ Foram encontradas **{total_ocorrencias}** ocorrências em **{len(notas_duplicadas)}** "
|
| 670 |
+
f"números de Nota Fiscal duplicados no resultado filtrado."
|
| 671 |
+
)
|
| 672 |
+
with st.expander("Ver lista de notas duplicadas"):
|
| 673 |
+
st.dataframe(
|
| 674 |
+
notas_duplicadas.rename("Ocorrências").reset_index().rename(columns={"index": "Nota Fiscal"}),
|
| 675 |
+
use_container_width=True
|
| 676 |
+
)
|
| 677 |
+
|
| 678 |
+
# Se marcado: mantém na lista apenas as duplicadas
|
| 679 |
+
if mostrar_apenas_nf_duplicadas:
|
| 680 |
+
set_dup = set(notas_duplicadas.index.tolist())
|
| 681 |
+
registros = [r for r in registros if (r.nota_fiscal is not None and str(r.nota_fiscal).strip() in set_dup)]
|
| 682 |
+
|
| 683 |
+
if not registros:
|
| 684 |
+
st.info("Nenhum registro duplicado após aplicar o filtro de 'Somente duplicadas'.")
|
| 685 |
+
return
|
| 686 |
+
|
| 687 |
+
else:
|
| 688 |
+
if mostrar_apenas_nf_duplicadas:
|
| 689 |
+
st.info("Não há notas duplicadas no conjunto filtrado.")
|
| 690 |
+
return
|
| 691 |
+
|
| 692 |
+
# =====================================================
|
| 693 |
+
# SELECTBOX DE ESCOLHA E FORMULÁRIO
|
| 694 |
+
# =====================================================
|
| 695 |
+
mapa = {
|
| 696 |
+
f"ID {r.id} | FPSO {r.fpso} | {r.modal} | {r.osm} | {r.data_coleta} | NF: {r.nota_fiscal or '—'}": r.id
|
| 697 |
+
for r in registros
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
escolha = st.selectbox("Selecione o registro", list(mapa.keys()))
|
| 701 |
+
registro = db.get(Equipamento, mapa[escolha])
|
| 702 |
+
|
| 703 |
+
st.divider()
|
| 704 |
+
st.subheader("✏️ Editar Registro")
|
| 705 |
+
|
| 706 |
+
# =====================================================
|
| 707 |
+
# FORMULÁRIO COMPLETO (MESMO DO MÓDULO FORMULÁRIO)
|
| 708 |
+
# =====================================================
|
| 709 |
+
with st.form("form_edicao"):
|
| 710 |
+
|
| 711 |
+
# ================== DADOS OPERACIONAIS ==================
|
| 712 |
+
st.subheader("📦 Dados Operacionais")
|
| 713 |
+
|
| 714 |
+
col1, col2, col3 = st.columns(3)
|
| 715 |
+
|
| 716 |
+
with col1:
|
| 717 |
+
fpso1 = campo_fpso("FPSO1", registro.fpso1)
|
| 718 |
+
fpso = campo_fpso("FPSO", registro.fpso)
|
| 719 |
+
data_coleta = st.date_input("Data de Coleta", registro.data_coleta)
|
| 720 |
+
especialista = st.text_input("Especialista", registro.especialista or "")
|
| 721 |
+
conferente = st.text_input("Conferente", registro.conferente or "")
|
| 722 |
+
osm = st.text_input("OSM", registro.osm or "")
|
| 723 |
+
|
| 724 |
+
with col2:
|
| 725 |
+
modal = st.selectbox(
|
| 726 |
+
"Modal",
|
| 727 |
+
MODAL_LISTA,
|
| 728 |
+
index=safe_index(MODAL_LISTA, registro.modal)
|
| 729 |
+
)
|
| 730 |
+
quant_equip = st.number_input(
|
| 731 |
+
"Quantidade de Equipamentos",
|
| 732 |
+
min_value=0,
|
| 733 |
+
value=registro.quant_equip or 0
|
| 734 |
+
)
|
| 735 |
+
mrob = st.text_input("MROB", registro.mrob or "")
|
| 736 |
+
|
| 737 |
+
with col3:
|
| 738 |
+
linhas_osm = st.number_input("Total de Linhas OSM", value=registro.linhas_osm or 0)
|
| 739 |
+
linhas_mrob = st.number_input("Total de Linhas MROB", value=registro.linhas_mrob or 0)
|
| 740 |
+
linhas_erros = st.number_input("Total de Linhas com Erro", value=registro.linhas_erros or 0)
|
| 741 |
+
|
| 742 |
+
st.divider()
|
| 743 |
+
|
| 744 |
+
# ================== ANÁLISE DE ERROS ==================
|
| 745 |
+
st.subheader("⚠️ Análise de Erros")
|
| 746 |
+
|
| 747 |
+
op_sim_nao = ["", "Sim", "Não"]
|
| 748 |
+
|
| 749 |
+
col_e1, col_e2, col_e3, col_e4 = st.columns(4)
|
| 750 |
+
|
| 751 |
+
with col_e1:
|
| 752 |
+
erro_storekeeper = st.selectbox(
|
| 753 |
+
"Storekeeper", op_sim_nao,
|
| 754 |
+
index=safe_index(op_sim_nao, registro.erro_storekeeper)
|
| 755 |
+
)
|
| 756 |
+
|
| 757 |
+
with col_e2:
|
| 758 |
+
erro_operacao = st.selectbox(
|
| 759 |
+
"Operação WH", op_sim_nao,
|
| 760 |
+
index=safe_index(op_sim_nao, registro.erro_operacao)
|
| 761 |
+
)
|
| 762 |
+
|
| 763 |
+
with col_e3:
|
| 764 |
+
erro_especialista = st.selectbox(
|
| 765 |
+
"Especialista WH", op_sim_nao,
|
| 766 |
+
index=safe_index(op_sim_nao, registro.erro_especialista)
|
| 767 |
+
)
|
| 768 |
+
|
| 769 |
+
with col_e4:
|
| 770 |
+
erro_outros = st.selectbox(
|
| 771 |
+
"Outros", op_sim_nao,
|
| 772 |
+
index=safe_index(op_sim_nao, registro.erro_outros)
|
| 773 |
+
)
|
| 774 |
+
|
| 775 |
+
op_inc_exc = ["", "INCLUSÃO", "EXCLUSÃO"]
|
| 776 |
+
|
| 777 |
+
inclusao_exclusao = st.selectbox(
|
| 778 |
+
"Inclusão / Exclusão",
|
| 779 |
+
op_inc_exc,
|
| 780 |
+
index=safe_index(op_inc_exc, registro.inclusao_exclusao)
|
| 781 |
+
)
|
| 782 |
+
|
| 783 |
+
st.divider()
|
| 784 |
+
|
| 785 |
+
# ================== DADOS ADMINISTRATIVOS ==================
|
| 786 |
+
st.subheader("🧾 Dados Administrativos")
|
| 787 |
+
|
| 788 |
+
col_a1, col_a2, col_a3 = st.columns(3)
|
| 789 |
+
|
| 790 |
+
with col_a1:
|
| 791 |
+
po = st.text_input("PO", registro.po or "")
|
| 792 |
+
part_number = st.text_input("Part Number", registro.part_number or "")
|
| 793 |
+
|
| 794 |
+
with col_a2:
|
| 795 |
+
material = st.text_input("Material", registro.material or "")
|
| 796 |
+
nota_fiscal = st.text_input("Nota Fiscal", registro.nota_fiscal or "")
|
| 797 |
+
|
| 798 |
+
with col_a3:
|
| 799 |
+
solicitante = st.text_input("Solicitante", registro.solicitante or "")
|
| 800 |
+
requisitante = st.text_input("Requisitante", registro.requisitante or "")
|
| 801 |
+
|
| 802 |
+
impacto = st.text_input("Impacto", registro.impacto or "")
|
| 803 |
+
dimensao = st.text_input("Dimensão", registro.dimensao or "")
|
| 804 |
+
|
| 805 |
+
# ✅ AJUSTE: corrigido para 'motivo'
|
| 806 |
+
motivo = st.text_input("Motivo da Inclusão / Exclusão", registro.motivo or "")
|
| 807 |
+
|
| 808 |
+
observacoes = st.text_area(
|
| 809 |
+
"Observações",
|
| 810 |
+
registro.observacoes or "",
|
| 811 |
+
height=120
|
| 812 |
+
)
|
| 813 |
+
|
| 814 |
+
op_dia = ["", "D1", "D2", "D3"]
|
| 815 |
+
|
| 816 |
+
dia_inclusao = st.selectbox(
|
| 817 |
+
"Dia de Inclusão (D)",
|
| 818 |
+
op_dia,
|
| 819 |
+
index=safe_index(op_dia, registro.dia_inclusao)
|
| 820 |
+
)
|
| 821 |
+
|
| 822 |
+
# ================== AÇÃO ==================
|
| 823 |
+
# 🔐 Apenas admin pode excluir
|
| 824 |
+
opcoes_acao = ["Salvar Alterações"] + (["Excluir Registro"] if is_admin else [])
|
| 825 |
+
acao = st.radio(
|
| 826 |
+
"Ação",
|
| 827 |
+
opcoes_acao,
|
| 828 |
+
horizontal=True
|
| 829 |
+
)
|
| 830 |
+
|
| 831 |
+
submit = st.form_submit_button("Confirmar")
|
| 832 |
+
|
| 833 |
+
# =====================================================
|
| 834 |
+
# AÇÕES
|
| 835 |
+
# =====================================================
|
| 836 |
+
if submit:
|
| 837 |
+
|
| 838 |
+
if acao == "Salvar Alterações":
|
| 839 |
+
# Atualiza todos os campos dinamicamente (exceto id)
|
| 840 |
+
for campo in registro.__table__.columns.keys():
|
| 841 |
+
if campo != "id":
|
| 842 |
+
setattr(registro, campo, locals().get(campo, getattr(registro, campo)))
|
| 843 |
+
|
| 844 |
+
registro.data_hora_input = datetime.now()
|
| 845 |
+
db.commit()
|
| 846 |
+
|
| 847 |
+
try:
|
| 848 |
+
registrar_log(
|
| 849 |
+
usuario=st.session_state.get("usuario"),
|
| 850 |
+
acao="EDITAR",
|
| 851 |
+
tabela="equipamentos",
|
| 852 |
+
registro_id=registro.id
|
| 853 |
+
)
|
| 854 |
+
except Exception:
|
| 855 |
+
pass
|
| 856 |
+
|
| 857 |
+
st.success("✅ Registro atualizado com sucesso!")
|
| 858 |
+
st.rerun()
|
| 859 |
+
|
| 860 |
+
elif acao == "Excluir Registro" and is_admin:
|
| 861 |
+
db.delete(registro)
|
| 862 |
+
db.commit()
|
| 863 |
+
|
| 864 |
+
try:
|
| 865 |
+
registrar_log(
|
| 866 |
+
usuario=st.session_state.get("usuario"),
|
| 867 |
+
acao="EXCLUIR",
|
| 868 |
+
tabela="equipamentos",
|
| 869 |
+
registro_id=registro.id
|
| 870 |
+
)
|
| 871 |
+
except Exception:
|
| 872 |
+
pass
|
| 873 |
+
|
| 874 |
+
st.success("🗑️ Registro excluído com sucesso!")
|
| 875 |
+
st.rerun()
|
| 876 |
+
|
| 877 |
+
finally:
|
| 878 |
+
try:
|
| 879 |
+
db.close()
|
| 880 |
+
except Exception:
|
| 881 |
+
pass
|
| 882 |
+
|
| 883 |
+
|
app.py
ADDED
|
@@ -0,0 +1,1015 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
import streamlit as st
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
from datetime import date, datetime, time
|
| 5 |
+
|
| 6 |
+
# ⬇️ Import correto das utils de operação
|
| 7 |
+
from utils_operacao import obter_grupos_disponiveis, obter_modulos_para_grupo
|
| 8 |
+
|
| 9 |
+
# ✅ Usa toda a largura da página (chamar antes de qualquer outro st.*)
|
| 10 |
+
st.set_page_config(layout="wide")
|
| 11 |
+
|
| 12 |
+
# Carrega variáveis de ambiente
|
| 13 |
+
load_dotenv()
|
| 14 |
+
|
| 15 |
+
# ===============================
|
| 16 |
+
# IMPORTAÇÃO DOS MÓDULOS
|
| 17 |
+
# ===============================
|
| 18 |
+
import formulario
|
| 19 |
+
import consulta
|
| 20 |
+
import relatorio
|
| 21 |
+
import administracao
|
| 22 |
+
import quiz
|
| 23 |
+
import ranking
|
| 24 |
+
import quiz_admin
|
| 25 |
+
import usuarios_admin
|
| 26 |
+
import videos
|
| 27 |
+
import auditoria
|
| 28 |
+
import importar_excel
|
| 29 |
+
import calendario
|
| 30 |
+
import auditoria_cleanup
|
| 31 |
+
import jogos
|
| 32 |
+
import db_tools
|
| 33 |
+
import db_admin
|
| 34 |
+
import db_monitor
|
| 35 |
+
import operacao
|
| 36 |
+
import db_export_import
|
| 37 |
+
import resposta # 📬 Admin: Caixa de Entrada IOI‑RUN (módulo interno)
|
| 38 |
+
import outlook_relatorio
|
| 39 |
+
import repositorio_load
|
| 40 |
+
import Produtividade_Especialista as produtividade_especialista
|
| 41 |
+
import rnc
|
| 42 |
+
import rnc_listagem
|
| 43 |
+
import rnc_relatorio
|
| 44 |
+
import sugestoes_usuario # 💡 Usuário: Sugestões IOI‑RUN (módulo separado)
|
| 45 |
+
import repo_rnc
|
| 46 |
+
import recebimento
|
| 47 |
+
|
| 48 |
+
from utils_info import INFO_CONTEUDO, INFO_MODULOS, INFO_MAP_PAGINA_ID
|
| 49 |
+
from login import login
|
| 50 |
+
from utils_permissoes import verificar_permissao
|
| 51 |
+
from utils_layout import exibir_logo
|
| 52 |
+
from modules_map import MODULES
|
| 53 |
+
from banco import engine, Base, SessionLocal
|
| 54 |
+
from models import QuizPontuacao
|
| 55 |
+
from models import IOIRunSugestao
|
| 56 |
+
from models import AvisoGlobal
|
| 57 |
+
|
| 58 |
+
# Extras p/ sessões ativas
|
| 59 |
+
from uuid import uuid4
|
| 60 |
+
from sqlalchemy import text, func, or_
|
| 61 |
+
|
| 62 |
+
# 🗄️ Banco ativo (Produção/Teste/Treinamento)
|
| 63 |
+
try:
|
| 64 |
+
from db_router import current_db_choice, bank_label
|
| 65 |
+
_HAS_ROUTER = True
|
| 66 |
+
except Exception:
|
| 67 |
+
_HAS_ROUTER = False
|
| 68 |
+
def current_db_choice() -> str:
|
| 69 |
+
return "prod"
|
| 70 |
+
def bank_label(choice: str) -> str:
|
| 71 |
+
return "🟢 Produção" if choice == "prod" else "🔴 Teste"
|
| 72 |
+
# ❌ REMOVIDO: não chamar nenhuma página ao importar/rodar o app principal
|
| 73 |
+
# if __name__ == "__main__":
|
| 74 |
+
# rnc.pagina()
|
| 75 |
+
|
| 76 |
+
# ===============================
|
| 77 |
+
# RERUN por querystring (atalho ?rr=1)
|
| 78 |
+
# ===============================
|
| 79 |
+
def _get_query_params():
|
| 80 |
+
"""Compat: retorna query params como dict (Streamlit novo/antigo)."""
|
| 81 |
+
try:
|
| 82 |
+
# Streamlit >= 1.32
|
| 83 |
+
return dict(st.query_params)
|
| 84 |
+
except Exception:
|
| 85 |
+
# Streamlit antigo (experimental)
|
| 86 |
+
try:
|
| 87 |
+
return dict(st.experimental_get_query_params())
|
| 88 |
+
except Exception:
|
| 89 |
+
return {}
|
| 90 |
+
|
| 91 |
+
def _set_query_params(new_params: dict):
|
| 92 |
+
"""Compat: define query params (Streamlit novo/antigo)."""
|
| 93 |
+
try:
|
| 94 |
+
st.query_params = new_params # Streamlit >= 1.32
|
| 95 |
+
except Exception:
|
| 96 |
+
try:
|
| 97 |
+
st.experimental_set_query_params(**new_params)
|
| 98 |
+
except Exception:
|
| 99 |
+
pass
|
| 100 |
+
|
| 101 |
+
def _check_rerun_qs(pagina_atual: str = ""):
|
| 102 |
+
"""
|
| 103 |
+
Se a URL contiver rr=1 (ou true), força um rerun e limpa o parâmetro para evitar loop.
|
| 104 |
+
✅ Não dispara quando estiver na página 'resposta' (Inbox Admin).
|
| 105 |
+
✅ Consome apenas uma vez por sessão.
|
| 106 |
+
✅ (PATCH) Também não dispara quando estiver em 'outlook_relatorio' para não interromper leitura COM.
|
| 107 |
+
"""
|
| 108 |
+
try:
|
| 109 |
+
if st.session_state.get("__qs_rr_consumed__", False):
|
| 110 |
+
return
|
| 111 |
+
# 🔒 Evita rr=1 em módulos sensíveis a rerun/refresh
|
| 112 |
+
# 🟩 AJUSTE: incluir 'formulario' para não aplicar rr=1 quando o formulário estiver ativo
|
| 113 |
+
if pagina_atual in ("resposta", "outlook_relatorio", "formulario"):
|
| 114 |
+
return # não aplicar rr=1 dentro destes módulos (evita 'piscar' e cancelamentos)
|
| 115 |
+
|
| 116 |
+
params = _get_query_params()
|
| 117 |
+
rr_raw = params.get("rr", ["0"])
|
| 118 |
+
rr = rr_raw[0] if isinstance(rr_raw, (list, tuple)) else str(rr_raw)
|
| 119 |
+
if str(rr).lower() in ("1", "true"):
|
| 120 |
+
new_params = {k: v for k, v in params.items() if k != "rr"}
|
| 121 |
+
_set_query_params(new_params)
|
| 122 |
+
st.session_state["__qs_rr_consumed__"] = True
|
| 123 |
+
st.rerun()
|
| 124 |
+
except Exception:
|
| 125 |
+
pass
|
| 126 |
+
|
| 127 |
+
# =========================================
|
| 128 |
+
# DB helper — sessão ciente do ambiente
|
| 129 |
+
# =========================================
|
| 130 |
+
def _get_db_session():
|
| 131 |
+
"""Retorna uma sessão de banco consistente com o ambiente atual."""
|
| 132 |
+
try:
|
| 133 |
+
from db_router import get_session_for_current_db
|
| 134 |
+
return get_session_for_current_db()
|
| 135 |
+
except Exception:
|
| 136 |
+
pass
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
from db_router import get_engine_for_current_db
|
| 140 |
+
from sqlalchemy.orm import sessionmaker
|
| 141 |
+
Eng = get_engine_for_current_db()
|
| 142 |
+
return sessionmaker(bind=Eng)()
|
| 143 |
+
except Exception:
|
| 144 |
+
pass
|
| 145 |
+
|
| 146 |
+
return SessionLocal()
|
| 147 |
+
|
| 148 |
+
# ===============================
|
| 149 |
+
# CONFIGURAÇÃO INICIAL
|
| 150 |
+
# ===============================
|
| 151 |
+
Base.metadata.create_all(bind=engine)
|
| 152 |
+
|
| 153 |
+
def quiz_respondido_hoje(usuario: str) -> bool:
|
| 154 |
+
# ✅ Usar sessão ciente do ambiente
|
| 155 |
+
db = _get_db_session()
|
| 156 |
+
try:
|
| 157 |
+
inicio_dia = datetime.combine(date.today(), time.min)
|
| 158 |
+
return (
|
| 159 |
+
db.query(QuizPontuacao)
|
| 160 |
+
.filter(
|
| 161 |
+
QuizPontuacao.usuario == usuario,
|
| 162 |
+
QuizPontuacao.data >= inicio_dia
|
| 163 |
+
)
|
| 164 |
+
.first()
|
| 165 |
+
is not None
|
| 166 |
+
)
|
| 167 |
+
finally:
|
| 168 |
+
try:
|
| 169 |
+
db.close()
|
| 170 |
+
except Exception:
|
| 171 |
+
pass
|
| 172 |
+
|
| 173 |
+
# ===============================
|
| 174 |
+
# Sessões ativas (usuários logados agora)
|
| 175 |
+
# ===============================
|
| 176 |
+
_SESS_TTL_MIN = 5 # janela para considerar "online"
|
| 177 |
+
|
| 178 |
+
def _get_session_id() -> str:
|
| 179 |
+
if "_sid" not in st.session_state:
|
| 180 |
+
st.session_state["_sid"] = f"{uuid4()}"
|
| 181 |
+
return st.session_state["_sid"]
|
| 182 |
+
|
| 183 |
+
def _ensure_sessao_table(db) -> None:
|
| 184 |
+
"""Cria a tabela sessao_web caso não exista (SQLite/Postgres/MySQL)."""
|
| 185 |
+
dialect = db.bind.dialect.name
|
| 186 |
+
if dialect == "sqlite":
|
| 187 |
+
db.execute(text("""
|
| 188 |
+
CREATE TABLE IF NOT EXISTS sessao_web (
|
| 189 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 190 |
+
usuario TEXT NOT NULL,
|
| 191 |
+
session_id TEXT NOT NULL UNIQUE,
|
| 192 |
+
last_seen TIMESTAMP NOT NULL,
|
| 193 |
+
ativo INTEGER NOT NULL DEFAULT 1
|
| 194 |
+
)
|
| 195 |
+
"""))
|
| 196 |
+
elif dialect in ("postgresql", "postgres"):
|
| 197 |
+
db.execute(text("""
|
| 198 |
+
CREATE TABLE IF NOT EXISTS sessao_web (
|
| 199 |
+
id SERIAL PRIMARY KEY,
|
| 200 |
+
usuario TEXT NOT NULL,
|
| 201 |
+
session_id TEXT NOT NULL UNIQUE,
|
| 202 |
+
last_seen TIMESTAMPTZ NOT NULL,
|
| 203 |
+
ativo BOOLEAN NOT NULL DEFAULT TRUE
|
| 204 |
+
)
|
| 205 |
+
"""))
|
| 206 |
+
else: # mysql / mariadb
|
| 207 |
+
db.execute(text("""
|
| 208 |
+
CREATE TABLE IF NOT EXISTS sessao_web (
|
| 209 |
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
| 210 |
+
usuario VARCHAR(255) NOT NULL,
|
| 211 |
+
session_id VARCHAR(255) NOT NULL UNIQUE,
|
| 212 |
+
last_seen TIMESTAMP NOT NULL,
|
| 213 |
+
ativo TINYINT(1) NOT NULL DEFAULT 1
|
| 214 |
+
)
|
| 215 |
+
"""))
|
| 216 |
+
db.commit()
|
| 217 |
+
|
| 218 |
+
def _session_heartbeat(usuario: str) -> None:
|
| 219 |
+
"""Atualiza/insere a sessão ativa do usuário com last_seen = now() e faz limpeza básica."""
|
| 220 |
+
if not usuario:
|
| 221 |
+
return
|
| 222 |
+
db = _get_db_session()
|
| 223 |
+
try:
|
| 224 |
+
_ensure_sessao_table(db)
|
| 225 |
+
sid = _get_session_id()
|
| 226 |
+
now_sql = "CURRENT_TIMESTAMP"
|
| 227 |
+
|
| 228 |
+
upd = db.execute(
|
| 229 |
+
text(f"UPDATE sessao_web SET last_seen = {now_sql}, ativo = 1 WHERE session_id = :sid"),
|
| 230 |
+
{"sid": sid}
|
| 231 |
+
)
|
| 232 |
+
if upd.rowcount == 0:
|
| 233 |
+
db.execute(
|
| 234 |
+
text(f"INSERT INTO sessao_web (usuario, session_id, last_seen, ativo) "
|
| 235 |
+
f"VALUES (:usuario, :sid, {now_sql}, 1)"),
|
| 236 |
+
{"usuario": usuario, "sid": sid}
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
dialect = db.bind.dialect.name
|
| 240 |
+
if dialect in ("postgresql", "postgres"):
|
| 241 |
+
cleanup_sql = f"UPDATE sessao_web SET ativo = FALSE WHERE last_seen < (NOW() - INTERVAL '{_SESS_TTL_MIN * 2} minutes')"
|
| 242 |
+
elif dialect == "sqlite":
|
| 243 |
+
cleanup_sql = f"UPDATE sessao_web SET ativo = 0 WHERE last_seen < datetime(CURRENT_TIMESTAMP, '-{_SESS_TTL_MIN * 2} minutes')"
|
| 244 |
+
else:
|
| 245 |
+
cleanup_sql = f"UPDATE sessao_web SET ativo = 0 WHERE last_seen < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL {_SESS_TTL_MIN * 2} MINUTE)"
|
| 246 |
+
db.execute(text(cleanup_sql))
|
| 247 |
+
db.commit()
|
| 248 |
+
except Exception:
|
| 249 |
+
db.rollback()
|
| 250 |
+
finally:
|
| 251 |
+
try:
|
| 252 |
+
db.close()
|
| 253 |
+
except Exception:
|
| 254 |
+
pass
|
| 255 |
+
|
| 256 |
+
def _get_active_users_count() -> int:
|
| 257 |
+
"""Conta usuários distintos com last_seen dentro da janela (_SESS_TTL_MIN) e ativo=1."""
|
| 258 |
+
db = _get_db_session()
|
| 259 |
+
try:
|
| 260 |
+
_ensure_sessao_table(db)
|
| 261 |
+
dialect = db.bind.dialect.name
|
| 262 |
+
if dialect in ("postgresql", "postgres"):
|
| 263 |
+
threshold = f"(NOW() - INTERVAL '{_SESS_TTL_MIN} minutes')"
|
| 264 |
+
elif dialect == "sqlite":
|
| 265 |
+
threshold = f"datetime(CURRENT_TIMESTAMP, '-{_SESS_TTL_MIN} minutes')"
|
| 266 |
+
else:
|
| 267 |
+
threshold = f"DATE_SUB(CURRENT_TIMESTAMP, INTERVAL {_SESS_TTL_MIN} MINUTE)"
|
| 268 |
+
res = db.execute(
|
| 269 |
+
text(f"SELECT COUNT(DISTINCT usuario) AS c FROM sessao_web WHERE ativo = 1 AND last_seen >= {threshold}")
|
| 270 |
+
).fetchone()
|
| 271 |
+
return int(res[0] if res and res[0] is not None else 0)
|
| 272 |
+
except Exception:
|
| 273 |
+
return 0
|
| 274 |
+
finally:
|
| 275 |
+
try:
|
| 276 |
+
db.close()
|
| 277 |
+
except Exception:
|
| 278 |
+
pass
|
| 279 |
+
|
| 280 |
+
def _mark_session_inactive() -> None:
|
| 281 |
+
"""Marca a sessão atual como inativa (chamar no logout)."""
|
| 282 |
+
sid = st.session_state.get("_sid")
|
| 283 |
+
if not sid:
|
| 284 |
+
return
|
| 285 |
+
db = _get_db_session()
|
| 286 |
+
try:
|
| 287 |
+
_ensure_sessao_table(db)
|
| 288 |
+
db.execute(text("UPDATE sessao_web SET ativo = 0 WHERE session_id = :sid"), {"sid": sid})
|
| 289 |
+
db.commit()
|
| 290 |
+
except Exception:
|
| 291 |
+
db.rollback()
|
| 292 |
+
finally:
|
| 293 |
+
try:
|
| 294 |
+
db.close()
|
| 295 |
+
except Exception:
|
| 296 |
+
pass
|
| 297 |
+
|
| 298 |
+
# ===============================
|
| 299 |
+
# Aviso Global — Util (leitura e sanitização)
|
| 300 |
+
# ===============================
|
| 301 |
+
def _sanitize_largura(largura_raw: str) -> str:
|
| 302 |
+
val = (largura_raw or "").strip()
|
| 303 |
+
if not val:
|
| 304 |
+
return "100%"
|
| 305 |
+
if val.endswith("%") or val.endswith("px"):
|
| 306 |
+
return val
|
| 307 |
+
if val.isdigit():
|
| 308 |
+
return f"{val}px"
|
| 309 |
+
return "100%"
|
| 310 |
+
|
| 311 |
+
def obter_aviso_ativo(db):
|
| 312 |
+
try:
|
| 313 |
+
aviso = (
|
| 314 |
+
db.query(AvisoGlobal)
|
| 315 |
+
.filter(AvisoGlobal.ativo == True)
|
| 316 |
+
.order_by(AvisoGlobal.updated_at.desc(), AvisoGlobal.created_at.desc())
|
| 317 |
+
.first()
|
| 318 |
+
)
|
| 319 |
+
return aviso
|
| 320 |
+
except Exception:
|
| 321 |
+
return None
|
| 322 |
+
|
| 323 |
+
# ===============================
|
| 324 |
+
# Aviso Global — Render do banner superior (robusto)
|
| 325 |
+
# ===============================
|
| 326 |
+
def _render_aviso_global_topbar():
|
| 327 |
+
try:
|
| 328 |
+
db = _get_db_session()
|
| 329 |
+
except Exception as e:
|
| 330 |
+
st.sidebar.warning(f"Aviso desativado: sessão indisponível ({e})")
|
| 331 |
+
return
|
| 332 |
+
|
| 333 |
+
aviso = None
|
| 334 |
+
try:
|
| 335 |
+
aviso = obter_aviso_ativo(db)
|
| 336 |
+
except Exception as e:
|
| 337 |
+
st.sidebar.warning(f"Aviso desativado: falha ao consultar ({e})")
|
| 338 |
+
aviso = None
|
| 339 |
+
finally:
|
| 340 |
+
try:
|
| 341 |
+
db.close()
|
| 342 |
+
except Exception:
|
| 343 |
+
pass
|
| 344 |
+
|
| 345 |
+
if not aviso:
|
| 346 |
+
return
|
| 347 |
+
|
| 348 |
+
try:
|
| 349 |
+
largura = _sanitize_largura(aviso.largura)
|
| 350 |
+
bg = aviso.bg_color or "#FFF3CD"
|
| 351 |
+
fg = aviso.text_color or "#664D03"
|
| 352 |
+
efeito = (aviso.efeito or "marquee").lower()
|
| 353 |
+
velocidade = int(aviso.velocidade or 20)
|
| 354 |
+
try:
|
| 355 |
+
font_size = max(10, min(int(getattr(aviso, "font_size", 14)), 48))
|
| 356 |
+
except Exception:
|
| 357 |
+
font_size = 14
|
| 358 |
+
|
| 359 |
+
altura = 52 # px
|
| 360 |
+
|
| 361 |
+
st.markdown(
|
| 362 |
+
f"""
|
| 363 |
+
<style>
|
| 364 |
+
/* Não derrube overlays do Streamlit */
|
| 365 |
+
.stApp::before,
|
| 366 |
+
header[data-testid="stHeader"],
|
| 367 |
+
[data-testid="stToolbar"],
|
| 368 |
+
[data-testid="stDecoration"],
|
| 369 |
+
[data-testid="collapsedControl"],
|
| 370 |
+
.stApp [class*="stDialog"] {{
|
| 371 |
+
z-index: 1 !important;
|
| 372 |
+
}}
|
| 373 |
+
/* Reserva espaço para a barra */
|
| 374 |
+
[data-testid="stAppViewContainer"] {{
|
| 375 |
+
padding-top: {altura + 8}px !important;
|
| 376 |
+
}}
|
| 377 |
+
|
| 378 |
+
.ag-topbar-wrap {{
|
| 379 |
+
position: fixed;
|
| 380 |
+
top: 0;
|
| 381 |
+
left: 0;
|
| 382 |
+
width: {largura};
|
| 383 |
+
z-index: 2147483647 !important;
|
| 384 |
+
background: {bg};
|
| 385 |
+
color: {fg};
|
| 386 |
+
border-bottom: 1px solid rgba(0,0,0,.12);
|
| 387 |
+
box-shadow: 0 2px 8px rgba(0,0,0,.15);
|
| 388 |
+
border-radius: 0 0 10px 10px;
|
| 389 |
+
pointer-events: none;
|
| 390 |
+
}}
|
| 391 |
+
.ag-topbar-inner {{
|
| 392 |
+
display: flex;
|
| 393 |
+
align-items: center;
|
| 394 |
+
height: {altura}px;
|
| 395 |
+
padding: 0 14px;
|
| 396 |
+
overflow: hidden;
|
| 397 |
+
font-weight: 700;
|
| 398 |
+
font-size: {font_size}px;
|
| 399 |
+
letter-spacing: .2px;
|
| 400 |
+
white-space: nowrap;
|
| 401 |
+
}}
|
| 402 |
+
.ag-topbar-marquee > span {{
|
| 403 |
+
display: inline-block;
|
| 404 |
+
padding-left: 100%;
|
| 405 |
+
animation: ag-marquee {velocidade}s linear infinite;
|
| 406 |
+
}}
|
| 407 |
+
@keyframes ag-marquee {{
|
| 408 |
+
0% {{ transform: translateX(0); }}
|
| 409 |
+
100% {{ transform: translateX(-100%); }}
|
| 410 |
+
}}
|
| 411 |
+
/* Acessibilidade: reduz movimento */
|
| 412 |
+
@media (prefers-reduced-motion: reduce) {{
|
| 413 |
+
.ag-topbar-marquee > span {{
|
| 414 |
+
animation: none !important;
|
| 415 |
+
padding-left: 0;
|
| 416 |
+
}}
|
| 417 |
+
}}
|
| 418 |
+
@media (max-width: 500px) {{
|
| 419 |
+
.ag-topbar-inner {{
|
| 420 |
+
font-size: {max(10, font_size-3)}px;
|
| 421 |
+
padding: 0 8px;
|
| 422 |
+
height: 44px;
|
| 423 |
+
}}
|
| 424 |
+
[data-testid="stAppViewContainer"] {{
|
| 425 |
+
padding-top: 52px !important;
|
| 426 |
+
}}
|
| 427 |
+
}}
|
| 428 |
+
</style>
|
| 429 |
+
|
| 430 |
+
<div class="ag-topbar-wrap">
|
| 431 |
+
<div class="ag-topbar-inner {'ag-topbar-marquee' if efeito=='marquee' else ''}">
|
| 432 |
+
<span>{aviso.mensagem}</span>
|
| 433 |
+
</div>
|
| 434 |
+
</div>
|
| 435 |
+
""",
|
| 436 |
+
unsafe_allow_html=True
|
| 437 |
+
)
|
| 438 |
+
except Exception as e:
|
| 439 |
+
st.sidebar.warning(f"Aviso desativado: erro de render ({e})")
|
| 440 |
+
return
|
| 441 |
+
|
| 442 |
+
# ===============================
|
| 443 |
+
# Logout (utilitário)
|
| 444 |
+
# ===============================
|
| 445 |
+
def logout():
|
| 446 |
+
"""Finaliza a sessão do usuário, limpa estados e recarrega a aplicação."""
|
| 447 |
+
_mark_session_inactive() # marca esta sessão como inativa
|
| 448 |
+
st.session_state.logado = False
|
| 449 |
+
st.session_state.usuario = None
|
| 450 |
+
st.session_state.perfil = None
|
| 451 |
+
st.session_state.nome = None
|
| 452 |
+
st.session_state.email = None
|
| 453 |
+
st.session_state.quiz_verificado = False
|
| 454 |
+
st.rerun()
|
| 455 |
+
|
| 456 |
+
# ===============================
|
| 457 |
+
# 🎂 Banner/efeito de aniversário
|
| 458 |
+
# ===============================
|
| 459 |
+
def _show_birthday_banner_if_needed():
|
| 460 |
+
if st.session_state.get("__show_birthday__"):
|
| 461 |
+
st.session_state["__show_birthday__"] = False
|
| 462 |
+
st.markdown(
|
| 463 |
+
"""
|
| 464 |
+
<style>
|
| 465 |
+
.confetti-wrapper { position: relative; width: 100%; height: 0; }
|
| 466 |
+
.confetti-area { position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
| 467 |
+
pointer-events: none; z-index: 9999; }
|
| 468 |
+
.confetti { position: absolute; top: -5%; font-size: 24px; animation-name: confetti-fall;
|
| 469 |
+
animation-timing-function: linear; animation-iteration-count: 1; }
|
| 470 |
+
@keyframes confetti-fall {
|
| 471 |
+
0% { transform: translateY(-5vh) rotate(0deg); opacity: 1; }
|
| 472 |
+
100% { transform: translateY(105vh) rotate(360deg); opacity: 0; }
|
| 473 |
+
}
|
| 474 |
+
.confetti:nth-child(1) { left: 5%; animation-duration: 3.5s; }
|
| 475 |
+
.confetti:nth-child(2) { left: 12%; animation-duration: 4.0s; }
|
| 476 |
+
.confetti:nth-child(3) { left: 20%; animation-duration: 3.2s; }
|
| 477 |
+
.confetti:nth-child(4) { left: 28%; animation-duration: 4.3s; }
|
| 478 |
+
.confetti:nth-child(5) { left: 36%; animation-duration: 3.8s; }
|
| 479 |
+
.confetti:nth-child(6) { left: 44%; animation-duration: 4.1s; }
|
| 480 |
+
.confetti:nth-child(7) { left: 52%; animation-duration: 3.4s; }
|
| 481 |
+
.confetti:nth-child(8) { left: 60%; animation-duration: 4.4s; }
|
| 482 |
+
.confetti:nth-child(9) { left: 68%; animation-duration: 3.9s; }
|
| 483 |
+
.confetti:nth-child(10) { left: 76%; animation-duration: 4.2s; }
|
| 484 |
+
.confetti:nth-child(11) { left: 84%; animation-duration: 3.6s; }
|
| 485 |
+
.confetti:nth-child(12) { left: 92%; animation-duration: 4.0s; }
|
| 486 |
+
</style>
|
| 487 |
+
<div class="confetti-wrapper">
|
| 488 |
+
<div class="confetti-area">
|
| 489 |
+
<div class="confetti">🎊</div><div class="confetti">🎉</div>
|
| 490 |
+
<div class="confetti">🎊</div><div class="confetti">🎉</div>
|
| 491 |
+
<div class="confetti">🎊</div><div class="confetti">🎉</div>
|
| 492 |
+
<div class="confetti">🎊</div><div class="confetti">🎉</div>
|
| 493 |
+
<div class="confetti">🎊</div><div class="confetti">🎉</div>
|
| 494 |
+
<div class="confetti">🎊</div><div class="confetti">🎉</div>
|
| 495 |
+
</div>
|
| 496 |
+
</div>
|
| 497 |
+
""",
|
| 498 |
+
unsafe_allow_html=True
|
| 499 |
+
)
|
| 500 |
+
st.balloons()
|
| 501 |
+
nome = st.session_state.get("nome") or st.session_state.get("usuario") or "Usuário"
|
| 502 |
+
st.markdown(
|
| 503 |
+
f"""
|
| 504 |
+
<div style="display:flex;justify-content:center;align-items:center;text-align:center;width:100%;margin:40px 0;">
|
| 505 |
+
<div style="font-size: 36px; font-weight: 800; color:#A020F0;
|
| 506 |
+
background:linear-gradient(90deg,#FFF0F6,#F0E6FF);
|
| 507 |
+
padding:20px 30px; border-radius:16px; box-shadow:0 4px 10px rgba(0,0,0,.08);">
|
| 508 |
+
🎉 Feliz Aniversário, {nome}! 🎉
|
| 509 |
+
</div>
|
| 510 |
+
</div>
|
| 511 |
+
""",
|
| 512 |
+
unsafe_allow_html=True
|
| 513 |
+
)
|
| 514 |
+
COR_FRASE = "#0d6efd"
|
| 515 |
+
st.markdown(
|
| 516 |
+
f"""
|
| 517 |
+
<div style="display:flex;justify-content:center;align-items:center;text-align:center;width:100%;margin-top:10px;">
|
| 518 |
+
<div style="font-size: 20px; font-weight: 500; color:{COR_FRASE}; padding:10px;">
|
| 519 |
+
Desejamos a você muitas conquistas e bons embarques ao longo do ano! 💜
|
| 520 |
+
</div>
|
| 521 |
+
</div>
|
| 522 |
+
""",
|
| 523 |
+
unsafe_allow_html=True
|
| 524 |
+
)
|
| 525 |
+
|
| 526 |
+
# ===============================
|
| 527 |
+
# MAIN
|
| 528 |
+
# ===============================
|
| 529 |
+
def main():
|
| 530 |
+
# Estados iniciais
|
| 531 |
+
if "logado" not in st.session_state:
|
| 532 |
+
st.session_state.logado = False
|
| 533 |
+
if "usuario" not in st.session_state:
|
| 534 |
+
st.session_state.usuario = None
|
| 535 |
+
if "quiz_verificado" not in st.session_state:
|
| 536 |
+
st.session_state.quiz_verificado = False
|
| 537 |
+
if "user_responses_viewed" not in st.session_state:
|
| 538 |
+
st.session_state.user_responses_viewed = False
|
| 539 |
+
if "nav_target" not in st.session_state:
|
| 540 |
+
st.session_state.nav_target = None
|
| 541 |
+
|
| 542 |
+
# ✅ Estado do intervalo de autoatualização (padrão aumentado p/ 60s; 0 = desligado)
|
| 543 |
+
st.session_state.setdefault("__auto_refresh_interval_sec__", 60)
|
| 544 |
+
|
| 545 |
+
# LOGIN
|
| 546 |
+
if not st.session_state.logado:
|
| 547 |
+
st.session_state.quiz_verificado = False
|
| 548 |
+
exibir_logo(top=True, sidebar=False)
|
| 549 |
+
login()
|
| 550 |
+
return
|
| 551 |
+
|
| 552 |
+
# 👥 Heartbeat + Badge de usuários logados (APENAS ADMIN)
|
| 553 |
+
_session_heartbeat(st.session_state.usuario)
|
| 554 |
+
if (st.session_state.get("perfil") or "").strip().lower() == "admin":
|
| 555 |
+
try:
|
| 556 |
+
online_now = _get_active_users_count()
|
| 557 |
+
except Exception:
|
| 558 |
+
online_now = 0
|
| 559 |
+
st.sidebar.markdown(
|
| 560 |
+
f"""
|
| 561 |
+
<div style="padding:8px 10px;margin-top:6px;margin-bottom:6px;border-radius:8px;
|
| 562 |
+
background:#1e293b; color:#e2e8f0; border:1px solid #334155;">
|
| 563 |
+
<span style="font-size:13px;">🟢 Online (últimos {_SESS_TTL_MIN} min)</span><br>
|
| 564 |
+
<span style="font-size:22px;font-weight:800;">{online_now}</span>
|
| 565 |
+
</div>
|
| 566 |
+
""",
|
| 567 |
+
unsafe_allow_html=True
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
# 🔄 Botão de Recarregar (mantém a sessão ativa) + ⏱️ Controle do intervalo
|
| 571 |
+
st.sidebar.markdown("---")
|
| 572 |
+
# Linha com botão de recarregar e popover para o intervalo
|
| 573 |
+
col_reload, col_interval = st.sidebar.columns([1, 1])
|
| 574 |
+
if col_reload.button("🔄 Recarregar (sem sair)", key="__btn_reload_now__"):
|
| 575 |
+
st.rerun()
|
| 576 |
+
|
| 577 |
+
# Popover (se disponível) para configurar intervalo; fallback para expander
|
| 578 |
+
if hasattr(st, "popover"):
|
| 579 |
+
with col_interval.popover("⏱️ Autoatualização"):
|
| 580 |
+
new_val = st.number_input(
|
| 581 |
+
"Intervalo (segundos) — 0 desativa",
|
| 582 |
+
min_value=0, max_value=3600,
|
| 583 |
+
value=int(st.session_state["__auto_refresh_interval_sec__"]),
|
| 584 |
+
step=5, key="__auto_refresh_input__"
|
| 585 |
+
)
|
| 586 |
+
if st.button("Aplicar intervalo", key="__btn_apply_auto_refresh__"):
|
| 587 |
+
st.session_state["__auto_refresh_interval_sec__"] = int(new_val)
|
| 588 |
+
try:
|
| 589 |
+
if int(new_val) > 0:
|
| 590 |
+
st.toast(f"Autoatualização ajustada para {int(new_val)}s.", icon="⏱️")
|
| 591 |
+
else:
|
| 592 |
+
st.toast("Autoatualização desativada.", icon="⛔")
|
| 593 |
+
except Exception:
|
| 594 |
+
pass
|
| 595 |
+
st.rerun()
|
| 596 |
+
else:
|
| 597 |
+
with st.sidebar.expander("⏱️ Autoatualização", expanded=False):
|
| 598 |
+
new_val = st.number_input(
|
| 599 |
+
"Intervalo (segundos) — 0 desativa",
|
| 600 |
+
min_value=0, max_value=3600,
|
| 601 |
+
value=int(st.session_state["__auto_refresh_interval_sec__"]),
|
| 602 |
+
step=5, key="__auto_refresh_input__"
|
| 603 |
+
)
|
| 604 |
+
if st.button("Aplicar intervalo", key="__btn_apply_auto_refresh__"):
|
| 605 |
+
st.session_state["__auto_refresh_interval_sec__"] = int(new_val)
|
| 606 |
+
try:
|
| 607 |
+
if int(new_val) > 0:
|
| 608 |
+
st.toast(f"Autoatualização ajustada para {int(new_val)}s.", icon="⏱️")
|
| 609 |
+
else:
|
| 610 |
+
st.toast("Autoatualização desativada.", icon="⛔")
|
| 611 |
+
except Exception:
|
| 612 |
+
pass
|
| 613 |
+
st.rerun()
|
| 614 |
+
|
| 615 |
+
usuario = st.session_state.usuario
|
| 616 |
+
perfil = (st.session_state.get("perfil") or "usuario").strip().lower()
|
| 617 |
+
|
| 618 |
+
# QUIZ
|
| 619 |
+
if not st.session_state.quiz_verificado:
|
| 620 |
+
if not quiz_respondido_hoje(usuario):
|
| 621 |
+
exibir_logo(top=True, sidebar=False)
|
| 622 |
+
quiz.main()
|
| 623 |
+
return
|
| 624 |
+
else:
|
| 625 |
+
st.session_state.quiz_verificado = True
|
| 626 |
+
st.rerun()
|
| 627 |
+
|
| 628 |
+
# SISTEMA LIBERADO
|
| 629 |
+
exibir_logo(top=True, sidebar=True)
|
| 630 |
+
_render_aviso_global_topbar()
|
| 631 |
+
_show_birthday_banner_if_needed()
|
| 632 |
+
|
| 633 |
+
st.sidebar.markdown("### Menu | 🎉 Carnaval 🎭 2026!")
|
| 634 |
+
|
| 635 |
+
# Banco ativo na sidebar
|
| 636 |
+
try:
|
| 637 |
+
banco_label = bank_label(current_db_choice()) if _HAS_ROUTER else (
|
| 638 |
+
"🟢 Produção" if current_db_choice() == "prod" else "🔴 Teste"
|
| 639 |
+
)
|
| 640 |
+
st.sidebar.caption(f"🗄️ Banco ativo: {banco_label}")
|
| 641 |
+
except Exception:
|
| 642 |
+
pass
|
| 643 |
+
|
| 644 |
+
# =========================
|
| 645 |
+
# Notificações no sidebar
|
| 646 |
+
# =========================
|
| 647 |
+
|
| 648 |
+
# --- Admin: pendentes ---
|
| 649 |
+
if perfil == "admin":
|
| 650 |
+
try:
|
| 651 |
+
db = _get_db_session()
|
| 652 |
+
pendentes = db.query(IOIRunSugestao).filter(func.lower(IOIRunSugestao.status) == "pendente").count()
|
| 653 |
+
except Exception:
|
| 654 |
+
pendentes = 0
|
| 655 |
+
finally:
|
| 656 |
+
try: db.close()
|
| 657 |
+
except Exception: pass
|
| 658 |
+
|
| 659 |
+
if pendentes > 0:
|
| 660 |
+
st.sidebar.markdown(
|
| 661 |
+
"""
|
| 662 |
+
<div style="padding:8px 10px;border-radius:8px;background:#FFF3CD;color:#664D03;
|
| 663 |
+
border:1px solid #FFECB5;margin-bottom:6px;">
|
| 664 |
+
<b>🔔 {pendentes} sugestão(ões) pendente(s)</b><br>
|
| 665 |
+
<span style="font-size:12px;">Acesse a caixa de entrada para responder.</span>
|
| 666 |
+
</div>
|
| 667 |
+
""".format(pendentes=pendentes),
|
| 668 |
+
unsafe_allow_html=True
|
| 669 |
+
)
|
| 670 |
+
|
| 671 |
+
# 👉 Direciona para o MESMO módulo do menu (resposta.main())
|
| 672 |
+
if st.sidebar.button("📬 Abrir Caixa de Entrada (Admin)"):
|
| 673 |
+
st.session_state.nav_target = "resposta"
|
| 674 |
+
st.rerun()
|
| 675 |
+
|
| 676 |
+
# --- Usuário: respostas novas (após último 'visto') ---
|
| 677 |
+
if perfil != "admin":
|
| 678 |
+
# Última vez que o usuário realmente abriu e visualizou as respostas
|
| 679 |
+
last_seen_dt = st.session_state.get("__user_last_answer_seen__")
|
| 680 |
+
|
| 681 |
+
try:
|
| 682 |
+
db = _get_db_session()
|
| 683 |
+
|
| 684 |
+
# Qual é a resposta mais recente existente
|
| 685 |
+
last_answer_dt_row = (
|
| 686 |
+
db.query(IOIRunSugestao.data_resposta)
|
| 687 |
+
.filter(
|
| 688 |
+
IOIRunSugestao.usuario == usuario,
|
| 689 |
+
func.lower(IOIRunSugestao.status) == "respondida",
|
| 690 |
+
IOIRunSugestao.data_resposta != None
|
| 691 |
+
)
|
| 692 |
+
.order_by(IOIRunSugestao.data_resposta.desc())
|
| 693 |
+
.first()
|
| 694 |
+
)
|
| 695 |
+
last_answer_dt = last_answer_dt_row[0] if last_answer_dt_row else None
|
| 696 |
+
|
| 697 |
+
# Se há algo mais novo do que o 'visto', marcamos como não visto
|
| 698 |
+
if last_answer_dt and (not last_seen_dt or last_answer_dt > last_seen_dt):
|
| 699 |
+
st.session_state.user_responses_viewed = False
|
| 700 |
+
|
| 701 |
+
# ✅ Conta SOMENTE respostas novas (depois do 'last_seen_dt')
|
| 702 |
+
novas_respostas = (
|
| 703 |
+
db.query(IOIRunSugestao)
|
| 704 |
+
.filter(
|
| 705 |
+
IOIRunSugestao.usuario == usuario,
|
| 706 |
+
func.lower(IOIRunSugestao.status) == "respondida",
|
| 707 |
+
(IOIRunSugestao.data_resposta > last_seen_dt) if last_seen_dt else (IOIRunSugestao.data_resposta != None)
|
| 708 |
+
)
|
| 709 |
+
.count()
|
| 710 |
+
)
|
| 711 |
+
except Exception:
|
| 712 |
+
novas_respostas = 0
|
| 713 |
+
finally:
|
| 714 |
+
try: db.close()
|
| 715 |
+
except Exception: pass
|
| 716 |
+
|
| 717 |
+
# ✅ Exibir card de nova mensagem até o usuário clicar em "Ver respostas"
|
| 718 |
+
if novas_respostas > 0 and not st.session_state.get("user_responses_viewed", False):
|
| 719 |
+
st.sidebar.markdown(
|
| 720 |
+
"""
|
| 721 |
+
<div style="padding:8px 10px;border-radius:8px;background:#D1E7DD;color:#0F5132;
|
| 722 |
+
border:1px solid #BADBCC;margin-bottom:6px;">
|
| 723 |
+
<b>🔔 {resps} resposta(s) nova(s) para suas sugestões</b><br>
|
| 724 |
+
<span style="font-size:12px;">Clique para ver suas respostas.</span>
|
| 725 |
+
</div>
|
| 726 |
+
""".format(resps=novas_respostas),
|
| 727 |
+
unsafe_allow_html=True
|
| 728 |
+
)
|
| 729 |
+
|
| 730 |
+
# (Opcional) Toast discreto — aparece uma única vez por sessão enquanto houver novidade
|
| 731 |
+
if not st.session_state.get("__user_toast_shown__"):
|
| 732 |
+
try:
|
| 733 |
+
st.toast("Você tem novas respostas do IOI‑RUN. Clique em '📥 Ver respostas'.", icon="💬")
|
| 734 |
+
except Exception:
|
| 735 |
+
pass
|
| 736 |
+
st.session_state["__user_toast_shown__"] = True
|
| 737 |
+
|
| 738 |
+
if st.sidebar.button("📥 Ver respostas"):
|
| 739 |
+
# Não atualizamos last_seen aqui; isso é feito dentro do módulo do usuário
|
| 740 |
+
st.session_state.nav_target = "sugestoes_ioirun"
|
| 741 |
+
st.session_state.user_responses_viewed = True
|
| 742 |
+
st.rerun()
|
| 743 |
+
else:
|
| 744 |
+
# Se não há novidades, libera o toast para a próxima vez que houver
|
| 745 |
+
st.session_state["__user_toast_shown__"] = False
|
| 746 |
+
|
| 747 |
+
# ------------------------- Menu lateral -------------------------
|
| 748 |
+
termo_busca = st.sidebar.text_input("Pesquisar módulo:").strip().lower()
|
| 749 |
+
|
| 750 |
+
try:
|
| 751 |
+
ambiente_atual = current_db_choice() if _HAS_ROUTER else "prod"
|
| 752 |
+
except Exception:
|
| 753 |
+
ambiente_atual = "prod"
|
| 754 |
+
|
| 755 |
+
grupos_disponiveis = obter_grupos_disponiveis(
|
| 756 |
+
MODULES,
|
| 757 |
+
perfil=st.session_state.get("perfil", "usuario"),
|
| 758 |
+
usuario=st.session_state.get("usuario"),
|
| 759 |
+
ambiente=ambiente_atual,
|
| 760 |
+
verificar_permissao=verificar_permissao
|
| 761 |
+
)
|
| 762 |
+
|
| 763 |
+
if not grupos_disponiveis:
|
| 764 |
+
st.sidebar.selectbox("Selecione a operação:", ["Em desenvolvimento"], index=0)
|
| 765 |
+
st.warning("Nenhuma operação disponível para seu perfil/ambiente neste momento.")
|
| 766 |
+
return
|
| 767 |
+
|
| 768 |
+
grupo_escolhido = st.sidebar.selectbox("Selecione a operação:", grupos_disponiveis)
|
| 769 |
+
|
| 770 |
+
opcoes = obter_modulos_para_grupo(
|
| 771 |
+
MODULES, grupo_escolhido, termo_busca,
|
| 772 |
+
perfil=st.session_state.get("perfil", "usuario"),
|
| 773 |
+
usuario=st.session_state.get("usuario"),
|
| 774 |
+
ambiente=ambiente_atual,
|
| 775 |
+
verificar_permissao=verificar_permissao
|
| 776 |
+
)
|
| 777 |
+
|
| 778 |
+
with st.sidebar.expander("🔧 Diagnóstico do menu", expanded=False):
|
| 779 |
+
st.caption(f"Perfil: **{st.session_state.get('perfil', '—')}** | Grupo: **{grupo_escolhido}** | Busca: **{termo_busca or '∅'}** | Ambiente: **{ambiente_atual}**")
|
| 780 |
+
try:
|
| 781 |
+
mods_dbg = [{"id": mid, "label": lbl} for mid, lbl in (opcoes or [])]
|
| 782 |
+
except Exception:
|
| 783 |
+
mods_dbg = []
|
| 784 |
+
st.write("Módulos visíveis (após regras):", mods_dbg if mods_dbg else "—")
|
| 785 |
+
|
| 786 |
+
# Failsafe outlook_relatorio
|
| 787 |
+
try:
|
| 788 |
+
mod_outlook = MODULES.get("outlook_relatorio")
|
| 789 |
+
if mod_outlook:
|
| 790 |
+
mesmo_grupo = (mod_outlook.get("grupo") == grupo_escolhido)
|
| 791 |
+
perfil_ok = verificar_permissao(
|
| 792 |
+
perfil=st.session_state.get("perfil", "usuario"),
|
| 793 |
+
modulo_key="outlook_relatorio",
|
| 794 |
+
usuario=st.session_state.get("usuario"),
|
| 795 |
+
ambiente=ambiente_atual
|
| 796 |
+
)
|
| 797 |
+
ja_nas_opcoes = any(mid == "outlook_relatorio" for mid, _ in (opcoes or []))
|
| 798 |
+
passa_busca = (not termo_busca) or (termo_busca in mod_outlook.get("label", "").strip().lower())
|
| 799 |
+
if mesmo_grupo and perfil_ok and not ja_nas_opcoes and passa_busca:
|
| 800 |
+
opcoes = (opcoes or []) + [("outlook_relatorio", mod_outlook.get("label", "Relatorio portaria"))]
|
| 801 |
+
except Exception:
|
| 802 |
+
pass
|
| 803 |
+
|
| 804 |
+
# Failsafe repositorio_load
|
| 805 |
+
try:
|
| 806 |
+
mod_repo = MODULES.get("repositorio_load")
|
| 807 |
+
if mod_repo:
|
| 808 |
+
mesmo_grupo_r = (mod_repo.get("grupo") == grupo_escolhido)
|
| 809 |
+
perfil_ok_r = verificar_permissao(
|
| 810 |
+
perfil=st.session_state.get("perfil", "usuario"),
|
| 811 |
+
modulo_key="repositorio_load",
|
| 812 |
+
usuario=st.session_state.get("usuario"),
|
| 813 |
+
ambiente=ambiente_atual
|
| 814 |
+
)
|
| 815 |
+
ja_nas_opcoes_r = any(mid == "repositorio_load" for mid, _ in (opcoes or []))
|
| 816 |
+
passa_busca_r = (not termo_busca) or (termo_busca in mod_repo.get("label", "").strip().lower())
|
| 817 |
+
if mesmo_grupo_r and perfil_ok_r and not ja_nas_opcoes_r and passa_busca_r:
|
| 818 |
+
opcoes = (opcoes or []) + [("repositorio_load", mod_repo.get("label", "Repositório Load"))]
|
| 819 |
+
except Exception:
|
| 820 |
+
pass
|
| 821 |
+
|
| 822 |
+
if not opcoes:
|
| 823 |
+
st.sidebar.selectbox("Selecione o módulo:", ["Em desenvolvimento"], index=0)
|
| 824 |
+
st.warning(f"A operação '{grupo_escolhido}' está em desenvolvimento.")
|
| 825 |
+
return
|
| 826 |
+
|
| 827 |
+
# ============================================================
|
| 828 |
+
# 🔒 Fix: selectbox com 'key' + seleção forçada para 'resposta'
|
| 829 |
+
# quando vier de nav_target (sidebar) ou quando já estivermos na página.
|
| 830 |
+
# ============================================================
|
| 831 |
+
labels = [label for _, label in opcoes]
|
| 832 |
+
|
| 833 |
+
# Se foi solicitado nav_target, injeta a label alvo antes do selectbox
|
| 834 |
+
if st.session_state.get("nav_target"):
|
| 835 |
+
target = st.session_state["nav_target"]
|
| 836 |
+
try:
|
| 837 |
+
target_label = next(lbl for mid, lbl in opcoes if mid == target)
|
| 838 |
+
st.session_state["mod_select_label"] = target_label
|
| 839 |
+
except StopIteration:
|
| 840 |
+
pass
|
| 841 |
+
|
| 842 |
+
# Inicializa/persiste seleção
|
| 843 |
+
if "mod_select_label" not in st.session_state or st.session_state["mod_select_label"] not in labels:
|
| 844 |
+
st.session_state["mod_select_label"] = labels[0]
|
| 845 |
+
|
| 846 |
+
escolha_label = st.sidebar.selectbox(
|
| 847 |
+
"Selecione o módulo:",
|
| 848 |
+
labels,
|
| 849 |
+
index=labels.index(st.session_state["mod_select_label"]),
|
| 850 |
+
key="mod_select_label"
|
| 851 |
+
)
|
| 852 |
+
|
| 853 |
+
pagina_id = next(mod_id for mod_id, label in opcoes if label == escolha_label)
|
| 854 |
+
|
| 855 |
+
# ✅ Navegação com lock (evita disputa com outros reruns)
|
| 856 |
+
if st.session_state.get("nav_target"):
|
| 857 |
+
pagina_id = st.session_state.nav_target
|
| 858 |
+
st.session_state["__nav_lock__"] = True
|
| 859 |
+
else:
|
| 860 |
+
st.session_state["__nav_lock__"] = False
|
| 861 |
+
|
| 862 |
+
# 🔎 Agora que sabemos a página atual, tratamos rr=1 com segurança
|
| 863 |
+
_check_rerun_qs(pagina_atual=pagina_id)
|
| 864 |
+
|
| 865 |
+
# ⏱️ Auto-refresh leve do sidebar — NÃO quando em Inbox/Admin/Outlook/Formulário/Recebimento
|
| 866 |
+
try:
|
| 867 |
+
from streamlit_autorefresh import st_autorefresh
|
| 868 |
+
is_inbox_admin = (pagina_id == "resposta")
|
| 869 |
+
is_outlook_rel = (pagina_id == "outlook_relatorio")
|
| 870 |
+
is_formulario = (pagina_id == "formulario")
|
| 871 |
+
is_recebimento = (pagina_id == "recebimento")
|
| 872 |
+
interval_sec = int(st.session_state.get("__auto_refresh_interval_sec__", 60))
|
| 873 |
+
if (interval_sec > 0) and not (is_inbox_admin or is_outlook_rel or is_formulario or is_recebimento):
|
| 874 |
+
# key dinâmica por intervalo evita conflitos ao trocar o valor
|
| 875 |
+
st_autorefresh(interval=interval_sec * 1000, key=f"sidebar_autorefresh_{interval_sec}s")
|
| 876 |
+
except Exception:
|
| 877 |
+
pass
|
| 878 |
+
|
| 879 |
+
# Logout
|
| 880 |
+
st.sidebar.markdown("---")
|
| 881 |
+
if st.session_state.get("logado"):
|
| 882 |
+
if st.sidebar.button("🚪 Sair (Logout)"):
|
| 883 |
+
logout()
|
| 884 |
+
|
| 885 |
+
st.divider()
|
| 886 |
+
|
| 887 |
+
# ------------------------- Roteamento -------------------------
|
| 888 |
+
if pagina_id == "formulario":
|
| 889 |
+
formulario.main()
|
| 890 |
+
elif pagina_id == "consulta":
|
| 891 |
+
consulta.main()
|
| 892 |
+
elif pagina_id == "relatorio":
|
| 893 |
+
relatorio.main()
|
| 894 |
+
elif pagina_id == "ranking":
|
| 895 |
+
ranking.main()
|
| 896 |
+
elif pagina_id == "quiz":
|
| 897 |
+
quiz.main()
|
| 898 |
+
ranking.main()
|
| 899 |
+
elif pagina_id == "quiz_admin":
|
| 900 |
+
quiz_admin.main()
|
| 901 |
+
elif pagina_id == "usuarios":
|
| 902 |
+
usuarios_admin.main()
|
| 903 |
+
elif pagina_id == "administracao":
|
| 904 |
+
administracao.main()
|
| 905 |
+
elif pagina_id == "videos":
|
| 906 |
+
videos.main()
|
| 907 |
+
elif pagina_id == "auditoria":
|
| 908 |
+
auditoria.main()
|
| 909 |
+
elif pagina_id == "auditoria_cleanup":
|
| 910 |
+
auditoria_cleanup.main()
|
| 911 |
+
elif pagina_id == "importacao":
|
| 912 |
+
importar_excel.main()
|
| 913 |
+
elif pagina_id == "calendario":
|
| 914 |
+
calendario.main()
|
| 915 |
+
elif pagina_id == "jogos":
|
| 916 |
+
st.session_state.setdefault("pontuacao", 0)
|
| 917 |
+
st.session_state.setdefault("rodadas", 0)
|
| 918 |
+
st.session_state.setdefault("ultimo_resultado", None)
|
| 919 |
+
jogos.main()
|
| 920 |
+
elif pagina_id == "temporario":
|
| 921 |
+
db_tools.main()
|
| 922 |
+
elif pagina_id == "db_admin":
|
| 923 |
+
db_admin.main()
|
| 924 |
+
elif pagina_id == "db_monitor":
|
| 925 |
+
db_monitor.main()
|
| 926 |
+
elif pagina_id == "operacao":
|
| 927 |
+
operacao.main()
|
| 928 |
+
elif pagina_id == "resposta": # 📬 Admin
|
| 929 |
+
resposta.main()
|
| 930 |
+
elif pagina_id == "db_export_import":
|
| 931 |
+
db_export_import.main()
|
| 932 |
+
elif pagina_id == "produtividade_especialista":
|
| 933 |
+
produtividade_especialista.main()
|
| 934 |
+
elif pagina_id == "outlook_relatorio":
|
| 935 |
+
outlook_relatorio.main()
|
| 936 |
+
elif pagina_id == "sugestoes_ioirun": # 💡 Usuário
|
| 937 |
+
if st.session_state.get("perfil") == "admin":
|
| 938 |
+
st.info("Use a **📬 Caixa de Entrada (Admin)** para responder sugestões.")
|
| 939 |
+
else:
|
| 940 |
+
sugestoes_usuario.main()
|
| 941 |
+
elif pagina_id == "repositorio_load":
|
| 942 |
+
repositorio_load.main()
|
| 943 |
+
elif pagina_id == "rnc":
|
| 944 |
+
rnc.pagina()
|
| 945 |
+
elif pagina_id == "rnc_listagem":
|
| 946 |
+
rnc_listagem.pagina()
|
| 947 |
+
elif pagina_id == "rnc_relatorio":
|
| 948 |
+
rnc_relatorio.pagina()
|
| 949 |
+
elif pagina_id == "repo_rnc":
|
| 950 |
+
repo_rnc.pagina()
|
| 951 |
+
elif pagina_id == "recebimento":
|
| 952 |
+
recebimento.main()
|
| 953 |
+
|
| 954 |
+
# ------------------------------------------------------
|
| 955 |
+
# ℹ️ INFO — Guia passo a passo de uso (no sidebar)
|
| 956 |
+
# ------------------------------------------------------
|
| 957 |
+
info_mod_default = INFO_MAP_PAGINA_ID.get(pagina_id, "Geral")
|
| 958 |
+
with st.sidebar.expander("ℹ️ Info • Como usar o sistema", expanded=False):
|
| 959 |
+
st.markdown("""
|
| 960 |
+
**Bem-vindo!**
|
| 961 |
+
Este painel reúne instruções rápidas para utilizar cada módulo e seus campos.
|
| 962 |
+
Selecione o módulo abaixo ou navegue pelo menu — o conteúdo ajusta automaticamente.
|
| 963 |
+
""")
|
| 964 |
+
mod_info_sel = st.selectbox(
|
| 965 |
+
"Escolha o módulo para ver instruções:",
|
| 966 |
+
INFO_MODULOS,
|
| 967 |
+
index=INFO_MODULOS.index(info_mod_default) if info_mod_default in INFO_MODULOS else 0,
|
| 968 |
+
key="info_mod_sel"
|
| 969 |
+
)
|
| 970 |
+
st.markdown(INFO_CONTEUDO.get(mod_info_sel, "_Conteúdo não disponível para este módulo._"))
|
| 971 |
+
|
| 972 |
+
# ✅ Libera o nav_target após a 1ª render da página de destino
|
| 973 |
+
if st.session_state.get("__nav_lock__"):
|
| 974 |
+
st.session_state["nav_target"] = None
|
| 975 |
+
st.session_state["__nav_lock__"] = False
|
| 976 |
+
|
| 977 |
+
if __name__ == "__main__":
|
| 978 |
+
main()
|
| 979 |
+
# -------------------------
|
| 980 |
+
# Desenvolvedor e versão
|
| 981 |
+
# -------------------------
|
| 982 |
+
if st.session_state.get("logado") and st.session_state.get("email"):
|
| 983 |
+
st.sidebar.markdown(
|
| 984 |
+
f"""
|
| 985 |
+
<div style="display:inline-flex;align-items:center;gap:8px; padding:4px 8px;border-radius:8px;
|
| 986 |
+
background:#e7f1ff;color:#0d6efd;font-size:13px;line-height:1.2;">
|
| 987 |
+
<span style="font-size:16px;">👤</span>
|
| 988 |
+
<span>{st.session_state.email}</span>
|
| 989 |
+
</div>
|
| 990 |
+
""",
|
| 991 |
+
unsafe_allow_html=True
|
| 992 |
+
)
|
| 993 |
+
|
| 994 |
+
st.sidebar.markdown(
|
| 995 |
+
"""
|
| 996 |
+
<hr style="margin-top: 10px; margin-bottom: 6px;">
|
| 997 |
+
<p style="font-size: 12px; color: #6c757d;">
|
| 998 |
+
Versão: <strong>1.0.0</strong> • Desenvolvedor: <strong>Rodrigo Silva - Ideiasystem | 2026</strong>
|
| 999 |
+
</p>
|
| 1000 |
+
""",
|
| 1001 |
+
unsafe_allow_html=True
|
| 1002 |
+
)
|
| 1003 |
+
|
| 1004 |
+
|
| 1005 |
+
|
| 1006 |
+
|
| 1007 |
+
|
| 1008 |
+
|
| 1009 |
+
|
| 1010 |
+
|
| 1011 |
+
|
| 1012 |
+
|
| 1013 |
+
|
| 1014 |
+
|
| 1015 |
+
|
app_outlook.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
import streamlit as st
|
| 4 |
+
import pandas as pd
|
| 5 |
+
from datetime import datetime, timedelta, date
|
| 6 |
+
import io
|
| 7 |
+
import pythoncom # ✅ necessário para inicializar/finalizar COM em cada operação
|
| 8 |
+
|
| 9 |
+
st.set_page_config(page_title="Relatório de E-mails • Outlook Desktop", layout="wide")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# ==============================
|
| 13 |
+
# Utilitários de exportação/indicadores
|
| 14 |
+
# ==============================
|
| 15 |
+
def build_downloads(df: pd.DataFrame, base_name: str):
|
| 16 |
+
"""Cria botões de download (CSV, Excel e PDF) para o DataFrame."""
|
| 17 |
+
if df.empty:
|
| 18 |
+
st.warning("Nenhum dado para exportar.")
|
| 19 |
+
return
|
| 20 |
+
|
| 21 |
+
# CSV
|
| 22 |
+
csv_buf = io.StringIO()
|
| 23 |
+
df.to_csv(csv_buf, index=False, encoding="utf-8-sig")
|
| 24 |
+
st.download_button(
|
| 25 |
+
"⬇️ Baixar CSV",
|
| 26 |
+
data=csv_buf.getvalue(),
|
| 27 |
+
file_name=f"{base_name}.csv",
|
| 28 |
+
mime="text/csv",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Excel
|
| 32 |
+
xlsx_buf = io.BytesIO()
|
| 33 |
+
with pd.ExcelWriter(xlsx_buf, engine="openpyxl") as writer:
|
| 34 |
+
df.to_excel(writer, index=False, sheet_name="Relatorio")
|
| 35 |
+
xlsx_buf.seek(0)
|
| 36 |
+
st.download_button(
|
| 37 |
+
"⬇️ Baixar Excel",
|
| 38 |
+
data=xlsx_buf,
|
| 39 |
+
file_name=f"{base_name}.xlsx",
|
| 40 |
+
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# PDF (resumo com até 100 linhas para leitura confortável)
|
| 44 |
+
try:
|
| 45 |
+
from reportlab.lib.pagesizes import A4, landscape
|
| 46 |
+
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
|
| 47 |
+
from reportlab.lib import colors
|
| 48 |
+
from reportlab.lib.styles import getSampleStyleSheet
|
| 49 |
+
|
| 50 |
+
pdf_buf = io.BytesIO()
|
| 51 |
+
doc = SimpleDocTemplate(
|
| 52 |
+
pdf_buf,
|
| 53 |
+
pagesize=landscape(A4),
|
| 54 |
+
rightMargin=20, leftMargin=20, topMargin=20, bottomMargin=20
|
| 55 |
+
)
|
| 56 |
+
styles = getSampleStyleSheet()
|
| 57 |
+
story = []
|
| 58 |
+
|
| 59 |
+
title = Paragraph(f"Relatório de E-mails — {base_name}", styles["Title"])
|
| 60 |
+
story.append(title)
|
| 61 |
+
story.append(Spacer(1, 12))
|
| 62 |
+
|
| 63 |
+
# Limita tabela para evitar PDFs gigantes
|
| 64 |
+
df_show = df.copy().head(100)
|
| 65 |
+
data_table = [list(df_show.columns)] + df_show.astype(str).values.tolist()
|
| 66 |
+
table = Table(data_table, repeatRows=1)
|
| 67 |
+
table.setStyle(TableStyle([
|
| 68 |
+
("BACKGROUND", (0,0), (-1,0), colors.HexColor("#E9ECEF")),
|
| 69 |
+
("TEXTCOLOR", (0,0), (-1,0), colors.HexColor("#212529")),
|
| 70 |
+
("GRID", (0,0), (-1,-1), 0.25, colors.HexColor("#ADB5BD")),
|
| 71 |
+
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
|
| 72 |
+
("FONTNAME", (0,1), (-1,-1), "Helvetica"),
|
| 73 |
+
("FONTSIZE", (0,0), (-1,-1), 9),
|
| 74 |
+
("ALIGN", (0,0), (-1,-1), "LEFT"),
|
| 75 |
+
("VALIGN", (0,0), (-1,-1), "MIDDLE"),
|
| 76 |
+
]))
|
| 77 |
+
story.append(table)
|
| 78 |
+
|
| 79 |
+
doc.build(story)
|
| 80 |
+
pdf_buf.seek(0)
|
| 81 |
+
|
| 82 |
+
st.download_button(
|
| 83 |
+
"⬇️ Baixar PDF",
|
| 84 |
+
data=pdf_buf,
|
| 85 |
+
file_name=f"{base_name}.pdf",
|
| 86 |
+
mime="application/pdf",
|
| 87 |
+
)
|
| 88 |
+
except Exception as e:
|
| 89 |
+
st.info(f"PDF: não foi possível gerar o arquivo (ReportLab). Detalhe: {e}")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def render_indicators(df: pd.DataFrame, dt_col_name: str):
|
| 93 |
+
"""Exibe indicadores simples (top remetentes, distribuição por dia)."""
|
| 94 |
+
if df.empty:
|
| 95 |
+
return
|
| 96 |
+
st.subheader("📊 Indicadores")
|
| 97 |
+
col1, col2 = st.columns(2)
|
| 98 |
+
with col1:
|
| 99 |
+
st.write("**Top Remetentes (Top 10)**")
|
| 100 |
+
st.dataframe(
|
| 101 |
+
df["Remetente"].value_counts().head(10).rename("Qtd").to_frame(),
|
| 102 |
+
use_container_width=True,
|
| 103 |
+
)
|
| 104 |
+
with col2:
|
| 105 |
+
st.write("**Mensagens por Dia**")
|
| 106 |
+
if dt_col_name in df.columns:
|
| 107 |
+
_dt = pd.to_datetime(df[dt_col_name], errors="coerce")
|
| 108 |
+
por_dia = _dt.dt.date.value_counts().sort_index().rename("Qtd")
|
| 109 |
+
st.dataframe(por_dia.to_frame(), use_container_width=True)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ==============================
|
| 113 |
+
# Outlook Desktop (Windows) — COM-safe helpers
|
| 114 |
+
# ==============================
|
| 115 |
+
def _list_folders_desktop(root_folder, prefix=""):
|
| 116 |
+
"""Recursão local (já com root_folder pronto) — retorna caminhos completos de subpastas."""
|
| 117 |
+
paths = []
|
| 118 |
+
try:
|
| 119 |
+
for i in range(1, root_folder.Folders.Count + 1):
|
| 120 |
+
f = root_folder.Folders.Item(i)
|
| 121 |
+
full_path = prefix + f.Name
|
| 122 |
+
paths.append(full_path)
|
| 123 |
+
# recursão
|
| 124 |
+
try:
|
| 125 |
+
paths.extend(_list_folders_desktop(f, prefix=full_path + "\\"))
|
| 126 |
+
except Exception:
|
| 127 |
+
pass
|
| 128 |
+
except Exception:
|
| 129 |
+
pass
|
| 130 |
+
return paths
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def safe_list_all_folders():
|
| 134 |
+
"""
|
| 135 |
+
✅ Inicializa COM, conecta no Outlook e retorna TODOS os caminhos de pastas
|
| 136 |
+
da caixa padrão. Finaliza COM ao terminar. Evita 'CoInitialize não foi chamado'.
|
| 137 |
+
"""
|
| 138 |
+
try:
|
| 139 |
+
import win32com.client
|
| 140 |
+
pythoncom.CoInitialize() # inicializa COM
|
| 141 |
+
outlook = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI")
|
| 142 |
+
root_mailbox = outlook.Folders.Item(1) # índice da caixa de correio padrão
|
| 143 |
+
return _list_folders_desktop(root_mailbox, prefix="")
|
| 144 |
+
except Exception as e:
|
| 145 |
+
st.sidebar.info(f"Não foi possível listar pastas automaticamente ({e}). Informe manualmente abaixo.")
|
| 146 |
+
return []
|
| 147 |
+
finally:
|
| 148 |
+
try:
|
| 149 |
+
pythoncom.CoUninitialize() # finaliza COM
|
| 150 |
+
except Exception:
|
| 151 |
+
pass
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _get_folder_by_path(root_folder, path: str):
|
| 155 |
+
parts = [p for p in path.split("\\") if p]
|
| 156 |
+
folder = root_folder.Folders.Item(parts[0])
|
| 157 |
+
for p in parts[1:]:
|
| 158 |
+
folder = folder.Folders.Item(p)
|
| 159 |
+
return folder
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _read_folder_items(folder, dias: int, filtro_remetente: str = "") -> pd.DataFrame:
|
| 163 |
+
"""Lê e-mails de uma pasta específica e retorna DataFrame."""
|
| 164 |
+
items = folder.Items
|
| 165 |
+
items.Sort("[ReceivedTime]", True) # decrescente
|
| 166 |
+
dt_from = (datetime.now() - timedelta(days=dias)).strftime("%m/%d/%Y %H:%M %p")
|
| 167 |
+
try:
|
| 168 |
+
items = items.Restrict(f"[ReceivedTime] >= '{dt_from}'")
|
| 169 |
+
except Exception:
|
| 170 |
+
# Alguns ambientes podem falhar no Restrict; segue sem filtro temporal
|
| 171 |
+
pass
|
| 172 |
+
|
| 173 |
+
rows = []
|
| 174 |
+
for mail in items:
|
| 175 |
+
try:
|
| 176 |
+
if getattr(mail, "Class", None) != 43: # 43 = MailItem
|
| 177 |
+
continue
|
| 178 |
+
try:
|
| 179 |
+
sender = mail.SenderEmailAddress or mail.Sender.Name
|
| 180 |
+
except Exception:
|
| 181 |
+
sender = getattr(mail, "SenderName", None)
|
| 182 |
+
|
| 183 |
+
# Filtro opcional por remetente
|
| 184 |
+
if filtro_remetente and sender:
|
| 185 |
+
if filtro_remetente.lower() not in str(sender).lower():
|
| 186 |
+
continue
|
| 187 |
+
|
| 188 |
+
anexos = mail.Attachments.Count if hasattr(mail, "Attachments") else 0
|
| 189 |
+
tamanho_kb = round(mail.Size / 1024, 1) if hasattr(mail, "Size") else None
|
| 190 |
+
|
| 191 |
+
rows.append({
|
| 192 |
+
"Pasta": folder.Name,
|
| 193 |
+
"Assunto": mail.Subject,
|
| 194 |
+
"Remetente": sender,
|
| 195 |
+
"RecebidoEm": mail.ReceivedTime.strftime("%Y-%m-%d %H:%M"),
|
| 196 |
+
"Anexos": anexos,
|
| 197 |
+
"TamanhoKB": tamanho_kb,
|
| 198 |
+
"Importancia": str(getattr(mail, "Importance", "")), # 0 baixa, 1 normal, 2 alta
|
| 199 |
+
"Categoria": getattr(mail, "Categories", "") or "",
|
| 200 |
+
"Lido": bool(getattr(mail, "UnRead", False) == False),
|
| 201 |
+
})
|
| 202 |
+
except Exception as e:
|
| 203 |
+
rows.append({
|
| 204 |
+
"Pasta": folder.Name, "Assunto": f"[ERRO] {e}", "Remetente": "",
|
| 205 |
+
"RecebidoEm": "", "Anexos": "", "TamanhoKB": "", "Importancia": "", "Categoria": "", "Lido": ""
|
| 206 |
+
})
|
| 207 |
+
return pd.DataFrame(rows)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def gerar_relatorio_outlook_desktop_multi(pastas: list[str], dias: int, filtro_remetente: str = "") -> pd.DataFrame:
|
| 211 |
+
"""
|
| 212 |
+
✅ Envolve toda operação COM: inicializa, lê e finaliza.
|
| 213 |
+
Evita o erro 'CoInitialize não foi chamado.'
|
| 214 |
+
"""
|
| 215 |
+
try:
|
| 216 |
+
import win32com.client
|
| 217 |
+
pythoncom.CoInitialize() # inicializa COM
|
| 218 |
+
outlook = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI")
|
| 219 |
+
root = outlook.Folders.Item(1) # ajuste o índice se tiver múltiplas caixas
|
| 220 |
+
except Exception as e:
|
| 221 |
+
st.error(f"Falha ao conectar ao Outlook/pywin32: {e}")
|
| 222 |
+
return pd.DataFrame()
|
| 223 |
+
|
| 224 |
+
frames = []
|
| 225 |
+
try:
|
| 226 |
+
for path in pastas:
|
| 227 |
+
try:
|
| 228 |
+
folder = _get_folder_by_path(root, path)
|
| 229 |
+
df = _read_folder_items(folder, dias, filtro_remetente=filtro_remetente)
|
| 230 |
+
df["PastaPath"] = path
|
| 231 |
+
frames.append(df)
|
| 232 |
+
except Exception as e:
|
| 233 |
+
st.warning(f"Não foi possível ler a pasta '{path}': {e}")
|
| 234 |
+
return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
| 235 |
+
finally:
|
| 236 |
+
try:
|
| 237 |
+
pythoncom.CoUninitialize() # finaliza COM
|
| 238 |
+
except Exception:
|
| 239 |
+
pass
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# ==============================
|
| 243 |
+
# UI — Streamlit (seleção de múltiplas pastas)
|
| 244 |
+
# ==============================
|
| 245 |
+
st.title("📧 Relatório de E-mails • Outlook Desktop (Windows)")
|
| 246 |
+
st.caption("Escolha **uma ou várias pastas** da sua Caixa de Entrada, defina o período, filtre por remetente (opcional) e gere o relatório.")
|
| 247 |
+
|
| 248 |
+
st.sidebar.header("Configurações")
|
| 249 |
+
dias = st.sidebar.slider("Período (últimos N dias)", min_value=1, max_value=365, value=30)
|
| 250 |
+
filtro_remetente = st.sidebar.text_input(
|
| 251 |
+
"Filtrar por remetente (opcional)",
|
| 252 |
+
value="",
|
| 253 |
+
placeholder='Ex.: "@fornecedor.com" ou "Fulano"'
|
| 254 |
+
)
|
| 255 |
+
apenas_inbox = st.sidebar.checkbox("Mostrar somente pastas sob Inbox", value=True)
|
| 256 |
+
|
| 257 |
+
# Tentar listar todas as pastas (COM-safe)
|
| 258 |
+
todas_pastas = safe_list_all_folders()
|
| 259 |
+
|
| 260 |
+
# Filtrar apenas pastas sob Inbox (Caixa de Entrada), se marcado
|
| 261 |
+
if todas_pastas:
|
| 262 |
+
if apenas_inbox:
|
| 263 |
+
opcoes_base = [p for p in todas_pastas if p.lower().startswith("inbox")]
|
| 264 |
+
else:
|
| 265 |
+
opcoes_base = todas_pastas
|
| 266 |
+
else:
|
| 267 |
+
opcoes_base = []
|
| 268 |
+
|
| 269 |
+
# Busca por nome
|
| 270 |
+
filtro_pasta = st.sidebar.text_input("Pesquisar pasta por nome:", value="")
|
| 271 |
+
if filtro_pasta and opcoes_base:
|
| 272 |
+
opcoes = [p for p in opcoes_base if filtro_pasta.lower() in p.lower()]
|
| 273 |
+
else:
|
| 274 |
+
opcoes = opcoes_base or []
|
| 275 |
+
|
| 276 |
+
# Multiselect de pastas
|
| 277 |
+
pastas_escolhidas = st.sidebar.multiselect(
|
| 278 |
+
"Selecione uma ou mais pastas:",
|
| 279 |
+
options=opcoes if opcoes else ["Inbox"],
|
| 280 |
+
default=(opcoes[:1] if opcoes else ["Inbox"]),
|
| 281 |
+
help="Use '\\' para subpastas. Ex.: Inbox\\Financeiro\\Notas"
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
# Campo manual adicional (para quem quer escrever um caminho específico não listado)
|
| 285 |
+
pasta_manual_extra = st.sidebar.text_input(
|
| 286 |
+
"Adicionar caminho manual (opcional)",
|
| 287 |
+
value="",
|
| 288 |
+
placeholder="Inbox\\Financeiro\\Notas"
|
| 289 |
+
)
|
| 290 |
+
if pasta_manual_extra.strip():
|
| 291 |
+
pastas_escolhidas = list(set(pastas_escolhidas + [pasta_manual_extra.strip()]))
|
| 292 |
+
|
| 293 |
+
# Botão gerar
|
| 294 |
+
if st.sidebar.button("🔍 Gerar relatório"):
|
| 295 |
+
if not pastas_escolhidas:
|
| 296 |
+
st.error("Selecione ao menos uma pasta.")
|
| 297 |
+
else:
|
| 298 |
+
with st.spinner("Lendo e-mails do Outlook..."):
|
| 299 |
+
df = gerar_relatorio_outlook_desktop_multi(
|
| 300 |
+
pastas_escolhidas,
|
| 301 |
+
dias,
|
| 302 |
+
filtro_remetente=filtro_remetente
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
st.success(f"Relatório gerado ({len(df)} registros) a partir de {len(pastas_escolhidas)} pasta(s).")
|
| 306 |
+
|
| 307 |
+
st.subheader("📄 Resultado")
|
| 308 |
+
st.dataframe(df, use_container_width=True)
|
| 309 |
+
render_indicators(df, dt_col_name="RecebidoEm")
|
| 310 |
+
|
| 311 |
+
base_name = f"relatorio_outlook_desktop_{date.today()}"
|
| 312 |
+
build_downloads(df, base_name=base_name)
|
| 313 |
+
|
| 314 |
+
st.markdown("---")
|
| 315 |
+
st.caption("Dica: se você tem várias caixas postais, troque o índice em `outlook.Folders.Item(1)` para a caixa correta.")
|
audit_streamlit_project.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
"""
|
| 4 |
+
Auditor de projeto Streamlit — chaves duplicadas, estrutura e relacionamentos.
|
| 5 |
+
|
| 6 |
+
Verifica:
|
| 7 |
+
1) Chaves duplicadas em st.form/st.button/st.download_button.
|
| 8 |
+
2) Widgets sem 'key' (risco em loops).
|
| 9 |
+
3) Imports faltantes no app.py para módulos usados no roteamento.
|
| 10 |
+
4) Cobertura MODULES ↔ Roteamento (entries sem rota e rotas sem entry).
|
| 11 |
+
5) Arquivos de módulos inexistentes e módulos sem main().
|
| 12 |
+
6) Imports não usados.
|
| 13 |
+
7) Ciclos de importação entre arquivos .py (somente locais).
|
| 14 |
+
8) Emite relatório em console e JSON.
|
| 15 |
+
|
| 16 |
+
Uso:
|
| 17 |
+
python audit_streamlit_project.py
|
| 18 |
+
python audit_streamlit_project.py --root . --app app.py --modules modules_map.py --exclude venv .venv .git
|
| 19 |
+
|
| 20 |
+
Saída JSON:
|
| 21 |
+
.audit_report.json (na raiz especificada)
|
| 22 |
+
"""
|
| 23 |
+
import os
|
| 24 |
+
import re
|
| 25 |
+
import ast
|
| 26 |
+
import json
|
| 27 |
+
import argparse
|
| 28 |
+
from collections import defaultdict
|
| 29 |
+
|
| 30 |
+
# -----------------------
|
| 31 |
+
# Util — File discovery
|
| 32 |
+
# -----------------------
|
| 33 |
+
def find_python_files(root, exclude_dirs=None):
|
| 34 |
+
exclude_dirs = set(exclude_dirs or [])
|
| 35 |
+
for dirpath, dirnames, filenames in os.walk(root):
|
| 36 |
+
# filtra diretorios ignorados
|
| 37 |
+
dirnames[:] = [
|
| 38 |
+
d for d in dirnames
|
| 39 |
+
if os.path.join(dirpath, d) not in {os.path.join(root, ex) for ex in exclude_dirs}
|
| 40 |
+
and d not in exclude_dirs
|
| 41 |
+
]
|
| 42 |
+
for fn in filenames:
|
| 43 |
+
if fn.endswith(".py"):
|
| 44 |
+
yield os.path.join(dirpath, fn)
|
| 45 |
+
|
| 46 |
+
def read_text(path):
|
| 47 |
+
try:
|
| 48 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 49 |
+
return f.read()
|
| 50 |
+
except Exception:
|
| 51 |
+
try:
|
| 52 |
+
with open(path, "r", encoding="latin-1") as f:
|
| 53 |
+
return f.read()
|
| 54 |
+
except Exception:
|
| 55 |
+
return ""
|
| 56 |
+
|
| 57 |
+
def parse_ast(path):
|
| 58 |
+
src = read_text(path)
|
| 59 |
+
if not src:
|
| 60 |
+
return None, ""
|
| 61 |
+
try:
|
| 62 |
+
tree = ast.parse(src, filename=path)
|
| 63 |
+
return tree, src
|
| 64 |
+
except Exception:
|
| 65 |
+
return None, src
|
| 66 |
+
|
| 67 |
+
# -----------------------
|
| 68 |
+
# Scan — Streamlit keys
|
| 69 |
+
# -----------------------
|
| 70 |
+
KEY_PATTERNS = {
|
| 71 |
+
"form_literal": re.compile(r'st\.form\(\s*\'"[\'"]'),
|
| 72 |
+
"button_key": re.compile(r'st\.button\([^)]*key\s*=\s*\'"[\'"]'),
|
| 73 |
+
"download_key": re.compile(r'st\.download_button\([^)]*key\s*=\s*\'"[\'"]'),
|
| 74 |
+
}
|
| 75 |
+
# widgets sem key (para alertar)
|
| 76 |
+
MISSING_KEY_PATTERNS = {
|
| 77 |
+
"button_no_key": re.compile(r'st\.button\((?![^)]*key\s*=)'),
|
| 78 |
+
"download_no_key": re.compile(r'st\.download_button\((?![^)]*key\s*=)'),
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
def scan_duplicate_and_missing_keys(file_path):
|
| 82 |
+
dups = defaultdict(list)
|
| 83 |
+
missing = defaultdict(list)
|
| 84 |
+
try:
|
| 85 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 86 |
+
for i, line in enumerate(f, 1):
|
| 87 |
+
# dup keys
|
| 88 |
+
for _, pat in KEY_PATTERNS.items():
|
| 89 |
+
for m in pat.finditer(line):
|
| 90 |
+
dups[m.group(1)].append(i)
|
| 91 |
+
# missing key warnings
|
| 92 |
+
for name, pat in MISSING_KEY_PATTERNS.items():
|
| 93 |
+
if pat.search(line):
|
| 94 |
+
missing[name].append(i)
|
| 95 |
+
except Exception:
|
| 96 |
+
pass
|
| 97 |
+
dup_filtered = {k: v for k, v in dups.items() if len(v) > 1}
|
| 98 |
+
return dup_filtered, missing
|
| 99 |
+
|
| 100 |
+
# -----------------------
|
| 101 |
+
# AST helpers — imports
|
| 102 |
+
# -----------------------
|
| 103 |
+
def extract_imports_defs_calls(tree):
|
| 104 |
+
"""
|
| 105 |
+
Retorna:
|
| 106 |
+
imports: { alias_ou_nome -> modulo_base }
|
| 107 |
+
used_names: set de nomes referenciados
|
| 108 |
+
defs: set de nomes de funções definidas
|
| 109 |
+
calls_main: set de nomes/lvalues em chamadas *.main()
|
| 110 |
+
"""
|
| 111 |
+
imports = {} # alias -> base_module
|
| 112 |
+
used_names = set()
|
| 113 |
+
defs = set()
|
| 114 |
+
calls_main = set()
|
| 115 |
+
|
| 116 |
+
class V(ast.NodeVisitor):
|
| 117 |
+
def visit_Import(self, node):
|
| 118 |
+
for alias in node.names:
|
| 119 |
+
base = alias.name.split(".")[0]
|
| 120 |
+
asname = alias.asname or alias.name
|
| 121 |
+
asname = asname.split(".")[0]
|
| 122 |
+
imports[asname] = base
|
| 123 |
+
|
| 124 |
+
def visit_ImportFrom(self, node):
|
| 125 |
+
if node.module:
|
| 126 |
+
base = node.module.split(".")[0]
|
| 127 |
+
for alias in node.names:
|
| 128 |
+
asname = alias.asname or alias.name
|
| 129 |
+
imports[asname] = base
|
| 130 |
+
|
| 131 |
+
def visit_FunctionDef(self, node):
|
| 132 |
+
defs.add(node.name)
|
| 133 |
+
self.generic_visit(node)
|
| 134 |
+
|
| 135 |
+
def visit_Name(self, node):
|
| 136 |
+
used_names.add(node.id)
|
| 137 |
+
|
| 138 |
+
def visit_Attribute(self, node):
|
| 139 |
+
# captura padrão X.main(...)
|
| 140 |
+
if isinstance(node.ctx, ast.Load) and getattr(node, "attr", None) == "main":
|
| 141 |
+
if isinstance(node.value, ast.Name):
|
| 142 |
+
calls_main.add(node.value.id)
|
| 143 |
+
else:
|
| 144 |
+
# pkg.sub.main -> tenta achar o nome raiz
|
| 145 |
+
root = node.value
|
| 146 |
+
while isinstance(root, ast.Attribute):
|
| 147 |
+
root = root.value
|
| 148 |
+
if isinstance(root, ast.Name):
|
| 149 |
+
calls_main.add(root.id)
|
| 150 |
+
self.generic_visit(node)
|
| 151 |
+
|
| 152 |
+
if tree:
|
| 153 |
+
V().visit(tree)
|
| 154 |
+
return imports, used_names, defs, calls_main
|
| 155 |
+
|
| 156 |
+
# -----------------------
|
| 157 |
+
# modules_map.py — parse
|
| 158 |
+
# -----------------------
|
| 159 |
+
def load_modules_map(modules_map_path):
|
| 160 |
+
"""
|
| 161 |
+
Extrai:
|
| 162 |
+
- route_keys: chaves top-level do dict MODULES (ex.: "consulta", "operacao"...)
|
| 163 |
+
- internal_keys: valores do campo "key" dentro de cada entrada
|
| 164 |
+
"""
|
| 165 |
+
route_keys = set()
|
| 166 |
+
internal_keys = set()
|
| 167 |
+
src = read_text(modules_map_path)
|
| 168 |
+
if not src:
|
| 169 |
+
return route_keys, internal_keys
|
| 170 |
+
# chaves top-level (aproximação): linhas com " \"nome\": {"
|
| 171 |
+
for m in re.finditer(r'^[ \t]*"([^"]+)"\s*:\s*\{', src, re.MULTILINE):
|
| 172 |
+
route_keys.add(m.group(1))
|
| 173 |
+
# field "key": "valor"
|
| 174 |
+
for m in re.finditer(r'"key"\s*:\s*"([^"]+)"', src):
|
| 175 |
+
internal_keys.add(m.group(1))
|
| 176 |
+
return route_keys, internal_keys
|
| 177 |
+
|
| 178 |
+
# -----------------------
|
| 179 |
+
# Roteamento em app.py
|
| 180 |
+
# -----------------------
|
| 181 |
+
def extract_routing(app_src):
|
| 182 |
+
"""
|
| 183 |
+
Busca padrões:
|
| 184 |
+
if/elif pagina_id == "consulta":
|
| 185 |
+
consulta.main()
|
| 186 |
+
Retorna lista de tuplas: (route_key, called_module_name)
|
| 187 |
+
"""
|
| 188 |
+
routes = []
|
| 189 |
+
|
| 190 |
+
# bloco "if" inicial
|
| 191 |
+
m_if = re.search(
|
| 192 |
+
r'if\s+pagina_id\s*==\s*\'"[\'"]\s*:\s*(.*?)\n\s*(?:elif|#|$)',
|
| 193 |
+
app_src, re.DOTALL
|
| 194 |
+
)
|
| 195 |
+
if m_if:
|
| 196 |
+
route = m_if.group(1)
|
| 197 |
+
block = m_if.group(2)
|
| 198 |
+
called = None
|
| 199 |
+
cm = re.search(r'([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*main\s*\(', block)
|
| 200 |
+
if cm:
|
| 201 |
+
called = cm.group(1)
|
| 202 |
+
routes.append((route, called))
|
| 203 |
+
|
| 204 |
+
# blocos "elif"
|
| 205 |
+
for m in re.finditer(
|
| 206 |
+
r'elif\s+pagina_id\s*==\s*\'"[\'"]\s*:\s*(.*?)\n\s*(?:elif|#|$)',
|
| 207 |
+
app_src, re.DOTALL
|
| 208 |
+
):
|
| 209 |
+
route = m.group(1)
|
| 210 |
+
block = m.group(2)
|
| 211 |
+
called = None
|
| 212 |
+
cm = re.search(r'([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*main\s*\(', block)
|
| 213 |
+
if cm:
|
| 214 |
+
called = cm.group(1)
|
| 215 |
+
routes.append((route, called))
|
| 216 |
+
|
| 217 |
+
return routes
|
| 218 |
+
|
| 219 |
+
# -----------------------
|
| 220 |
+
# Import graph & cycles
|
| 221 |
+
# -----------------------
|
| 222 |
+
def build_local_import_graph(py_files):
|
| 223 |
+
"""
|
| 224 |
+
Monta grafo de importações locais: base_name -> { base_names importados }
|
| 225 |
+
"""
|
| 226 |
+
# mapeia base_name -> arquivo
|
| 227 |
+
base_to_file = {}
|
| 228 |
+
for f in py_files:
|
| 229 |
+
base = os.path.splitext(os.path.basename(f))[0]
|
| 230 |
+
base_to_file[base] = f
|
| 231 |
+
|
| 232 |
+
graph = defaultdict(set)
|
| 233 |
+
for f in py_files:
|
| 234 |
+
base = os.path.splitext(os.path.basename(f))[0]
|
| 235 |
+
tree, _ = parse_ast(f)
|
| 236 |
+
imports, _, _, _ = extract_imports_defs_calls(tree)
|
| 237 |
+
for alias, base_mod in imports.items():
|
| 238 |
+
# se alias ou base_mod mapeia para arquivo local, considera aresta
|
| 239 |
+
target = None
|
| 240 |
+
if alias in base_to_file:
|
| 241 |
+
target = alias
|
| 242 |
+
elif base_mod in base_to_file:
|
| 243 |
+
target = base_mod
|
| 244 |
+
if target and target != base:
|
| 245 |
+
graph[base].add(target)
|
| 246 |
+
return graph
|
| 247 |
+
|
| 248 |
+
def find_cycles(graph):
|
| 249 |
+
"""
|
| 250 |
+
Detecta ciclos no grafo (lista de ciclos) — sem mutar o dicionário durante a iteração.
|
| 251 |
+
"""
|
| 252 |
+
# Conjunto estático de nós (origens + destinos)
|
| 253 |
+
nodes = set(graph.keys())
|
| 254 |
+
for vs in graph.values():
|
| 255 |
+
nodes.update(vs)
|
| 256 |
+
|
| 257 |
+
visited = set()
|
| 258 |
+
stack = set()
|
| 259 |
+
cycles = []
|
| 260 |
+
path = []
|
| 261 |
+
|
| 262 |
+
def dfs(u):
|
| 263 |
+
visited.add(u)
|
| 264 |
+
stack.add(u)
|
| 265 |
+
path.append(u)
|
| 266 |
+
for v in graph.get(u, set()): # <- sem criar chaves novas
|
| 267 |
+
if v not in visited:
|
| 268 |
+
dfs(v)
|
| 269 |
+
elif v in stack:
|
| 270 |
+
# ciclo encontrado — extrai subpath (v até fim) + fecha em v
|
| 271 |
+
if v in path:
|
| 272 |
+
idx = len(path) - 1
|
| 273 |
+
while idx >= 0 and path[idx] != v:
|
| 274 |
+
idx -= 1
|
| 275 |
+
if idx >= 0:
|
| 276 |
+
cycle = path[idx:] + [v]
|
| 277 |
+
cycles.append(cycle)
|
| 278 |
+
stack.remove(u)
|
| 279 |
+
path.pop()
|
| 280 |
+
|
| 281 |
+
for node in list(nodes): # <- lista estática
|
| 282 |
+
if node not in visited:
|
| 283 |
+
dfs(node)
|
| 284 |
+
|
| 285 |
+
# Deduplicar ciclos por forma canônica (rotação mínima)
|
| 286 |
+
def canonical(cyc):
|
| 287 |
+
core = cyc[:-1] # remove a repetição final
|
| 288 |
+
if not core:
|
| 289 |
+
return tuple()
|
| 290 |
+
rots = [tuple(core[i:] + core[:i]) for i in range(len(core))]
|
| 291 |
+
return min(rots)
|
| 292 |
+
|
| 293 |
+
seen = set()
|
| 294 |
+
unique = []
|
| 295 |
+
for cyc in cycles:
|
| 296 |
+
can = canonical(cyc)
|
| 297 |
+
if can and can not in seen:
|
| 298 |
+
seen.add(can)
|
| 299 |
+
unique.append(cyc)
|
| 300 |
+
return unique
|
| 301 |
+
|
| 302 |
+
# -----------------------
|
| 303 |
+
# Unused imports (aprox)
|
| 304 |
+
# -----------------------
|
| 305 |
+
def find_unused_imports(tree, imports, used_names):
|
| 306 |
+
"""
|
| 307 |
+
Aproximação: se o alias importado não aparece em used_names -> não usado.
|
| 308 |
+
Não detecta usos por getattr/reflection; serve como guia inicial.
|
| 309 |
+
"""
|
| 310 |
+
unused = []
|
| 311 |
+
for alias in imports.keys():
|
| 312 |
+
if alias not in used_names:
|
| 313 |
+
unused.append(alias)
|
| 314 |
+
return unused
|
| 315 |
+
|
| 316 |
+
# -----------------------
|
| 317 |
+
# Auditor principal
|
| 318 |
+
# -----------------------
|
| 319 |
+
def audit(root, app_path, modules_map_path, exclude_dirs=None, output_json=".audit_report.json"):
|
| 320 |
+
report = {
|
| 321 |
+
"duplicate_keys": {}, # file -> {key: [lines]}
|
| 322 |
+
"widgets_without_key": {}, # file -> {pattern: [lines]}
|
| 323 |
+
"missing_imports_in_app": [], # [(route_key, called_module, reason)]
|
| 324 |
+
"routing_vs_modules": {
|
| 325 |
+
"routes_without_modules_entry": [], # [route_key]
|
| 326 |
+
"modules_entry_without_route": [], # [modules_map_key]
|
| 327 |
+
},
|
| 328 |
+
"module_files_missing": [], # [module_name]
|
| 329 |
+
"modules_without_main": [], # [module_name]
|
| 330 |
+
"unused_imports": {}, # file -> [alias]
|
| 331 |
+
"import_cycles": [], # [[mod_a, mod_b, ..., mod_a]]
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
# 1) varrer arquivos
|
| 335 |
+
py_files = list(find_python_files(root, exclude_dirs=exclude_dirs))
|
| 336 |
+
# mapa base_name -> file
|
| 337 |
+
base_to_file = {os.path.splitext(os.path.basename(f))[0]: f for f in py_files}
|
| 338 |
+
|
| 339 |
+
# 2) chaves duplicadas e widgets sem key
|
| 340 |
+
for f in py_files:
|
| 341 |
+
dups, missing = scan_duplicate_and_missing_keys(f)
|
| 342 |
+
if dups:
|
| 343 |
+
report["duplicate_keys"][f] = dups
|
| 344 |
+
if any(missing.values()):
|
| 345 |
+
report["widgets_without_key"][f] = {k: v for k, v in missing.items() if v}
|
| 346 |
+
|
| 347 |
+
# 3) carrega app.py e modules_map.py
|
| 348 |
+
app_full = os.path.join(root, app_path)
|
| 349 |
+
modules_map_full = os.path.join(root, modules_map_path)
|
| 350 |
+
app_tree, app_src = parse_ast(app_full)
|
| 351 |
+
routes = extract_routing(app_src) if app_src else []
|
| 352 |
+
|
| 353 |
+
# imports e defs do app
|
| 354 |
+
app_imports, app_used, app_defs, app_calls_main = extract_imports_defs_calls(app_tree)
|
| 355 |
+
|
| 356 |
+
# 4) MODULES
|
| 357 |
+
route_keys_in_map, internal_keys_in_map = load_modules_map(modules_map_full)
|
| 358 |
+
|
| 359 |
+
# 5) checar import para cada rota
|
| 360 |
+
routes_set = set()
|
| 361 |
+
for route_key, called_module in routes:
|
| 362 |
+
routes_set.add(route_key)
|
| 363 |
+
if not called_module:
|
| 364 |
+
report["missing_imports_in_app"].append((route_key, None, "Bloco da rota não chama *.main()"))
|
| 365 |
+
continue
|
| 366 |
+
# foi importado?
|
| 367 |
+
imported_aliases = set(app_imports.keys()) # aliases disponíveis
|
| 368 |
+
if called_module not in imported_aliases:
|
| 369 |
+
report["missing_imports_in_app"].append((route_key, called_module, "Módulo não importado no app.py"))
|
| 370 |
+
# arquivo existe?
|
| 371 |
+
if called_module not in base_to_file:
|
| 372 |
+
# talvez seja alias de import (base module)
|
| 373 |
+
base_mod = app_imports.get(called_module)
|
| 374 |
+
if not (base_mod and base_mod in base_to_file):
|
| 375 |
+
report["module_files_missing"].append(called_module)
|
| 376 |
+
else:
|
| 377 |
+
# checar main()
|
| 378 |
+
t, _ = parse_ast(base_to_file[called_module])
|
| 379 |
+
_, _, defs, _ = extract_imports_defs_calls(t)
|
| 380 |
+
if "main" not in defs:
|
| 381 |
+
report["modules_without_main"].append(called_module)
|
| 382 |
+
|
| 383 |
+
# 6) cobertura rota vs modules_map
|
| 384 |
+
# - rotas no app que não existem no modules_map
|
| 385 |
+
for r in routes_set:
|
| 386 |
+
if r not in route_keys_in_map and r not in internal_keys_in_map:
|
| 387 |
+
report["routing_vs_modules"]["routes_without_modules_entry"].append(r)
|
| 388 |
+
# - entries no modules_map que não têm rota no app
|
| 389 |
+
for m in route_keys_in_map:
|
| 390 |
+
if m not in routes_set:
|
| 391 |
+
report["routing_vs_modules"]["modules_entry_without_route"].append(m)
|
| 392 |
+
|
| 393 |
+
# 7) unused imports por arquivo
|
| 394 |
+
for f in py_files:
|
| 395 |
+
t, _ = parse_ast(f)
|
| 396 |
+
imp, used, defs, calls_main = extract_imports_defs_calls(t)
|
| 397 |
+
unused = find_unused_imports(t, imp, used)
|
| 398 |
+
if unused:
|
| 399 |
+
report["unused_imports"][f] = unused
|
| 400 |
+
|
| 401 |
+
# 8) ciclos de import local
|
| 402 |
+
graph = build_local_import_graph(py_files)
|
| 403 |
+
cycles = find_cycles(graph)
|
| 404 |
+
report["import_cycles"] = cycles
|
| 405 |
+
|
| 406 |
+
# 9) remover duplicidades simples nas listas
|
| 407 |
+
report["missing_imports_in_app"] = list(dict.fromkeys(report["missing_imports_in_app"]))
|
| 408 |
+
report["module_files_missing"] = sorted(set(report["module_files_missing"]))
|
| 409 |
+
report["modules_without_main"] = sorted(set(report["modules_without_main"]))
|
| 410 |
+
report["routing_vs_modules"]["routes_without_modules_entry"] = sorted(
|
| 411 |
+
set(report["routing_vs_modules"]["routes_without_modules_entry"]))
|
| 412 |
+
report["routing_vs_modules"]["modules_entry_without_route"] = sorted(
|
| 413 |
+
set(report["routing_vs_modules"]["modules_entry_without_route"]))
|
| 414 |
+
|
| 415 |
+
# 10) saída
|
| 416 |
+
print("\n=== RELATÓRIO DE AUDITORIA — Streamlit Project ===")
|
| 417 |
+
# chaves duplicadas
|
| 418 |
+
print("\n[Chaves duplicadas]")
|
| 419 |
+
if not report["duplicate_keys"]:
|
| 420 |
+
print(" ✔ Nenhuma chave duplicada literal encontrada.")
|
| 421 |
+
else:
|
| 422 |
+
for file, dups in report["duplicate_keys"].items():
|
| 423 |
+
print(f" - {file}")
|
| 424 |
+
for key, lines in dups.items():
|
| 425 |
+
print(f" * key='{key}' duplicada em linhas {lines}")
|
| 426 |
+
|
| 427 |
+
# widgets sem key
|
| 428 |
+
print("\n[Widgets sem 'key' (atenção em loops)]")
|
| 429 |
+
if not report["widgets_without_key"]:
|
| 430 |
+
print(" ✔ Nenhum potencial widget sem key encontrado.")
|
| 431 |
+
else:
|
| 432 |
+
for file, miss in report["widgets_without_key"].items():
|
| 433 |
+
print(f" - {file}")
|
| 434 |
+
for kind, lines in miss.items():
|
| 435 |
+
print(f" * {kind}: linhas {lines}")
|
| 436 |
+
|
| 437 |
+
# imports faltantes e módulos
|
| 438 |
+
print("\n[Imports faltantes no app e módulos]")
|
| 439 |
+
if not report["missing_imports_in_app"]:
|
| 440 |
+
print(" ✔ Nenhum import faltante detectado no app.py (para rotas).")
|
| 441 |
+
else:
|
| 442 |
+
for route_key, called_module, reason in report["missing_imports_in_app"]:
|
| 443 |
+
print(f" - rota='{route_key}' -> módulo='{called_module}' • {reason}")
|
| 444 |
+
if not report["module_files_missing"]:
|
| 445 |
+
print(" ✔ Nenhum arquivo de módulo ausente detectado.")
|
| 446 |
+
else:
|
| 447 |
+
print(" Arquivos de módulo não encontrados:", report["module_files_missing"])
|
| 448 |
+
if not report["modules_without_main"]:
|
| 449 |
+
print(" ✔ Todos os módulos localizados possuem main().")
|
| 450 |
+
else:
|
| 451 |
+
print(" Módulos sem main():", report["modules_without_main"])
|
| 452 |
+
|
| 453 |
+
# cobertura MODULES ↔ Roteamento
|
| 454 |
+
print("\n[Consistência: MODULES x Roteamento]")
|
| 455 |
+
rwm = report["routing_vs_modules"]
|
| 456 |
+
if not rwm["routes_without_modules_entry"]:
|
| 457 |
+
print(" ✔ Todas as rotas possuem entrada em modules_map.py (ou 'key' interna).")
|
| 458 |
+
else:
|
| 459 |
+
print(" Rotas sem entrada no modules_map.py:", rwm["routes_without_modules_entry"])
|
| 460 |
+
if not rwm["modules_entry_without_route"]:
|
| 461 |
+
print(" ✔ Todas as entradas do modules_map.py possuem rota no app.py.")
|
| 462 |
+
else:
|
| 463 |
+
print(" Entradas do modules_map.py sem rota no app.py:", rwm["modules_entry_without_route"])
|
| 464 |
+
|
| 465 |
+
# imports não usados
|
| 466 |
+
print("\n[Imports não usados (aprox.)]")
|
| 467 |
+
if not report["unused_imports"]:
|
| 468 |
+
print(" ✔ Nenhum import potencialmente não usado encontrado.")
|
| 469 |
+
else:
|
| 470 |
+
for file, unused in report["unused_imports"].items():
|
| 471 |
+
print(f" - {file}: {unused}")
|
| 472 |
+
|
| 473 |
+
# ciclos
|
| 474 |
+
print("\n[Ciclos de importação]")
|
| 475 |
+
if not report["import_cycles"]:
|
| 476 |
+
print(" ✔ Nenhum ciclo de importação detectado.")
|
| 477 |
+
else:
|
| 478 |
+
for cyc in report["import_cycles"]:
|
| 479 |
+
print(" - ciclo:", " -> ".join(cyc))
|
| 480 |
+
|
| 481 |
+
# salvar JSON
|
| 482 |
+
out_path = os.path.join(root, output_json)
|
| 483 |
+
with open(out_path, "w", encoding="utf-8") as f:
|
| 484 |
+
json.dump(report, f, ensure_ascii=False, indent=2)
|
| 485 |
+
print(f"\n📄 Relatório JSON salvo em: {out_path}")
|
| 486 |
+
|
| 487 |
+
return report
|
| 488 |
+
|
| 489 |
+
# -----------------------
|
| 490 |
+
# CLI
|
| 491 |
+
# -----------------------
|
| 492 |
+
def cli():
|
| 493 |
+
p = argparse.ArgumentParser(description="Auditor de projeto Streamlit")
|
| 494 |
+
p.add_argument("--root", default=".", help="Raiz do projeto (default: .)")
|
| 495 |
+
p.add_argument("--app", default="app.py", help="Caminho do app.py (relativo à raiz)")
|
| 496 |
+
p.add_argument("--modules", default="modules_map.py", help="Caminho do modules_map.py (relativo à raiz)")
|
| 497 |
+
p.add_argument("--exclude", nargs="*", default=[".git", ".venv", "venv", "__pycache__", ".streamlit"],
|
| 498 |
+
help="Pastas a excluir da varredura")
|
| 499 |
+
p.add_argument("--json", default=".audit_report.json", help="Nome do arquivo JSON de saída")
|
| 500 |
+
args = p.parse_args()
|
| 501 |
+
|
| 502 |
+
audit(
|
| 503 |
+
root=args.root,
|
| 504 |
+
app_path=args.app,
|
| 505 |
+
modules_map_path=args.modules,
|
| 506 |
+
exclude_dirs=args.exclude,
|
| 507 |
+
output_json=args.json
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
if __name__ == "__main__":
|
| 511 |
+
cli()
|
| 512 |
+
|
auditoria.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import streamlit as st
|
| 3 |
+
from banco import SessionLocal
|
| 4 |
+
from models import LogAcesso, Usuario
|
| 5 |
+
import pandas as pd
|
| 6 |
+
from io import BytesIO
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
# Debug opcional – confirma o banco em uso
|
| 10 |
+
print("📂 BANCO LIDO NA AUDITORIA:", os.path.abspath("load.db"))
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def main():
|
| 14 |
+
st.title("🧾 Auditoria do Sistema Load")
|
| 15 |
+
|
| 16 |
+
db = SessionLocal()
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
# =========================
|
| 20 |
+
# FILTRO POR PERFIL
|
| 21 |
+
# =========================
|
| 22 |
+
perfis = (
|
| 23 |
+
db.query(Usuario.perfil)
|
| 24 |
+
.distinct()
|
| 25 |
+
.order_by(Usuario.perfil)
|
| 26 |
+
.all()
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
lista_perfis = ["Todos"] + [p[0] for p in perfis]
|
| 30 |
+
|
| 31 |
+
perfil_selecionado = st.selectbox(
|
| 32 |
+
"Filtrar por perfil:",
|
| 33 |
+
lista_perfis
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# =========================
|
| 37 |
+
# CONSULTA COM JOIN
|
| 38 |
+
# =========================
|
| 39 |
+
# ✅ Incluímos o e-mail do usuário na seleção
|
| 40 |
+
query = (
|
| 41 |
+
db.query(
|
| 42 |
+
LogAcesso.usuario,
|
| 43 |
+
Usuario.perfil,
|
| 44 |
+
Usuario.email, # <-- novo
|
| 45 |
+
LogAcesso.acao,
|
| 46 |
+
LogAcesso.tabela,
|
| 47 |
+
LogAcesso.registro_id,
|
| 48 |
+
LogAcesso.data_hora
|
| 49 |
+
)
|
| 50 |
+
.join(Usuario, Usuario.usuario == LogAcesso.usuario)
|
| 51 |
+
.order_by(LogAcesso.data_hora.desc())
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
if perfil_selecionado != "Todos":
|
| 55 |
+
query = query.filter(Usuario.perfil == perfil_selecionado)
|
| 56 |
+
|
| 57 |
+
logs = query.all()
|
| 58 |
+
|
| 59 |
+
if not logs:
|
| 60 |
+
st.info("Nenhum registro encontrado.")
|
| 61 |
+
return
|
| 62 |
+
|
| 63 |
+
# =========================
|
| 64 |
+
# DATAFRAME FORMATADO
|
| 65 |
+
# =========================
|
| 66 |
+
dados = []
|
| 67 |
+
for l in logs:
|
| 68 |
+
# l = (usuario, perfil, email, acao, tabela, registro_id, data_hora)
|
| 69 |
+
dados.append({
|
| 70 |
+
"Usuário": l[0],
|
| 71 |
+
"Perfil": l[1],
|
| 72 |
+
"E-mail": l[2] or "—", # ✅ e-mail pode ser nulo
|
| 73 |
+
"Ação": l[3],
|
| 74 |
+
"Tabela": l[4],
|
| 75 |
+
"Registro": l[5],
|
| 76 |
+
"Data": l[6].strftime("%d/%m/%Y"),
|
| 77 |
+
"Hora": l[6].strftime("%H:%M:%S"),
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
df = pd.DataFrame(dados)
|
| 81 |
+
|
| 82 |
+
st.dataframe(df, use_container_width=True)
|
| 83 |
+
|
| 84 |
+
# =========================
|
| 85 |
+
# EXPORTAÇÃO PARA EXCEL
|
| 86 |
+
# =========================
|
| 87 |
+
buffer = BytesIO()
|
| 88 |
+
with pd.ExcelWriter(buffer, engine="openpyxl") as writer:
|
| 89 |
+
df.to_excel(writer, index=False, sheet_name="Auditoria")
|
| 90 |
+
|
| 91 |
+
st.download_button(
|
| 92 |
+
label="📥 Exportar Auditoria para Excel",
|
| 93 |
+
data=buffer.getvalue(),
|
| 94 |
+
file_name="auditoria_sistema.xlsx",
|
| 95 |
+
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
finally:
|
| 99 |
+
db.close()
|
| 100 |
+
|
auditoria_cleanup.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import streamlit as st
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from banco import SessionLocal
|
| 5 |
+
from models import LogAcesso # ✅ usar a tabela correta
|
| 6 |
+
from utils_auditoria import registrar_log
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def main():
|
| 10 |
+
st.title("🧹 Limpeza de Logs de Auditoria")
|
| 11 |
+
|
| 12 |
+
st.markdown(
|
| 13 |
+
"""
|
| 14 |
+
Este módulo permite excluir registros antigos de auditoria
|
| 15 |
+
para suavizar o banco de dados.
|
| 16 |
+
"""
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
opcoes = {
|
| 20 |
+
"Último mês": 30,
|
| 21 |
+
"Últimos 2 meses": 60,
|
| 22 |
+
"Últimos 6 meses": 180,
|
| 23 |
+
"Últimos 12 meses": 365,
|
| 24 |
+
"Personalizado": None,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
escolha = st.selectbox("📅 Escolha o período:", list(opcoes.keys()))
|
| 28 |
+
|
| 29 |
+
data_inicio = None
|
| 30 |
+
data_fim = datetime.now()
|
| 31 |
+
|
| 32 |
+
if escolha != "Personalizado":
|
| 33 |
+
dias = opcoes[escolha]
|
| 34 |
+
data_inicio = datetime.now() - timedelta(days=dias)
|
| 35 |
+
else:
|
| 36 |
+
col1, col2 = st.columns(2)
|
| 37 |
+
with col1:
|
| 38 |
+
data_inicio = st.date_input("Data inicial")
|
| 39 |
+
with col2:
|
| 40 |
+
data_fim = st.date_input("Data final")
|
| 41 |
+
data_inicio = datetime.combine(data_inicio, datetime.min.time())
|
| 42 |
+
data_fim = datetime.combine(data_fim, datetime.max.time())
|
| 43 |
+
|
| 44 |
+
st.info(f"🗓️ Registros de auditoria entre {data_inicio.date()} e {data_fim.date()} serão excluídos.")
|
| 45 |
+
|
| 46 |
+
st.divider()
|
| 47 |
+
|
| 48 |
+
# ✅ Prévia do total (opcional, ajuda na decisão)
|
| 49 |
+
with SessionLocal() as db:
|
| 50 |
+
total_prev = (
|
| 51 |
+
db.query(LogAcesso)
|
| 52 |
+
.filter(LogAcesso.data_hora >= data_inicio, LogAcesso.data_hora <= data_fim)
|
| 53 |
+
.count()
|
| 54 |
+
)
|
| 55 |
+
st.info(f"🔎 Prévia: {total_prev} registro(s) serão removidos no período selecionado.")
|
| 56 |
+
|
| 57 |
+
# ✅ Etapa de confirmação via caixa de seleção
|
| 58 |
+
st.warning(
|
| 59 |
+
"⚠️ **Atenção:** Todos os registros de auditoria no período selecionado serão apagados.\n\n"
|
| 60 |
+
"Confirme abaixo para prosseguir."
|
| 61 |
+
)
|
| 62 |
+
confirmacao = st.selectbox("Confirmar exclusão?", ["Não", "SIM"], index=0)
|
| 63 |
+
|
| 64 |
+
# Botão de exclusão (só prossegue se confirmação for SIM)
|
| 65 |
+
if st.button("❌ Excluir registros de auditoria"):
|
| 66 |
+
if confirmacao != "SIM":
|
| 67 |
+
st.error("Operação cancelada. Se desejar prosseguir, selecione **SIM** na confirmação.")
|
| 68 |
+
return
|
| 69 |
+
|
| 70 |
+
with SessionLocal() as db:
|
| 71 |
+
try:
|
| 72 |
+
registros = (
|
| 73 |
+
db.query(LogAcesso)
|
| 74 |
+
.filter(LogAcesso.data_hora >= data_inicio, LogAcesso.data_hora <= data_fim)
|
| 75 |
+
.all()
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
total = len(registros)
|
| 79 |
+
|
| 80 |
+
if total == 0:
|
| 81 |
+
st.warning("Nenhum registro encontrado para exclusão.")
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
for r in registros:
|
| 85 |
+
db.delete(r)
|
| 86 |
+
|
| 87 |
+
db.commit()
|
| 88 |
+
|
| 89 |
+
registrar_log(
|
| 90 |
+
usuario=st.session_state.get("usuario"),
|
| 91 |
+
acao=f"Excluiu {total} registros de auditoria entre {data_inicio.date()} e {data_fim.date()}",
|
| 92 |
+
tabela="log_acesso",
|
| 93 |
+
registro_id=None
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
st.success(f"🎉 {total} registro(s) de auditoria foram excluídos com sucesso!")
|
| 97 |
+
|
| 98 |
+
except Exception as e:
|
| 99 |
+
db.rollback()
|
| 100 |
+
st.error(f"❌ Erro ao excluir registros: {e}")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
|
auto_capture.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
"""
|
| 4 |
+
auto_capture.py — Captura screenshots de todas as telas do app Streamlit e monta um PPTX.
|
| 5 |
+
|
| 6 |
+
Recursos:
|
| 7 |
+
• Login automático (usuário/senha + escolha do banco)
|
| 8 |
+
• Bypass do Quiz (clica: “Voltar ao sistema”, “Finalizar”, “Continuar”, se visível)
|
| 9 |
+
• Seletores robustos para st.selectbox (procura pelo label visível)
|
| 10 |
+
• Captura pós-login/pós-quiz, por grupo e por módulo
|
| 11 |
+
• Artefatos de debug (HTML + PNG) quando algo falha
|
| 12 |
+
• Sanitização de nomes de arquivo (compatível com Windows)
|
| 13 |
+
• Geração de PPTX com um slide por módulo capturado
|
| 14 |
+
|
| 15 |
+
Requisitos:
|
| 16 |
+
pip install playwright python-pptx python-dotenv
|
| 17 |
+
playwright install
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import os
|
| 21 |
+
import re
|
| 22 |
+
import traceback
|
| 23 |
+
from datetime import datetime
|
| 24 |
+
from dotenv import load_dotenv
|
| 25 |
+
|
| 26 |
+
# Carrega .env
|
| 27 |
+
load_dotenv()
|
| 28 |
+
|
| 29 |
+
APP_URL = os.getenv("APP_URL", "http://localhost:8501")
|
| 30 |
+
LOGIN_USER = os.getenv("LOGIN_USER", "admin")
|
| 31 |
+
LOGIN_PASS = os.getenv("LOGIN_PASS", "admin123")
|
| 32 |
+
BANK_CHOICE = os.getenv("BANK_CHOICE", "prod") # prod | test | treinamento
|
| 33 |
+
|
| 34 |
+
SCREEN_DIR = os.getenv("SCREEN_DIR", "./screenshots")
|
| 35 |
+
OUTPUT_PPTX = os.getenv("OUTPUT_PPTX", "./demo_funcionalidades.pptx")
|
| 36 |
+
|
| 37 |
+
HEADLESS = os.getenv("AUTOCAPTURE_HEADLESS", "false").lower() == "true"
|
| 38 |
+
VIEWPORT_W = int(os.getenv("AUTOCAPTURE_VIEWPORT_W", "1440"))
|
| 39 |
+
VIEWPORT_H = int(os.getenv("AUTOCAPTURE_VIEWPORT_H", "900"))
|
| 40 |
+
|
| 41 |
+
# Importa seu mapa de módulos (aproveita rótulos e grupos)
|
| 42 |
+
try:
|
| 43 |
+
from modules_map import MODULES
|
| 44 |
+
except Exception:
|
| 45 |
+
MODULES = {}
|
| 46 |
+
print("⚠️ Não consegui importar modules_map.py. Ele deve estar no mesmo diretório do script.")
|
| 47 |
+
|
| 48 |
+
# PowerPoint
|
| 49 |
+
from pptx import Presentation
|
| 50 |
+
from pptx.util import Inches, Pt
|
| 51 |
+
from pptx.dml.color import RGBColor
|
| 52 |
+
|
| 53 |
+
# Playwright
|
| 54 |
+
from playwright.sync_api import sync_playwright
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# -----------------------------------------------------------------------------
|
| 58 |
+
# Helpers
|
| 59 |
+
# -----------------------------------------------------------------------------
|
| 60 |
+
def ensure_dir(path: str):
|
| 61 |
+
os.makedirs(path, exist_ok=True)
|
| 62 |
+
|
| 63 |
+
def sanitize(s: str) -> str:
|
| 64 |
+
"""Remove/normaliza caracteres inválidos de nomes (Windows-safe)."""
|
| 65 |
+
s = re.sub(r"[\\/:*?\"<>|]", "_", s) # remove proibidos
|
| 66 |
+
s = re.sub(r"\s+", "_", s.strip()) # espaços -> _
|
| 67 |
+
return s
|
| 68 |
+
|
| 69 |
+
def bank_label(choice: str) -> str:
|
| 70 |
+
return {
|
| 71 |
+
"prod": "Banco 1 (📗 Produção)",
|
| 72 |
+
"test": "Banco 2 (📕 Teste)",
|
| 73 |
+
"treinamento": "Banco 3 (📘 Treinamento)",
|
| 74 |
+
}.get(choice, choice)
|
| 75 |
+
|
| 76 |
+
def save_artifacts_on_fail(page, tag="fail"):
|
| 77 |
+
"""Salva HTML e screenshot quando algo dá errado."""
|
| 78 |
+
ensure_dir(SCREEN_DIR)
|
| 79 |
+
tag = sanitize(tag)
|
| 80 |
+
try:
|
| 81 |
+
html_path = os.path.join(SCREEN_DIR, f"{tag}_page.html")
|
| 82 |
+
img_path = os.path.join(SCREEN_DIR, f"{tag}_page.png")
|
| 83 |
+
with open(html_path, "w", encoding="utf-8") as f:
|
| 84 |
+
f.write(page.content())
|
| 85 |
+
page.screenshot(path=img_path, full_page=True)
|
| 86 |
+
print(f"📝 Artefatos salvos: {html_path}, {img_path}")
|
| 87 |
+
except Exception as e:
|
| 88 |
+
print(f"⚠️ Falha ao salvar artefatos de erro: {e}")
|
| 89 |
+
|
| 90 |
+
def select_by_label(page, select_label: str, option_text: str):
|
| 91 |
+
"""
|
| 92 |
+
Seleciona uma opção em um st.selectbox, procurando pelo label (texto visível).
|
| 93 |
+
• Varre todos os elementos com data-testid="stSelectbox"
|
| 94 |
+
• Encontra o que contém o label desejado (case-insensitive)
|
| 95 |
+
• Abre o combobox e clica na opção exata
|
| 96 |
+
"""
|
| 97 |
+
boxes = page.locator('[data-testid="stSelectbox"]')
|
| 98 |
+
count = boxes.count()
|
| 99 |
+
if count == 0:
|
| 100 |
+
raise RuntimeError("Nenhum stSelectbox encontrado na página.")
|
| 101 |
+
|
| 102 |
+
found = False
|
| 103 |
+
for i in range(count):
|
| 104 |
+
box = boxes.nth(i)
|
| 105 |
+
try:
|
| 106 |
+
txt = box.inner_text().strip()
|
| 107 |
+
except Exception:
|
| 108 |
+
continue
|
| 109 |
+
if select_label.lower() in txt.lower():
|
| 110 |
+
box.locator('div[role="combobox"]').first.click()
|
| 111 |
+
page.locator('div[role="listbox"]').get_by_text(option_text, exact=True).click()
|
| 112 |
+
found = True
|
| 113 |
+
break
|
| 114 |
+
|
| 115 |
+
if not found:
|
| 116 |
+
raise RuntimeError(f"Selectbox com label '{select_label}' não encontrado.")
|
| 117 |
+
|
| 118 |
+
def bypass_quiz(page):
|
| 119 |
+
"""
|
| 120 |
+
Tenta sair da tela de Quiz, caso esteja bloqueando a navegação.
|
| 121 |
+
Procura ações típicas: 'Voltar ao sistema', 'Finalizar', 'Continuar'.
|
| 122 |
+
"""
|
| 123 |
+
# 1) Voltar ao sistema
|
| 124 |
+
try:
|
| 125 |
+
if page.get_by_text("Voltar ao sistema").count() > 0:
|
| 126 |
+
page.get_by_text("Voltar ao sistema").click()
|
| 127 |
+
page.wait_for_timeout(600)
|
| 128 |
+
return
|
| 129 |
+
except Exception:
|
| 130 |
+
pass
|
| 131 |
+
|
| 132 |
+
# 2) Finalizar
|
| 133 |
+
try:
|
| 134 |
+
if page.get_by_role("button", name="Finalizar").count() > 0:
|
| 135 |
+
page.get_by_role("button", name="Finalizar").click()
|
| 136 |
+
page.wait_for_timeout(600)
|
| 137 |
+
return
|
| 138 |
+
except Exception:
|
| 139 |
+
pass
|
| 140 |
+
|
| 141 |
+
# 3) Continuar
|
| 142 |
+
try:
|
| 143 |
+
if page.get_by_role("button", name="Continuar").count() > 0:
|
| 144 |
+
page.get_by_role("button", name="Continuar").click()
|
| 145 |
+
page.wait_for_timeout(600)
|
| 146 |
+
return
|
| 147 |
+
except Exception:
|
| 148 |
+
pass
|
| 149 |
+
|
| 150 |
+
# 4) Se nada funcionar, salva artefatos para analisarmos o DOM real
|
| 151 |
+
save_artifacts_on_fail(page, "quiz_bypass")
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def do_login(page):
|
| 155 |
+
page.goto(APP_URL, timeout=60000)
|
| 156 |
+
page.wait_for_load_state("networkidle")
|
| 157 |
+
page.wait_for_timeout(800)
|
| 158 |
+
|
| 159 |
+
# Seleciona Banco (selectbox "Usar banco:")
|
| 160 |
+
try:
|
| 161 |
+
select_by_label(page, "Usar banco:", bank_label(BANK_CHOICE))
|
| 162 |
+
except Exception as e:
|
| 163 |
+
print(f"⚠️ Falha ao selecionar banco: {e}")
|
| 164 |
+
save_artifacts_on_fail(page, "select_bank")
|
| 165 |
+
# Fallback por texto simples (última tentativa)
|
| 166 |
+
try:
|
| 167 |
+
page.get_by_text("Usar banco:").click()
|
| 168 |
+
page.get_by_text(bank_label(BANK_CHOICE), exact=True).click()
|
| 169 |
+
except Exception:
|
| 170 |
+
pass
|
| 171 |
+
|
| 172 |
+
# Preenche credenciais
|
| 173 |
+
try:
|
| 174 |
+
page.get_by_label("Usuário").fill(LOGIN_USER)
|
| 175 |
+
except Exception:
|
| 176 |
+
page.locator('label:has-text("Usuário")').locator("xpath=..").locator('input').fill(LOGIN_USER)
|
| 177 |
+
|
| 178 |
+
try:
|
| 179 |
+
page.get_by_label("Senha").fill(LOGIN_PASS)
|
| 180 |
+
except Exception:
|
| 181 |
+
page.locator('label:has-text("Senha")').locator("xpath=..").locator('input').fill(LOGIN_PASS)
|
| 182 |
+
|
| 183 |
+
# Entrar
|
| 184 |
+
try:
|
| 185 |
+
page.get_by_role("button", name="Entrar").click()
|
| 186 |
+
except Exception:
|
| 187 |
+
page.get_by_text("Entrar").click()
|
| 188 |
+
|
| 189 |
+
page.wait_for_load_state("networkidle")
|
| 190 |
+
page.wait_for_timeout(1000)
|
| 191 |
+
|
| 192 |
+
# Captura pós-login
|
| 193 |
+
page.screenshot(path=os.path.join(SCREEN_DIR, "00_pos_login.png"), full_page=True)
|
| 194 |
+
|
| 195 |
+
# Bypass do Quiz (se existir)
|
| 196 |
+
bypass_quiz(page)
|
| 197 |
+
|
| 198 |
+
# Captura pós-quiz
|
| 199 |
+
page.screenshot(path=os.path.join(SCREEN_DIR, "01_pos_quiz.png"), full_page=True)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def clear_search(page):
|
| 203 |
+
"""Limpa campo 'Pesquisar módulo:' para não filtrar nada (opcional)."""
|
| 204 |
+
try:
|
| 205 |
+
page.get_by_label("Pesquisar módulo:").fill("")
|
| 206 |
+
page.wait_for_timeout(200)
|
| 207 |
+
except Exception:
|
| 208 |
+
# Fallback: tenta input na sidebar
|
| 209 |
+
try:
|
| 210 |
+
sb = page.locator('[data-testid="stSidebar"]').first
|
| 211 |
+
sb.locator('input').first.fill("")
|
| 212 |
+
except Exception:
|
| 213 |
+
pass
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def capture_all_screens():
|
| 217 |
+
ensure_dir(SCREEN_DIR)
|
| 218 |
+
screenshots = []
|
| 219 |
+
|
| 220 |
+
from playwright.sync_api import TimeoutError
|
| 221 |
+
|
| 222 |
+
with sync_playwright() as pw:
|
| 223 |
+
browser = pw.chromium.launch(headless=HEADLESS)
|
| 224 |
+
context = browser.new_context(viewport={"width": VIEWPORT_W, "height": VIEWPORT_H})
|
| 225 |
+
page = context.new_page()
|
| 226 |
+
|
| 227 |
+
# Login
|
| 228 |
+
do_login(page)
|
| 229 |
+
|
| 230 |
+
# Grupos
|
| 231 |
+
grupos = sorted({MODULES[mid].get("grupo", "Outros") for mid in MODULES}) if MODULES else []
|
| 232 |
+
if not grupos:
|
| 233 |
+
print("⚠️ MODULES está vazio. Não há módulos para capturar.")
|
| 234 |
+
save_artifacts_on_fail(page, "no_modules")
|
| 235 |
+
context.close(); browser.close()
|
| 236 |
+
return screenshots
|
| 237 |
+
|
| 238 |
+
for grupo in grupos:
|
| 239 |
+
try:
|
| 240 |
+
clear_search(page)
|
| 241 |
+
select_by_label(page, "Selecione a operação:", grupo)
|
| 242 |
+
page.wait_for_timeout(500)
|
| 243 |
+
|
| 244 |
+
gshot = os.path.join(SCREEN_DIR, f"{BANK_CHOICE}_grupo_{sanitize(grupo)}.png")
|
| 245 |
+
page.screenshot(path=gshot, full_page=True)
|
| 246 |
+
print(f"📸 Grupo: {grupo} → {gshot}")
|
| 247 |
+
except Exception as e:
|
| 248 |
+
print(f"⚠️ Falha ao selecionar grupo '{grupo}': {e}")
|
| 249 |
+
save_artifacts_on_fail(page, f"grupo_{grupo}")
|
| 250 |
+
continue
|
| 251 |
+
|
| 252 |
+
# Módulos do grupo
|
| 253 |
+
mod_ids = [mid for mid in MODULES if MODULES[mid].get("grupo", "Outros") == grupo]
|
| 254 |
+
for mid in mod_ids:
|
| 255 |
+
label = MODULES[mid].get("label", mid)
|
| 256 |
+
try:
|
| 257 |
+
select_by_label(page, "Selecione o módulo:", label)
|
| 258 |
+
page.wait_for_load_state("networkidle")
|
| 259 |
+
page.wait_for_timeout(800)
|
| 260 |
+
|
| 261 |
+
fname = f"{BANK_CHOICE}_{sanitize(grupo)}_{sanitize(mid)}.png"
|
| 262 |
+
fpath = os.path.join(SCREEN_DIR, fname)
|
| 263 |
+
page.screenshot(path=fpath, full_page=True)
|
| 264 |
+
screenshots.append((mid, label, grupo, fpath))
|
| 265 |
+
print(f"📸 Módulo: {label} → {fpath}")
|
| 266 |
+
except Exception as e:
|
| 267 |
+
print(f"❌ Falha ao capturar módulo '{label}': {e}")
|
| 268 |
+
save_artifacts_on_fail(page, f"mod_{mid}")
|
| 269 |
+
traceback.print_exc()
|
| 270 |
+
continue
|
| 271 |
+
|
| 272 |
+
context.close()
|
| 273 |
+
browser.close()
|
| 274 |
+
|
| 275 |
+
return screenshots
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def build_pptx(screens, out_path):
|
| 279 |
+
prs = Presentation()
|
| 280 |
+
|
| 281 |
+
# Slide de título
|
| 282 |
+
slide = prs.slides.add_slide(prs.slide_layouts[0])
|
| 283 |
+
slide.shapes.title.text = "Apresentação do Sistema (ARM LoadApp)"
|
| 284 |
+
subtitle = slide.placeholders[1].text_frame
|
| 285 |
+
subtitle.clear()
|
| 286 |
+
p = subtitle.paragraphs[0]
|
| 287 |
+
p.text = f"Ambiente: {bank_label(BANK_CHOICE)} | Gerado em {datetime.now().strftime('%d/%m/%Y %H:%M:%S')}"
|
| 288 |
+
p.font.size = Pt(14)
|
| 289 |
+
|
| 290 |
+
# Slides por módulo
|
| 291 |
+
for mid, label, grupo, fpath in screens:
|
| 292 |
+
layout = prs.slides.add_slide(prs.slide_layouts[5]) # Title Only
|
| 293 |
+
layout.shapes.title.text = f"{label} • {grupo}"
|
| 294 |
+
left, top, width = Inches(0.5), Inches(1.2), Inches(9)
|
| 295 |
+
try:
|
| 296 |
+
layout.shapes.add_picture(fpath, left, top, width=width)
|
| 297 |
+
except Exception:
|
| 298 |
+
tx = layout.shapes.add_textbox(left, top, Inches(9), Inches(1))
|
| 299 |
+
tf = tx.text_frame
|
| 300 |
+
tf.text = f"(Falha ao inserir imagem: {os.path.basename(fpath)})"
|
| 301 |
+
tf.paragraphs[0].font.color.rgb = RGBColor(200, 0, 0)
|
| 302 |
+
|
| 303 |
+
prs.save(out_path)
|
| 304 |
+
print(f"🎉 PPTX gerado: {out_path}")
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def main():
|
| 308 |
+
print(f"🚀 Captura em {APP_URL} | Banco: {BANK_CHOICE} ({bank_label(BANK_CHOICE)}) | headless={HEADLESS}")
|
| 309 |
+
ensure_dir(SCREEN_DIR)
|
| 310 |
+
screens = capture_all_screens()
|
| 311 |
+
if not screens:
|
| 312 |
+
print("⚠️ Nenhuma captura gerada. Veja os artefatos na pasta e revise seletores/menus.")
|
| 313 |
+
return
|
| 314 |
+
build_pptx(screens, OUTPUT_PPTX)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
if __name__ == "__main__":
|
| 318 |
+
main()
|
| 319 |
+
|
banco.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
from sqlalchemy import create_engine
|
| 3 |
+
from sqlalchemy.orm import sessionmaker, declarative_base
|
| 4 |
+
import os
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
import importlib
|
| 7 |
+
|
| 8 |
+
# 🔒 Caminho absoluto do projeto
|
| 9 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 10 |
+
|
| 11 |
+
# Carrega variáveis de ambiente (.env) antes de ler DATABASE_URL
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
# ============================================================
|
| 15 |
+
# 🔀 SUPORTE A DOIS BANCOS (Produção/Teste) COM FALLBACK
|
| 16 |
+
# ============================================================
|
| 17 |
+
# Tentamos usar o roteador (db_router.py). Se não existir ainda,
|
| 18 |
+
# caímos no comportamento original usando apenas DATABASE_URL.
|
| 19 |
+
try:
|
| 20 |
+
from db_router import (
|
| 21 |
+
get_engine as _router_get_engine,
|
| 22 |
+
get_session_factory as _router_get_session_factory,
|
| 23 |
+
SessionLocal as _router_SessionLocal,
|
| 24 |
+
)
|
| 25 |
+
_HAS_ROUTER = True
|
| 26 |
+
except Exception:
|
| 27 |
+
_HAS_ROUTER = False
|
| 28 |
+
|
| 29 |
+
# 🔧 Fallback: mesma lógica do seu módulo original — um único DATABASE_URL
|
| 30 |
+
DATABASE_URL = os.getenv(
|
| 31 |
+
"DATABASE_URL",
|
| 32 |
+
f"sqlite:///{os.path.join(BASE_DIR, 'load.db')}"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
engine_args = {
|
| 36 |
+
"echo": False,
|
| 37 |
+
"pool_pre_ping": True,
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
# Parâmetros específicos para SQLite (apenas se o fallback estiver ativo)
|
| 41 |
+
if DATABASE_URL.startswith("sqlite"):
|
| 42 |
+
engine_args["connect_args"] = {"check_same_thread": False}
|
| 43 |
+
|
| 44 |
+
# ============================================================
|
| 45 |
+
# Engine / SessionLocal (com ou sem roteador)
|
| 46 |
+
# ============================================================
|
| 47 |
+
if _HAS_ROUTER:
|
| 48 |
+
# ✅ Usa engine e SessionLocal do banco ATIVO (Produção/Teste), conforme escolha no login
|
| 49 |
+
def get_engine():
|
| 50 |
+
return _router_get_engine()
|
| 51 |
+
|
| 52 |
+
def _session_factory():
|
| 53 |
+
return _router_get_session_factory()
|
| 54 |
+
|
| 55 |
+
# A SessionLocal do roteador já entrega sessões no banco ativo
|
| 56 |
+
SessionLocal = _router_SessionLocal
|
| 57 |
+
|
| 58 |
+
else:
|
| 59 |
+
# ✅ Fallback: comportamento original com DATABASE_URL único
|
| 60 |
+
_engine = create_engine(DATABASE_URL, **engine_args)
|
| 61 |
+
|
| 62 |
+
def get_engine():
|
| 63 |
+
return _engine
|
| 64 |
+
|
| 65 |
+
_SessionFactory = sessionmaker(
|
| 66 |
+
autocommit=False,
|
| 67 |
+
autoflush=False,
|
| 68 |
+
bind=_engine,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
def _session_factory():
|
| 72 |
+
return _SessionFactory
|
| 73 |
+
|
| 74 |
+
# Compatível com seu uso atual: SessionLocal() -> sessão
|
| 75 |
+
SessionLocal = _SessionFactory
|
| 76 |
+
|
| 77 |
+
# ⚠️ Compatibilidade: expõe 'engine' resolvendo via get_engine()
|
| 78 |
+
# Observação importante:
|
| 79 |
+
# - Se trocar o banco após a importação deste módulo (via login),
|
| 80 |
+
# prefira sempre chamar get_engine() ou criar sessões com SessionLocal(),
|
| 81 |
+
# pois 'engine' abaixo é resolvido apenas uma vez (na importação).
|
| 82 |
+
engine = get_engine()
|
| 83 |
+
|
| 84 |
+
# ORM Base
|
| 85 |
+
Base = declarative_base()
|
| 86 |
+
|
| 87 |
+
# ============================================================
|
| 88 |
+
# 🛠️ Utilitários (opcionais)
|
| 89 |
+
# ============================================================
|
| 90 |
+
def init_schema():
|
| 91 |
+
"""
|
| 92 |
+
Cria/atualiza as tabelas no banco ATIVO.
|
| 93 |
+
• Com roteador: aplica no banco escolhido (Produção/Teste).
|
| 94 |
+
• Sem roteador: aplica no DATABASE_URL padrão.
|
| 95 |
+
Use em DEV/TESTE; em produção, prefira migrações (ex.: Alembic).
|
| 96 |
+
"""
|
| 97 |
+
# Importa 'models' de forma tardia e segura (sem wildcard) para registrar todos os mapeamentos
|
| 98 |
+
# antes de criar as tabelas. Isso evita import circular no topo.
|
| 99 |
+
try:
|
| 100 |
+
importlib.import_module("models")
|
| 101 |
+
except ModuleNotFoundError:
|
| 102 |
+
# Se seus modelos estiverem em outro pacote/caminho, ajuste aqui:
|
| 103 |
+
# importlib.import_module("app.models") # exemplo
|
| 104 |
+
raise
|
| 105 |
+
|
| 106 |
+
Base.metadata.create_all(bind=get_engine())
|
| 107 |
+
|
| 108 |
+
def db_info() -> dict:
|
| 109 |
+
"""
|
| 110 |
+
Retorna informações básicas do banco ativo (para debug/UX).
|
| 111 |
+
"""
|
| 112 |
+
eng = get_engine()
|
| 113 |
+
try:
|
| 114 |
+
url = str(eng.url)
|
| 115 |
+
except Exception:
|
| 116 |
+
url = DATABASE_URL
|
| 117 |
+
return {
|
| 118 |
+
"url": url,
|
| 119 |
+
"using_router": _HAS_ROUTER,
|
| 120 |
+
}
|
| 121 |
+
|
bi.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import streamlit.components.v1 as components
|
| 3 |
+
from banco import SessionLocal
|
| 4 |
+
from models import LogAcesso
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# =====================================================
|
| 9 |
+
# AUDITORIA
|
| 10 |
+
# =====================================================
|
| 11 |
+
def registrar_auditoria(acao):
|
| 12 |
+
db = SessionLocal()
|
| 13 |
+
try:
|
| 14 |
+
db.add(LogAcesso(
|
| 15 |
+
usuario=st.session_state.get("usuario", "desconhecido"),
|
| 16 |
+
acao=acao,
|
| 17 |
+
tabela="bi",
|
| 18 |
+
data_hora=datetime.now()
|
| 19 |
+
))
|
| 20 |
+
db.commit()
|
| 21 |
+
finally:
|
| 22 |
+
db.close()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# =====================================================
|
| 26 |
+
# APP PRINCIPAL
|
| 27 |
+
# =====================================================
|
| 28 |
+
def main():
|
| 29 |
+
st.title("📊 Business Intelligence")
|
| 30 |
+
|
| 31 |
+
st.caption("Indicadores e dashboards oficiais")
|
| 32 |
+
|
| 33 |
+
# 🔐 Auditoria de acesso
|
| 34 |
+
registrar_auditoria("ACESSO_BI")
|
| 35 |
+
|
| 36 |
+
# =====================================================
|
| 37 |
+
# SELEÇÃO DE DASHBOARD
|
| 38 |
+
# =====================================================
|
| 39 |
+
dashboards = {
|
| 40 |
+
"📈 Performance Operacional": {
|
| 41 |
+
"url": "https://app.powerbi.com/view?r=SEU_LINK_AQUI",
|
| 42 |
+
"height": 800
|
| 43 |
+
},
|
| 44 |
+
"📊 Qualidade e Erros": {
|
| 45 |
+
"url": "https://app.powerbi.com/view?r=SEU_LINK_AQUI",
|
| 46 |
+
"height": 800
|
| 47 |
+
},
|
| 48 |
+
"📦 Produtividade FPSO": {
|
| 49 |
+
"url": "https://app.powerbi.com/view?r=SEU_LINK_AQUI",
|
| 50 |
+
"height": 900
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
opcao = st.selectbox("Selecione o Dashboard", dashboards.keys())
|
| 55 |
+
|
| 56 |
+
dash = dashboards[opcao]
|
| 57 |
+
|
| 58 |
+
st.divider()
|
| 59 |
+
|
| 60 |
+
# =====================================================
|
| 61 |
+
# EMBED DO POWER BI
|
| 62 |
+
# =====================================================
|
| 63 |
+
components.html(
|
| 64 |
+
f"""
|
| 65 |
+
<iframe
|
| 66 |
+
width="100%"
|
| 67 |
+
height="{dash['height']}"
|
| 68 |
+
src="{dash['url']}"
|
| 69 |
+
frameborder="0"
|
| 70 |
+
allowfullscreen="true">
|
| 71 |
+
</iframe>
|
| 72 |
+
""",
|
| 73 |
+
height=dash["height"] + 20
|
| 74 |
+
)
|
cadastro_py.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from banco import SessionLocal
|
| 2 |
+
from models import FPSO
|
| 3 |
+
|
| 4 |
+
FPSO_PADRAO = [
|
| 5 |
+
"CDA", "CDP", "CDM", "ADG",
|
| 6 |
+
"ESS", "SEP", "CDI", "ATD", "CDS"
|
| 7 |
+
]
|
| 8 |
+
|
| 9 |
+
def main():
|
| 10 |
+
db = SessionLocal()
|
| 11 |
+
try:
|
| 12 |
+
existentes = {f.nome for f in db.query(FPSO).all()}
|
| 13 |
+
|
| 14 |
+
for nome in FPSO_PADRAO:
|
| 15 |
+
if nome not in existentes:
|
| 16 |
+
db.add(FPSO(nome=nome))
|
| 17 |
+
|
| 18 |
+
db.commit()
|
| 19 |
+
print("✅ FPSOs padrão inseridos com sucesso!")
|
| 20 |
+
|
| 21 |
+
finally:
|
| 22 |
+
db.close()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
if __name__ == "__main__":
|
| 26 |
+
main()
|
| 27 |
+
|
| 28 |
+
|
calendario.py
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
import streamlit as st
|
| 4 |
+
from datetime import date, datetime, timedelta
|
| 5 |
+
from typing import Dict, List
|
| 6 |
+
from banco import SessionLocal
|
| 7 |
+
from models import EventoCalendario
|
| 8 |
+
from utils_permissoes import verificar_permissao
|
| 9 |
+
from log import registrar_log
|
| 10 |
+
from utils_datas import formatar_data_br
|
| 11 |
+
|
| 12 |
+
# ⬇️ Componente de calendário
|
| 13 |
+
from streamlit_calendar import calendar
|
| 14 |
+
|
| 15 |
+
# =====================================================
|
| 16 |
+
# 📅 CALENDÁRIO + CRONOGRAMA ANUAL (D-3 / D-2 / D-1 / D 🚢)
|
| 17 |
+
# =====================================================
|
| 18 |
+
|
| 19 |
+
# ------------------------------
|
| 20 |
+
# ⚙️ Regras de embarque (fase/seed e passo)
|
| 21 |
+
# ------------------------------
|
| 22 |
+
# seed_day = dia (de Janeiro) usado como "D" inicial para o ano selecionado
|
| 23 |
+
# step = dias entre embarques (D → próximo D)
|
| 24 |
+
REGRAS_FPSO = {
|
| 25 |
+
"ATD": {"seed_day": 1, "step": 5},
|
| 26 |
+
"ADG": {"seed_day": 1, "step": 5},
|
| 27 |
+
"CDM": {"seed_day": 2, "step": 5},
|
| 28 |
+
"CDP": {"seed_day": 2, "step": 5},
|
| 29 |
+
"CDS": {"seed_day": 2, "step": 5},
|
| 30 |
+
"CDI": {"seed_day": 5, "step": 5}, # (ACDI → CDI)
|
| 31 |
+
"CDA": {"seed_day": 5, "step": 5},
|
| 32 |
+
"SEP": {"seed_day": 4, "step": 4}, # sem dia vazio
|
| 33 |
+
"ESS": {"seed_day": 3, "step": 7}, # blocos com pausa maior
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# 🎨 Paleta
|
| 37 |
+
COLOR_MAP = {
|
| 38 |
+
"D-3": "#00B050", # verde
|
| 39 |
+
"D-2": "#FF0000", # vermelho
|
| 40 |
+
"D-1": "#C00000", # vermelho escuro
|
| 41 |
+
"D": "#7F7F7F", # cinza
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
EMOJI_NAVIO = " 🚢" # adicionado aos títulos no dia D
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _usuario_atual() -> str:
|
| 48 |
+
return (st.session_state.get("usuario") or "sistema")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _criar_evento_fc(title: str, dt: date, color: str, extra: Dict = None) -> dict:
|
| 52 |
+
"""Monta um evento no formato FullCalendar/streamlit_calendar."""
|
| 53 |
+
ev = {
|
| 54 |
+
"id": f"auto::{title}::{dt.isoformat()}",
|
| 55 |
+
"title": title,
|
| 56 |
+
"start": dt.isoformat(),
|
| 57 |
+
"allDay": True,
|
| 58 |
+
"color": color,
|
| 59 |
+
"extendedProps": {"gerado_auto": True},
|
| 60 |
+
}
|
| 61 |
+
if extra:
|
| 62 |
+
ev["extendedProps"].update(extra)
|
| 63 |
+
return ev
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _rotulo_antes_de_d(dias: int) -> str:
|
| 67 |
+
"""Converte o deslocamento até D para rótulo: 0->D, 1->D-1, 2->D-2, 3->D-3, outros->''"""
|
| 68 |
+
if dias == 0:
|
| 69 |
+
return "D"
|
| 70 |
+
if dias in (1, 2, 3):
|
| 71 |
+
return f"D-{dias}"
|
| 72 |
+
return ""
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _gerar_cronograma_ano(
|
| 76 |
+
ano: int,
|
| 77 |
+
fpsos_sel: List[str],
|
| 78 |
+
incluir_anteriores: bool = True,
|
| 79 |
+
apenas_D: bool = False,
|
| 80 |
+
) -> List[dict]:
|
| 81 |
+
"""
|
| 82 |
+
Gera eventos 'D-3/D-2/D-1/D 🚢' para TODO o ano.
|
| 83 |
+
- incluir_anteriores: inclui D-1..D-3 que caem no começo do ano (vindo do D-semente).
|
| 84 |
+
- apenas_D: se True, somente 'D 🚢'.
|
| 85 |
+
"""
|
| 86 |
+
events = []
|
| 87 |
+
dt_ini = date(ano, 1, 1)
|
| 88 |
+
dt_fim = date(ano, 12, 31)
|
| 89 |
+
|
| 90 |
+
for fpso in fpsos_sel:
|
| 91 |
+
cfg = REGRAS_FPSO.get(fpso)
|
| 92 |
+
if not cfg:
|
| 93 |
+
continue
|
| 94 |
+
seed_day = max(1, min(cfg["seed_day"], 28)) # segurança (fev)
|
| 95 |
+
seed = date(ano, 1, seed_day)
|
| 96 |
+
step = int(cfg["step"])
|
| 97 |
+
|
| 98 |
+
# Todos os D do ano
|
| 99 |
+
d = seed
|
| 100 |
+
while d <= dt_fim:
|
| 101 |
+
if d >= dt_ini:
|
| 102 |
+
# D (com emoji) + cor
|
| 103 |
+
titulo_d = f"{fpso} – D{EMOJI_NAVIO}"
|
| 104 |
+
events.append(
|
| 105 |
+
_criar_evento_fc(
|
| 106 |
+
titulo_d, d, COLOR_MAP["D"],
|
| 107 |
+
{"tipo": "D", "fpso": fpso}
|
| 108 |
+
)
|
| 109 |
+
)
|
| 110 |
+
if not apenas_D:
|
| 111 |
+
# D-1..D-3
|
| 112 |
+
for k in (1, 2, 3):
|
| 113 |
+
dk = d - timedelta(days=k)
|
| 114 |
+
if dt_ini <= dk <= dt_fim:
|
| 115 |
+
label = f"D-{k}"
|
| 116 |
+
events.append(
|
| 117 |
+
_criar_evento_fc(
|
| 118 |
+
f"{fpso} – {label}",
|
| 119 |
+
dk,
|
| 120 |
+
COLOR_MAP[label],
|
| 121 |
+
{"tipo": label, "fpso": fpso},
|
| 122 |
+
)
|
| 123 |
+
)
|
| 124 |
+
d += timedelta(days=step)
|
| 125 |
+
|
| 126 |
+
# Cobertura no início do ano (apenas rótulos anteriores ao D-semente)
|
| 127 |
+
if incluir_anteriores and not apenas_D:
|
| 128 |
+
for k in (1, 2, 3):
|
| 129 |
+
dk = seed - timedelta(days=k)
|
| 130 |
+
if dt_ini <= dk <= dt_fim:
|
| 131 |
+
label = f"D-{k}"
|
| 132 |
+
events.append(
|
| 133 |
+
_criar_evento_fc(
|
| 134 |
+
f"{fpso} – {label}",
|
| 135 |
+
dk,
|
| 136 |
+
COLOR_MAP[label],
|
| 137 |
+
{"tipo": label, "fpso": fpso},
|
| 138 |
+
)
|
| 139 |
+
)
|
| 140 |
+
return events
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _gerar_cronograma_intervalo(
|
| 144 |
+
ano_ini: int,
|
| 145 |
+
ano_fim: int,
|
| 146 |
+
fpsos_sel: List[str],
|
| 147 |
+
apenas_D: bool = False,
|
| 148 |
+
) -> List[dict]:
|
| 149 |
+
"""Gera eventos para [ano_ini..ano_fim]."""
|
| 150 |
+
out = []
|
| 151 |
+
for y in range(ano_ini, ano_fim + 1):
|
| 152 |
+
out.extend(_gerar_cronograma_ano(y, fpsos_sel, incluir_anteriores=True, apenas_D=apenas_D))
|
| 153 |
+
return out
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _titulo_normalizado(titulo: str) -> str:
|
| 157 |
+
"""Remove o emoji ' 🚢' apenas para comparação/deduplicação."""
|
| 158 |
+
return titulo.replace(EMOJI_NAVIO, "")
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _dedup_chave(titulo: str, data_evt: date) -> str:
|
| 162 |
+
"""Chave de de-duplicação (título normalizado + data)."""
|
| 163 |
+
return f"{_titulo_normalizado(titulo)}::{data_evt.isoformat()}"
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _gravar_cronograma_no_banco(db, eventos_fc: List[dict]) -> int:
|
| 167 |
+
"""
|
| 168 |
+
Grava no banco eventos 'gerado_auto' evitando duplicados (ignorando emoji).
|
| 169 |
+
Retorna contagem de inserções.
|
| 170 |
+
"""
|
| 171 |
+
# Pré-carregar existentes no intervalo abrangido
|
| 172 |
+
if not eventos_fc:
|
| 173 |
+
return 0
|
| 174 |
+
min_day = min(date.fromisoformat(ev["start"][:10]) for ev in eventos_fc)
|
| 175 |
+
max_day = max(date.fromisoformat(ev["start"][:10]) for ev in eventos_fc)
|
| 176 |
+
|
| 177 |
+
existentes = (
|
| 178 |
+
db.query(EventoCalendario)
|
| 179 |
+
.filter(EventoCalendario.data_evento >= min_day)
|
| 180 |
+
.filter(EventoCalendario.data_evento <= max_day)
|
| 181 |
+
.filter(EventoCalendario.ativo.is_(True))
|
| 182 |
+
.all()
|
| 183 |
+
)
|
| 184 |
+
idx_existentes = {
|
| 185 |
+
_dedup_chave(e.titulo, e.data_evento): e.id for e in existentes
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
ins = 0
|
| 189 |
+
for ev in eventos_fc:
|
| 190 |
+
if not ev.get("extendedProps", {}).get("gerado_auto"):
|
| 191 |
+
continue
|
| 192 |
+
titulo = ev["title"]
|
| 193 |
+
dt = date.fromisoformat(ev["start"][:10])
|
| 194 |
+
k = _dedup_chave(titulo, dt)
|
| 195 |
+
if k in idx_existentes:
|
| 196 |
+
continue
|
| 197 |
+
novo = EventoCalendario(
|
| 198 |
+
titulo=titulo, # mantém o emoji nos D
|
| 199 |
+
descricao=f"Cronograma automático ({ev['extendedProps'].get('tipo','')})",
|
| 200 |
+
data_evento=dt,
|
| 201 |
+
data_lembrete=None,
|
| 202 |
+
ativo=True,
|
| 203 |
+
usuario_criacao=_usuario_atual(),
|
| 204 |
+
data_criacao=datetime.now(),
|
| 205 |
+
)
|
| 206 |
+
db.add(novo)
|
| 207 |
+
try:
|
| 208 |
+
db.commit()
|
| 209 |
+
ins += 1
|
| 210 |
+
except Exception:
|
| 211 |
+
db.rollback()
|
| 212 |
+
return ins
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _remover_cronograma_do_banco_intervalo(db, fpsos_sel: List[str], ano_ini: int, ano_fim: int) -> int:
|
| 216 |
+
"""
|
| 217 |
+
Remove do banco os eventos gerados por este módulo, para [ano_ini..ano_fim] e FPSOs.
|
| 218 |
+
Busca por títulos ('<FPSO> – D' / ' – D 🚢' / ' – D-1/2/3') e data no intervalo.
|
| 219 |
+
"""
|
| 220 |
+
ini = date(ano_ini, 1, 1)
|
| 221 |
+
fim = date(ano_fim, 12, 31)
|
| 222 |
+
total = 0
|
| 223 |
+
for fpso in fpsos_sel:
|
| 224 |
+
base = [f"{fpso} – D", f"{fpso} – D-1", f"{fpso} – D-2", f"{fpso} – D-3"]
|
| 225 |
+
# inclui com emoji para D
|
| 226 |
+
variantes = base + [f"{fpso} – D{EMOJI_NAVIO}"]
|
| 227 |
+
to_del = (
|
| 228 |
+
db.query(EventoCalendario)
|
| 229 |
+
.filter(EventoCalendario.data_evento >= ini)
|
| 230 |
+
.filter(EventoCalendario.data_evento <= fim)
|
| 231 |
+
.filter(EventoCalendario.titulo.in_(variantes))
|
| 232 |
+
.all()
|
| 233 |
+
)
|
| 234 |
+
for e in to_del:
|
| 235 |
+
db.delete(e)
|
| 236 |
+
total += 1
|
| 237 |
+
try:
|
| 238 |
+
db.commit()
|
| 239 |
+
except Exception:
|
| 240 |
+
db.rollback()
|
| 241 |
+
return total
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def main():
|
| 245 |
+
|
| 246 |
+
# =====================================================
|
| 247 |
+
# 🔒 PROTEÇÃO POR PERFIL
|
| 248 |
+
# =====================================================
|
| 249 |
+
if not verificar_permissao("calendario"):
|
| 250 |
+
st.error("⛔ Acesso não autorizado.")
|
| 251 |
+
return
|
| 252 |
+
|
| 253 |
+
st.title("📅 Calendário e Lembretes")
|
| 254 |
+
|
| 255 |
+
hoje = date.today()
|
| 256 |
+
db = SessionLocal()
|
| 257 |
+
|
| 258 |
+
# Helper: cor por status (eventos do banco)
|
| 259 |
+
def _cor_evento_db(e: "EventoCalendario") -> str:
|
| 260 |
+
if not e.ativo:
|
| 261 |
+
return "#95a5a6" # Cinza
|
| 262 |
+
if e.data_evento < hoje:
|
| 263 |
+
return "#e74c3c" # Vermelho (passado)
|
| 264 |
+
if e.data_lembrete and e.data_lembrete == hoje:
|
| 265 |
+
return "#f39c12" # Laranja (lembrete hoje)
|
| 266 |
+
return "#2ecc71" # Verde (ativo futuro)
|
| 267 |
+
|
| 268 |
+
# Converte EventoCalendario do banco → FullCalendar
|
| 269 |
+
def _to_fc_event_db(e: "EventoCalendario") -> dict:
|
| 270 |
+
return {
|
| 271 |
+
"id": str(e.id),
|
| 272 |
+
"title": e.titulo,
|
| 273 |
+
"start": e.data_evento.isoformat(),
|
| 274 |
+
"allDay": True,
|
| 275 |
+
"color": _cor_evento_db(e),
|
| 276 |
+
"extendedProps": {
|
| 277 |
+
"descricao": (e.descricao or ""),
|
| 278 |
+
"data_evento": e.data_evento.isoformat(),
|
| 279 |
+
"data_lembrete": e.data_lembrete.isoformat() if e.data_lembrete else None,
|
| 280 |
+
"ativo": e.ativo,
|
| 281 |
+
"gerado_auto": False,
|
| 282 |
+
},
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
try:
|
| 286 |
+
# =====================================================
|
| 287 |
+
# 🔔 LEMBRETES DO DIA
|
| 288 |
+
# =====================================================
|
| 289 |
+
st.subheader("⏰ Lembretes de Hoje | Adicione o calendário da sua embarcação")
|
| 290 |
+
|
| 291 |
+
lembretes = (
|
| 292 |
+
db.query(EventoCalendario)
|
| 293 |
+
.filter(EventoCalendario.data_lembrete == hoje)
|
| 294 |
+
.filter(EventoCalendario.ativo.is_(True))
|
| 295 |
+
.order_by(EventoCalendario.data_evento)
|
| 296 |
+
.all()
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
if lembretes:
|
| 300 |
+
for l in lembretes:
|
| 301 |
+
st.warning(f"🔔 **{l.titulo}** — Evento em {formatar_data_br(l.data_evento)}")
|
| 302 |
+
else:
|
| 303 |
+
st.info("Nenhum lembrete para hoje.")
|
| 304 |
+
|
| 305 |
+
st.divider()
|
| 306 |
+
|
| 307 |
+
# =====================================================
|
| 308 |
+
# 🎛️ CONTROLES DO CRONOGRAMA
|
| 309 |
+
# =====================================================
|
| 310 |
+
st.subheader("🛠️ Cronograma de Embarques (D-3 / D-2 / D-1 / D 🚢)")
|
| 311 |
+
|
| 312 |
+
col_a, col_b, col_c = st.columns([1, 2, 2])
|
| 313 |
+
with col_a:
|
| 314 |
+
ano_sel = st.number_input(
|
| 315 |
+
"Ano",
|
| 316 |
+
min_value=2000, max_value=2100,
|
| 317 |
+
value=hoje.year, step=1, key="cal_ano_sel"
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
fpsos_all = list(REGRAS_FPSO.keys())
|
| 321 |
+
with col_b:
|
| 322 |
+
fpsos_sel = st.multiselect(
|
| 323 |
+
"FPSOs",
|
| 324 |
+
options=fpsos_all,
|
| 325 |
+
default=fpsos_all,
|
| 326 |
+
key="cal_fpsos_sel",
|
| 327 |
+
)
|
| 328 |
+
if not fpsos_sel:
|
| 329 |
+
fpsos_sel = fpsos_all
|
| 330 |
+
|
| 331 |
+
with col_c:
|
| 332 |
+
apenas_D = st.checkbox("Exibir apenas dias de Embarque (D)", value=False)
|
| 333 |
+
|
| 334 |
+
# Gera cronograma em memória para o ANO selecionado (visualização)
|
| 335 |
+
eventos_auto = _gerar_cronograma_ano(
|
| 336 |
+
ano_sel, fpsos_sel, incluir_anteriores=True, apenas_D=apenas_D
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
# 🔁 Ações de banco: ANO
|
| 340 |
+
col_b1, col_b2, col_b3, col_b4 = st.columns([1.7, 1.7, 2, 2])
|
| 341 |
+
with col_b1:
|
| 342 |
+
if st.button("💾 Gravar cronograma (ano) no banco"):
|
| 343 |
+
qtd = _gravar_cronograma_no_banco(db, eventos_auto)
|
| 344 |
+
if qtd > 0:
|
| 345 |
+
registrar_log(_usuario_atual(), "CRIAR", "eventos_calendario", None)
|
| 346 |
+
st.success(f"Cronograma do ano {ano_sel} gravado/atualizado. Inserções: {qtd}.")
|
| 347 |
+
st.rerun()
|
| 348 |
+
with col_b2:
|
| 349 |
+
if st.button("🧹 Remover cronograma (ano) do banco"):
|
| 350 |
+
qtd = _remover_cronograma_do_banco_intervalo(db, fpsos_sel, ano_sel, ano_sel)
|
| 351 |
+
if qtd > 0:
|
| 352 |
+
registrar_log(_usuario_atual(), "EXCLUIR", "eventos_calendario", None)
|
| 353 |
+
st.warning(f"Eventos removidos do banco (ano {ano_sel}): {qtd}.")
|
| 354 |
+
st.rerun()
|
| 355 |
+
|
| 356 |
+
# 🔁 Ações de banco: INTERVALO ATÉ 2030
|
| 357 |
+
with col_b3:
|
| 358 |
+
if st.button("💾 Gravar cronograma até 2030 (banco)"):
|
| 359 |
+
eventos_lote = _gerar_cronograma_intervalo(
|
| 360 |
+
ano_ini=ano_sel, ano_fim=2030, fpsos_sel=fpsos_sel, apenas_D=apenas_D
|
| 361 |
+
)
|
| 362 |
+
qtd = _gravar_cronograma_no_banco(db, eventos_lote)
|
| 363 |
+
if qtd > 0:
|
| 364 |
+
registrar_log(_usuario_atual(), "CRIAR", "eventos_calendario", None)
|
| 365 |
+
st.success(f"Cronogramas {ano_sel}–2030 gravados/atualizados. Inserções: {qtd}.")
|
| 366 |
+
st.rerun()
|
| 367 |
+
with col_b4:
|
| 368 |
+
if st.button("🧹 Remover cronograma até 2030 (banco)"):
|
| 369 |
+
qtd = _remover_cronograma_do_banco_intervalo(db, fpsos_sel, ano_sel, 2030)
|
| 370 |
+
if qtd > 0:
|
| 371 |
+
registrar_log(_usuario_atual(), "EXCLUIR", "eventos_calendario", None)
|
| 372 |
+
st.warning(f"Eventos removidos do banco ({ano_sel}–2030): {qtd}.")
|
| 373 |
+
st.rerun()
|
| 374 |
+
|
| 375 |
+
st.caption(
|
| 376 |
+
"• A geração automática **não** altera seus eventos manuais. "
|
| 377 |
+
"Use os botões para **gravar** ou **remover** do banco apenas os eventos criados por este módulo. "
|
| 378 |
+
"Nos dias de **D**, o título inclui o ícone de navio (🚢)."
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
st.divider()
|
| 382 |
+
|
| 383 |
+
# =====================================================
|
| 384 |
+
# ➕ NOVO EVENTO / LEMBRETE (manual)
|
| 385 |
+
# =====================================================
|
| 386 |
+
with st.expander("➕ Novo Evento / Lembrete"):
|
| 387 |
+
with st.form("form_evento"):
|
| 388 |
+
titulo = st.text_input("Título *")
|
| 389 |
+
descricao = st.text_area("Descrição")
|
| 390 |
+
data_evento = st.date_input("Data do Evento", value=hoje, format="DD/MM/YYYY")
|
| 391 |
+
data_lembrete = st.date_input("Data do Lembrete (opcional)", value=None, format="DD/MM/YYYY")
|
| 392 |
+
ativo = st.checkbox("Evento ativo", value=True)
|
| 393 |
+
salvar = st.form_submit_button("💾 Salvar Evento")
|
| 394 |
+
|
| 395 |
+
if salvar:
|
| 396 |
+
if not titulo.strip():
|
| 397 |
+
st.error("⚠️ O título é obrigatório.")
|
| 398 |
+
elif data_lembrete and (data_lembrete > data_evento):
|
| 399 |
+
st.error("⚠️ O lembrete não pode ser após a data do evento.")
|
| 400 |
+
else:
|
| 401 |
+
evento = EventoCalendario(
|
| 402 |
+
titulo=titulo.strip(),
|
| 403 |
+
descricao=(descricao or "").strip(),
|
| 404 |
+
data_evento=data_evento,
|
| 405 |
+
data_lembrete=data_lembrete,
|
| 406 |
+
ativo=ativo,
|
| 407 |
+
usuario_criacao=_usuario_atual(),
|
| 408 |
+
data_criacao=datetime.now()
|
| 409 |
+
)
|
| 410 |
+
db.add(evento)
|
| 411 |
+
try:
|
| 412 |
+
db.commit()
|
| 413 |
+
except Exception as e:
|
| 414 |
+
db.rollback()
|
| 415 |
+
st.error(f"❌ Erro ao salvar evento: {e}")
|
| 416 |
+
else:
|
| 417 |
+
registrar_log(_usuario_atual(), "CRIAR", "eventos_calendario", evento.id)
|
| 418 |
+
st.success("✅ Evento criado com sucesso!")
|
| 419 |
+
st.rerun()
|
| 420 |
+
|
| 421 |
+
st.divider()
|
| 422 |
+
|
| 423 |
+
# =====================================================
|
| 424 |
+
# 📆 CALENDÁRIO (eventos do banco + cronograma do ANO selecionado)
|
| 425 |
+
# =====================================================
|
| 426 |
+
st.subheader("📆 Calendário (clique no dia ou no evento para ver a observação)")
|
| 427 |
+
|
| 428 |
+
# Banco (apenas ano selecionado na visualização)
|
| 429 |
+
ini_year = date(ano_sel, 1, 1)
|
| 430 |
+
end_year = date(ano_sel, 12, 31)
|
| 431 |
+
eventos_db = (
|
| 432 |
+
db.query(EventoCalendario)
|
| 433 |
+
.filter(EventoCalendario.data_evento >= ini_year)
|
| 434 |
+
.filter(EventoCalendario.data_evento <= end_year)
|
| 435 |
+
.order_by(EventoCalendario.data_evento.asc())
|
| 436 |
+
.all()
|
| 437 |
+
)
|
| 438 |
+
eventos_fc_db = [_to_fc_event_db(e) for e in eventos_db]
|
| 439 |
+
|
| 440 |
+
# Junta cronograma automático (memória) + banco (para a visualização do ano)
|
| 441 |
+
eventos_fc = eventos_fc_db + eventos_auto
|
| 442 |
+
|
| 443 |
+
options = {
|
| 444 |
+
"initialView": "dayGridMonth",
|
| 445 |
+
"locale": "pt-br",
|
| 446 |
+
"height": 700,
|
| 447 |
+
"firstDay": 1,
|
| 448 |
+
"weekNumbers": False,
|
| 449 |
+
"headerToolbar": {
|
| 450 |
+
"left": "prev,next today",
|
| 451 |
+
"center": "title",
|
| 452 |
+
"right": "dayGridMonth,dayGridWeek,listWeek"
|
| 453 |
+
},
|
| 454 |
+
"buttonText": {
|
| 455 |
+
"today": "Hoje",
|
| 456 |
+
"month": "Mês",
|
| 457 |
+
"week": "Semana",
|
| 458 |
+
"day": "Dia",
|
| 459 |
+
"list": "Lista"
|
| 460 |
+
},
|
| 461 |
+
"dayMaxEventRows": True,
|
| 462 |
+
"navLinks": True,
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
state = calendar(
|
| 466 |
+
events=eventos_fc,
|
| 467 |
+
options=options,
|
| 468 |
+
custom_css="",
|
| 469 |
+
key=f"calendario_eventos_{ano_sel}"
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
# Legenda
|
| 473 |
+
with st.container():
|
| 474 |
+
cols = st.columns([1.2, 1.2, 1.2, 1.2, 2.2, 3])
|
| 475 |
+
cols[0].markdown("⬛ **D** (cinza) " + EMOJI_NAVIO)
|
| 476 |
+
cols[1].markdown("🟥 **D‑1** (vinho)")
|
| 477 |
+
cols[2].markdown("🟥 **D‑2** (vermelho)")
|
| 478 |
+
cols[3].markdown("🟩 **D‑3** (verde)")
|
| 479 |
+
cols[4].markdown("🟧 **Lembrete hoje (eventos do banco)**")
|
| 480 |
+
cols[5].markdown("🟦 **Outros eventos (banco)**")
|
| 481 |
+
|
| 482 |
+
st.divider()
|
| 483 |
+
|
| 484 |
+
# =====================================================
|
| 485 |
+
# 🔎 Detalhe por clique (evento ou dia)
|
| 486 |
+
# =====================================================
|
| 487 |
+
clicked_event = None
|
| 488 |
+
if state and isinstance(state, dict):
|
| 489 |
+
clicked_event = (state.get("eventClick") or {}).get("event")
|
| 490 |
+
clicked_date_str = (state.get("dateClick") or {}).get("dateStr")
|
| 491 |
+
else:
|
| 492 |
+
clicked_date_str = None
|
| 493 |
+
|
| 494 |
+
if clicked_event:
|
| 495 |
+
ev_id = clicked_event.get("id")
|
| 496 |
+
ev_title = clicked_event.get("title")
|
| 497 |
+
ev_start = clicked_event.get("start")
|
| 498 |
+
ev_ext = clicked_event.get("extendedProps") or {}
|
| 499 |
+
|
| 500 |
+
# Se for do banco, traz detalhes atualizados
|
| 501 |
+
e = None
|
| 502 |
+
if ev_id and not str(ev_id).startswith("auto::"):
|
| 503 |
+
try:
|
| 504 |
+
e = db.query(EventoCalendario).get(int(ev_id))
|
| 505 |
+
except Exception:
|
| 506 |
+
e = None
|
| 507 |
+
|
| 508 |
+
st.subheader(f"📌 {ev_title or 'Evento'}")
|
| 509 |
+
if e:
|
| 510 |
+
st.markdown(
|
| 511 |
+
f"""
|
| 512 |
+
**Descrição:**
|
| 513 |
+
{e.descricao or "_Sem descrição_"}
|
| 514 |
+
|
| 515 |
+
**📅 Data do Evento:** {formatar_data_br(e.data_evento)}
|
| 516 |
+
**⏰ Data do Lembrete:** {formatar_data_br(e.data_lembrete) if e.data_lembrete else "_Sem lembrete_"}
|
| 517 |
+
**📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"}
|
| 518 |
+
"""
|
| 519 |
+
)
|
| 520 |
+
if verificar_permissao("administracao"):
|
| 521 |
+
col1, col2 = st.columns(2)
|
| 522 |
+
with col1:
|
| 523 |
+
if e.ativo and st.button("🚫 Desativar", key=f"desativar_{e.id}"):
|
| 524 |
+
e.ativo = False
|
| 525 |
+
try:
|
| 526 |
+
db.commit()
|
| 527 |
+
except Exception as ex:
|
| 528 |
+
db.rollback()
|
| 529 |
+
st.error(f"Erro ao desativar: {ex}")
|
| 530 |
+
else:
|
| 531 |
+
registrar_log(_usuario_atual(), "DESATIVAR",
|
| 532 |
+
"eventos_calendario", e.id)
|
| 533 |
+
st.success("Evento desativado.")
|
| 534 |
+
st.rerun()
|
| 535 |
+
with col2:
|
| 536 |
+
if st.button("🗑️ Excluir", key=f"excluir_{e.id}"):
|
| 537 |
+
db.delete(e)
|
| 538 |
+
try:
|
| 539 |
+
db.commit()
|
| 540 |
+
except Exception as ex:
|
| 541 |
+
db.rollback()
|
| 542 |
+
st.error(f"Erro ao excluir: {ex}")
|
| 543 |
+
else:
|
| 544 |
+
registrar_log(_usuario_atual(), "EXCLUIR",
|
| 545 |
+
"eventos_calendario", e.id)
|
| 546 |
+
st.success("Evento excluído.")
|
| 547 |
+
st.rerun()
|
| 548 |
+
else:
|
| 549 |
+
# Evento do cronograma automático (memória)
|
| 550 |
+
dt_evt = date.fromisoformat(ev_start[:10])
|
| 551 |
+
st.markdown(
|
| 552 |
+
f"""
|
| 553 |
+
**FPSO:** {ev_title.split(' – ')[0] if ' – ' in (ev_title or '') else '—'}
|
| 554 |
+
**Tipo:** {ev_ext.get('tipo', '—')}
|
| 555 |
+
**📅 Data:** {formatar_data_br(dt_evt)}
|
| 556 |
+
**Origem:** _Cronograma automático (não gravado no banco)_
|
| 557 |
+
"""
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
+
elif clicked_date_str:
|
| 561 |
+
try:
|
| 562 |
+
data_clicada = date.fromisoformat(clicked_date_str)
|
| 563 |
+
except Exception:
|
| 564 |
+
data_clicada = None
|
| 565 |
+
|
| 566 |
+
if data_clicada:
|
| 567 |
+
st.subheader(f"🗓️ Eventos em {formatar_data_br(data_clicada)}")
|
| 568 |
+
|
| 569 |
+
# Banco
|
| 570 |
+
eventos_no_dia_db = (
|
| 571 |
+
db.query(EventoCalendario)
|
| 572 |
+
.filter(EventoCalendario.data_evento == data_clicada)
|
| 573 |
+
.order_by(EventoCalendario.id.desc())
|
| 574 |
+
.all()
|
| 575 |
+
)
|
| 576 |
+
if not eventos_no_dia_db:
|
| 577 |
+
st.info("Nenhum evento do banco para este dia.")
|
| 578 |
+
else:
|
| 579 |
+
st.markdown("**📦 Eventos do banco**")
|
| 580 |
+
for e in eventos_no_dia_db:
|
| 581 |
+
with st.expander(f"📌 {e.titulo}"):
|
| 582 |
+
st.markdown(
|
| 583 |
+
f"""
|
| 584 |
+
**Descrição:**
|
| 585 |
+
{e.descricao or "_Sem descrição_"}
|
| 586 |
+
|
| 587 |
+
**📅 Data do Evento:** {formatar_data_br(e.data_evento)}
|
| 588 |
+
**⏰ Data do Lembrete:** {formatar_data_br(e.data_lembrete)}
|
| 589 |
+
**📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"}
|
| 590 |
+
"""
|
| 591 |
+
)
|
| 592 |
+
if verificar_permissao("administracao"):
|
| 593 |
+
c1, c2 = st.columns(2)
|
| 594 |
+
with c1:
|
| 595 |
+
if e.ativo and st.button("🚫 Desativar", key=f"desativar_list_{e.id}"):
|
| 596 |
+
e.ativo = False
|
| 597 |
+
try:
|
| 598 |
+
db.commit()
|
| 599 |
+
except Exception as ex:
|
| 600 |
+
db.rollback()
|
| 601 |
+
st.error(f"Erro ao desativar: {ex}")
|
| 602 |
+
else:
|
| 603 |
+
registrar_log(_usuario_atual(), "DESATIVAR",
|
| 604 |
+
"eventos_calendario", e.id)
|
| 605 |
+
st.success("Evento desativado.")
|
| 606 |
+
st.rerun()
|
| 607 |
+
with c2:
|
| 608 |
+
if st.button("🗑️ Excluir", key=f"excluir_list_{e.id}"):
|
| 609 |
+
db.delete(e)
|
| 610 |
+
try:
|
| 611 |
+
db.commit()
|
| 612 |
+
except Exception as ex:
|
| 613 |
+
db.rollback()
|
| 614 |
+
st.error(f"Erro ao excluir: {ex}")
|
| 615 |
+
else:
|
| 616 |
+
registrar_log(_usuario_atual(), "EXCLUIR",
|
| 617 |
+
"eventos_calendario", e.id)
|
| 618 |
+
st.success("Evento excluído.")
|
| 619 |
+
st.rerun()
|
| 620 |
+
|
| 621 |
+
# Cronograma automático (memória) – ano selecionado
|
| 622 |
+
eventos_auto_no_dia = [
|
| 623 |
+
ev for ev in eventos_auto
|
| 624 |
+
if ev.get("start", "")[:10] == data_clicada.isoformat()
|
| 625 |
+
]
|
| 626 |
+
if eventos_auto_no_dia:
|
| 627 |
+
st.markdown("**🛠️ Eventos gerados automaticamente (cronograma)**")
|
| 628 |
+
for ev in sorted(eventos_auto_no_dia, key=lambda x: x.get("title","")):
|
| 629 |
+
fps = ev.get("title","").split(" – ")[0] if " – " in ev.get("title","") else "—"
|
| 630 |
+
tipo = ev.get("extendedProps", {}).get("tipo", "—")
|
| 631 |
+
st.write(f"• **{fps}** — **{tipo}** ({formatar_data_br(data_clicada)})")
|
| 632 |
+
|
| 633 |
+
st.divider()
|
| 634 |
+
|
| 635 |
+
# =====================================================
|
| 636 |
+
# 📆 Consultar Eventos por Data (modo antigo) — inclui AUTO
|
| 637 |
+
# =====================================================
|
| 638 |
+
with st.expander("📆 Consultar Eventos por Data (modo antigo)"):
|
| 639 |
+
data_consulta = st.date_input("Selecione uma data",
|
| 640 |
+
value=hoje, format="DD/MM/YYYY",
|
| 641 |
+
key="consulta_antiga")
|
| 642 |
+
|
| 643 |
+
# Banco
|
| 644 |
+
eventos = (
|
| 645 |
+
db.query(EventoCalendario)
|
| 646 |
+
.filter(EventoCalendario.data_evento == data_consulta)
|
| 647 |
+
.order_by(EventoCalendario.id.desc())
|
| 648 |
+
.all()
|
| 649 |
+
)
|
| 650 |
+
if not eventos:
|
| 651 |
+
st.info("Nenhum evento do banco para esta data.")
|
| 652 |
+
else:
|
| 653 |
+
st.markdown("**📦 Eventos do banco**")
|
| 654 |
+
for e in eventos:
|
| 655 |
+
with st.expander(f"📌 {e.titulo}"):
|
| 656 |
+
st.markdown(
|
| 657 |
+
f"""
|
| 658 |
+
**Descrição:**
|
| 659 |
+
{e.descricao or "_Sem descrição_"}
|
| 660 |
+
|
| 661 |
+
**📅 Data do Evento:** {formatar_data_br(e.data_evento)}
|
| 662 |
+
**⏰ Data do Lembrete:** {formatar_data_br(e.data_lembrete)}
|
| 663 |
+
**📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"}
|
| 664 |
+
"""
|
| 665 |
+
)
|
| 666 |
+
if verificar_permissao("administracao"):
|
| 667 |
+
col1, col2 = st.columns(2)
|
| 668 |
+
with col1:
|
| 669 |
+
if e.ativo and st.button("🚫 Desativar", key=f"desativar_old_{e.id}"):
|
| 670 |
+
e.ativo = False
|
| 671 |
+
try:
|
| 672 |
+
db.commit()
|
| 673 |
+
except Exception as ex:
|
| 674 |
+
db.rollback()
|
| 675 |
+
st.error(f"Erro ao desativar: {ex}")
|
| 676 |
+
else:
|
| 677 |
+
registrar_log(_usuario_atual(), "DESATIVAR",
|
| 678 |
+
"eventos_calendario", e.id)
|
| 679 |
+
st.success("Evento desativado.")
|
| 680 |
+
st.rerun()
|
| 681 |
+
with col2:
|
| 682 |
+
if st.button("🗑️ Excluir", key=f"excluir_old_{e.id}"):
|
| 683 |
+
db.delete(e)
|
| 684 |
+
try:
|
| 685 |
+
db.commit()
|
| 686 |
+
except Exception as ex:
|
| 687 |
+
db.rollback()
|
| 688 |
+
st.error(f"Erro ao excluir: {ex}")
|
| 689 |
+
else:
|
| 690 |
+
registrar_log(_usuario_atual(), "EXCLUIR",
|
| 691 |
+
"eventos_calendario", e.id)
|
| 692 |
+
st.success("Evento excluído.")
|
| 693 |
+
st.rerun()
|
| 694 |
+
|
| 695 |
+
# AUTO (memória) no ano selecionado
|
| 696 |
+
eventos_auto_antigo = [
|
| 697 |
+
ev for ev in eventos_auto
|
| 698 |
+
if ev.get("start", "")[:10] == data_consulta.isoformat()
|
| 699 |
+
]
|
| 700 |
+
if eventos_auto_antigo:
|
| 701 |
+
st.markdown("**🛠️ Eventos gerados automaticamente (cronograma)**")
|
| 702 |
+
for ev in sorted(eventos_auto_antigo, key=lambda x: x.get("title","")):
|
| 703 |
+
fps = ev.get("title","").split(" – ")[0] if " – " in ev.get("title","") else "—"
|
| 704 |
+
tipo = ev.get("extendedProps", {}).get("tipo", "—")
|
| 705 |
+
st.write(f"• **{fps}** — **{tipo}** ({formatar_data_br(data_consulta)})")
|
| 706 |
+
|
| 707 |
+
finally:
|
| 708 |
+
db.close()
|
calendario_mensal.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import calendar
|
| 3 |
+
from datetime import date
|
| 4 |
+
from banco import SessionLocal
|
| 5 |
+
from models import EventoCalendario
|
| 6 |
+
from utils_permissoes import verificar_permissao
|
| 7 |
+
from utils_datas import formatar_data_br
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def main():
|
| 11 |
+
|
| 12 |
+
if not verificar_permissao("calendario"):
|
| 13 |
+
st.error("⛔ Acesso não autorizado.")
|
| 14 |
+
return
|
| 15 |
+
|
| 16 |
+
usuario = st.session_state.get("usuario")
|
| 17 |
+
if not usuario:
|
| 18 |
+
st.error("Usuário não autenticado.")
|
| 19 |
+
return
|
| 20 |
+
|
| 21 |
+
st.title("📆 Agenda Mensal")
|
| 22 |
+
|
| 23 |
+
hoje = date.today()
|
| 24 |
+
|
| 25 |
+
col1, col2 = st.columns(2)
|
| 26 |
+
|
| 27 |
+
with col1:
|
| 28 |
+
ano = st.selectbox("Ano", range(hoje.year - 2, hoje.year + 3), index=2)
|
| 29 |
+
|
| 30 |
+
with col2:
|
| 31 |
+
mes = st.selectbox("Mês", range(1, 13), index=hoje.month - 1)
|
| 32 |
+
|
| 33 |
+
db = SessionLocal()
|
| 34 |
+
try:
|
| 35 |
+
eventos = (
|
| 36 |
+
db.query(EventoCalendario)
|
| 37 |
+
.filter(EventoCalendario.usuario_criacao == usuario)
|
| 38 |
+
.filter(EventoCalendario.data_evento.between(
|
| 39 |
+
date(ano, mes, 1),
|
| 40 |
+
date(ano, mes, calendar.monthrange(ano, mes)[1])
|
| 41 |
+
))
|
| 42 |
+
.filter(EventoCalendario.ativo.is_(True))
|
| 43 |
+
.all()
|
| 44 |
+
)
|
| 45 |
+
finally:
|
| 46 |
+
db.close()
|
| 47 |
+
|
| 48 |
+
eventos_por_dia = {}
|
| 49 |
+
for e in eventos:
|
| 50 |
+
eventos_por_dia.setdefault(e.data_evento.day, []).append(e)
|
| 51 |
+
|
| 52 |
+
st.divider()
|
| 53 |
+
|
| 54 |
+
semanas = calendar.monthcalendar(ano, mes)
|
| 55 |
+
|
| 56 |
+
dias_semana = ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"]
|
| 57 |
+
st.columns(7)
|
| 58 |
+
for d in dias_semana:
|
| 59 |
+
st.markdown(f"**{d}**")
|
| 60 |
+
|
| 61 |
+
for semana in semanas:
|
| 62 |
+
cols = st.columns(7)
|
| 63 |
+
for idx, dia in enumerate(semana):
|
| 64 |
+
with cols[idx]:
|
| 65 |
+
if dia == 0:
|
| 66 |
+
st.write("")
|
| 67 |
+
else:
|
| 68 |
+
st.markdown(f"### {dia}")
|
| 69 |
+
for ev in eventos_por_dia.get(dia, []):
|
| 70 |
+
st.caption(f"📌 {ev.titulo}")
|
componentes.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
FPSO_PADRAO = [
|
| 5 |
+
"CDA",
|
| 6 |
+
"CDP",
|
| 7 |
+
"CDM",
|
| 8 |
+
"ADG",
|
| 9 |
+
"ESS",
|
| 10 |
+
"SEP",
|
| 11 |
+
"CDI",
|
| 12 |
+
"ATD",
|
| 13 |
+
"CDS"
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def campo_fpso(label, key):
|
| 18 |
+
"""
|
| 19 |
+
Campo FPSO com sugestões + opção de texto livre
|
| 20 |
+
"""
|
| 21 |
+
opcoes = [""] + FPSO_PADRAO + ["Outro"]
|
| 22 |
+
|
| 23 |
+
escolha = st.selectbox(
|
| 24 |
+
label,
|
| 25 |
+
opcoes,
|
| 26 |
+
key=f"{key}_select"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
if escolha == "Outro":
|
| 30 |
+
return st.text_input(
|
| 31 |
+
f"{label} (digite)",
|
| 32 |
+
key=f"{key}_texto"
|
| 33 |
+
).strip()
|
| 34 |
+
|
| 35 |
+
return escolha
|
consulta.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import streamlit as st
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from io import BytesIO
|
| 5 |
+
from datetime import date
|
| 6 |
+
from banco import SessionLocal
|
| 7 |
+
from models import Equipamento
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def limpar_estado_consulta():
|
| 11 |
+
"""
|
| 12 |
+
Remove do session_state qualquer dado
|
| 13 |
+
relacionado ao módulo Consulta
|
| 14 |
+
"""
|
| 15 |
+
for key in list(st.session_state.keys()):
|
| 16 |
+
if key.startswith("consulta_"):
|
| 17 |
+
del st.session_state[key]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _coerce_date(x):
|
| 21 |
+
"""Garante que valores sejam datas (date) ou NaT para comparação."""
|
| 22 |
+
if pd.isna(x):
|
| 23 |
+
return pd.NaT
|
| 24 |
+
if isinstance(x, (pd.Timestamp, )):
|
| 25 |
+
return x.date()
|
| 26 |
+
if isinstance(x, date):
|
| 27 |
+
return x
|
| 28 |
+
try:
|
| 29 |
+
return pd.to_datetime(x).date()
|
| 30 |
+
except Exception:
|
| 31 |
+
return pd.NaT
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def main():
|
| 35 |
+
|
| 36 |
+
# =====================================================
|
| 37 |
+
# 🧹 LIMPA ESTADO AO ENTRAR NO MÓDULO
|
| 38 |
+
# =====================================================
|
| 39 |
+
if not st.session_state.get("_consulta_inicializado"):
|
| 40 |
+
limpar_estado_consulta()
|
| 41 |
+
st.session_state["_consulta_inicializado"] = True
|
| 42 |
+
|
| 43 |
+
st.title("🔍 Consulta de Registros")
|
| 44 |
+
|
| 45 |
+
db = SessionLocal()
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
registros = db.query(Equipamento).all()
|
| 49 |
+
|
| 50 |
+
if not registros:
|
| 51 |
+
st.info("Nenhum registro encontrado.")
|
| 52 |
+
return
|
| 53 |
+
|
| 54 |
+
# =====================================================
|
| 55 |
+
# 🔄 CONVERTE REGISTROS EM DATAFRAME (TODOS OS CAMPOS)
|
| 56 |
+
# =====================================================
|
| 57 |
+
df = pd.DataFrame([
|
| 58 |
+
{
|
| 59 |
+
"ID": r.id,
|
| 60 |
+
|
| 61 |
+
# Identificação
|
| 62 |
+
"FPSO1": r.fpso1,
|
| 63 |
+
"FPSO": r.fpso,
|
| 64 |
+
"Data Coleta": r.data_coleta,
|
| 65 |
+
|
| 66 |
+
# Responsáveis
|
| 67 |
+
"Especialista": r.especialista,
|
| 68 |
+
"Conferente": r.conferente,
|
| 69 |
+
"OSM": r.osm,
|
| 70 |
+
|
| 71 |
+
# Operacional
|
| 72 |
+
"Modal": r.modal,
|
| 73 |
+
"Quantidade Equip.": r.quant_equip,
|
| 74 |
+
"MROB": r.mrob,
|
| 75 |
+
|
| 76 |
+
# Métricas
|
| 77 |
+
"Linhas OSM": r.linhas_osm,
|
| 78 |
+
"Linhas MROB": r.linhas_mrob,
|
| 79 |
+
"Linhas Erros": r.linhas_erros,
|
| 80 |
+
|
| 81 |
+
# Erros
|
| 82 |
+
"Erro Storekeeper": r.erro_storekeeper,
|
| 83 |
+
"Erro Operação": r.erro_operacao,
|
| 84 |
+
"Erro Especialista": r.erro_especialista,
|
| 85 |
+
"Erro Outros": r.erro_outros,
|
| 86 |
+
|
| 87 |
+
# Dados complementares
|
| 88 |
+
"Inclusão / Exclusão": r.inclusao_exclusao,
|
| 89 |
+
"PO": r.po,
|
| 90 |
+
"Part Number": r.part_number,
|
| 91 |
+
"Material": r.material,
|
| 92 |
+
|
| 93 |
+
"Solicitante": r.solicitante,
|
| 94 |
+
"Motivo": getattr(r, "motivo", None),
|
| 95 |
+
"Requisitante": r.requisitante,
|
| 96 |
+
"Nota Fiscal": r.nota_fiscal,
|
| 97 |
+
"Impacto": r.impacto,
|
| 98 |
+
"Dimensão": r.dimensao,
|
| 99 |
+
|
| 100 |
+
"Observações": r.observacoes,
|
| 101 |
+
"Dia Inclusão": r.dia_inclusao,
|
| 102 |
+
|
| 103 |
+
# Auditoria
|
| 104 |
+
"Data/Hora Input": r.data_hora_input,
|
| 105 |
+
}
|
| 106 |
+
for r in registros
|
| 107 |
+
])
|
| 108 |
+
|
| 109 |
+
# Normaliza a coluna de data para comparação correta
|
| 110 |
+
if "Data Coleta" in df.columns:
|
| 111 |
+
df["Data Coleta"] = df["Data Coleta"].apply(_coerce_date)
|
| 112 |
+
|
| 113 |
+
# =====================================================
|
| 114 |
+
# 🔎 FILTROS
|
| 115 |
+
# =====================================================
|
| 116 |
+
st.subheader("🔎 Filtros")
|
| 117 |
+
|
| 118 |
+
col1, col2, col3 = st.columns(3)
|
| 119 |
+
|
| 120 |
+
with col1:
|
| 121 |
+
filtro_fpso = st.multiselect(
|
| 122 |
+
"FPSO",
|
| 123 |
+
sorted(df["FPSO"].dropna().unique()),
|
| 124 |
+
key="consulta_fpso"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
filtro_dia = st.multiselect(
|
| 128 |
+
"Dia de Inclusão (D1 / D2 / D3)",
|
| 129 |
+
sorted(df["Dia Inclusão"].dropna().unique()),
|
| 130 |
+
key="consulta_dia"
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
with col2:
|
| 134 |
+
filtro_modal = st.multiselect(
|
| 135 |
+
"Modal",
|
| 136 |
+
sorted(df["Modal"].dropna().unique()),
|
| 137 |
+
key="consulta_modal"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
filtro_especialista = st.multiselect(
|
| 141 |
+
"Especialista",
|
| 142 |
+
sorted(df["Especialista"].dropna().unique()),
|
| 143 |
+
key="consulta_especialista"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
# 🔵 FILTRO OSM
|
| 147 |
+
filtro_osm = st.multiselect(
|
| 148 |
+
"OSM",
|
| 149 |
+
sorted(df["OSM"].dropna().unique()),
|
| 150 |
+
key="consulta_osm"
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
with col3:
|
| 154 |
+
periodo = st.date_input(
|
| 155 |
+
"Período de Coleta",
|
| 156 |
+
value=None,
|
| 157 |
+
key="consulta_periodo"
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
# 🟩 NOVO: FILTRO DE NOTA FISCAL
|
| 161 |
+
st.markdown("**Nota Fiscal**")
|
| 162 |
+
nota_input_text = st.text_input(
|
| 163 |
+
"Digite um ou mais números (separados por vírgula)",
|
| 164 |
+
value="",
|
| 165 |
+
key="consulta_nf_text"
|
| 166 |
+
)
|
| 167 |
+
# Alternativamente (opcional) oferecer multiselect pelos valores existentes
|
| 168 |
+
filtro_nf_multi = st.multiselect(
|
| 169 |
+
"Ou selecione",
|
| 170 |
+
sorted([str(x) for x in df["Nota Fiscal"].dropna().unique()]),
|
| 171 |
+
key="consulta_nf_multi"
|
| 172 |
+
)
|
| 173 |
+
mostrar_apenas_duplicadas = st.checkbox(
|
| 174 |
+
"Mostrar apenas notas duplicadas",
|
| 175 |
+
value=False,
|
| 176 |
+
key="consulta_mostrar_dup_nf"
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
# =====================================================
|
| 180 |
+
# 🔄 APLICA FILTROS
|
| 181 |
+
# =====================================================
|
| 182 |
+
# Filtros simples
|
| 183 |
+
if filtro_fpso:
|
| 184 |
+
df = df[df["FPSO"].isin(filtro_fpso)]
|
| 185 |
+
|
| 186 |
+
if filtro_modal:
|
| 187 |
+
df = df[df["Modal"].isin(filtro_modal)]
|
| 188 |
+
|
| 189 |
+
if filtro_especialista:
|
| 190 |
+
df = df[df["Especialista"].isin(filtro_especialista)]
|
| 191 |
+
|
| 192 |
+
if filtro_dia:
|
| 193 |
+
df = df[df["Dia Inclusão"].isin(filtro_dia)]
|
| 194 |
+
|
| 195 |
+
if filtro_osm:
|
| 196 |
+
df = df[df["OSM"].isin(filtro_osm)]
|
| 197 |
+
|
| 198 |
+
# Filtro de período (intervalo)
|
| 199 |
+
if isinstance(periodo, (list, tuple)) and len(periodo) == 2 and all(periodo):
|
| 200 |
+
data_inicio, data_fim = periodo
|
| 201 |
+
df = df[
|
| 202 |
+
(df["Data Coleta"] >= data_inicio) &
|
| 203 |
+
(df["Data Coleta"] <= data_fim)
|
| 204 |
+
]
|
| 205 |
+
|
| 206 |
+
# -----------------------------------------------------
|
| 207 |
+
# Filtro de Nota Fiscal (texto e/ou multiselect)
|
| 208 |
+
# -----------------------------------------------------
|
| 209 |
+
# Consolida as notas informadas via texto (separadas por vírgula)
|
| 210 |
+
notas_texto = []
|
| 211 |
+
if nota_input_text.strip():
|
| 212 |
+
notas_texto = [x.strip() for x in nota_input_text.split(",") if x.strip()]
|
| 213 |
+
|
| 214 |
+
# Concatena com o multiselect (transformando em string)
|
| 215 |
+
notas_escolhidas = set([str(x) for x in filtro_nf_multi] + [str(x) for x in notas_texto])
|
| 216 |
+
|
| 217 |
+
if notas_escolhidas:
|
| 218 |
+
# Comparar sempre como string para evitar problemas com zeros à esquerda ou tipos heterogêneos
|
| 219 |
+
df = df[df["Nota Fiscal"].astype(str).isin(notas_escolhidas)]
|
| 220 |
+
|
| 221 |
+
# =====================================================
|
| 222 |
+
# 🧭 SINALIZA DUPLICIDADE DE NOTA FISCAL
|
| 223 |
+
# =====================================================
|
| 224 |
+
# Conta ocorrências por número (string) ignorando NaN
|
| 225 |
+
nf_series = df["Nota Fiscal"].astype(str).fillna("")
|
| 226 |
+
contagem_nf = nf_series.value_counts(dropna=False)
|
| 227 |
+
# Duplicadas são as que tem contagem > 1 (e não vazias)
|
| 228 |
+
notas_duplicadas = contagem_nf[(contagem_nf > 1) & (contagem_nf.index != "")]
|
| 229 |
+
|
| 230 |
+
# Coluna booleana marcando duplicidade no DF atual
|
| 231 |
+
df["Duplicidade Nota"] = df["Nota Fiscal"].astype(str).isin(notas_duplicadas.index)
|
| 232 |
+
|
| 233 |
+
# Aviso resumido
|
| 234 |
+
if len(notas_duplicadas) > 0:
|
| 235 |
+
st.warning(
|
| 236 |
+
f"⚠️ Foram encontradas **{int(notas_duplicadas.sum())}** ocorrências em **{len(notas_duplicadas)}** "
|
| 237 |
+
f"números de Nota Fiscal duplicados no resultado."
|
| 238 |
+
)
|
| 239 |
+
with st.expander("Ver lista de notas duplicadas"):
|
| 240 |
+
dup_df = pd.DataFrame({
|
| 241 |
+
"Nota Fiscal": notas_duplicadas.index,
|
| 242 |
+
"Ocorrências": notas_duplicadas.values
|
| 243 |
+
}).sort_values(by="Ocorrências", ascending=False)
|
| 244 |
+
st.dataframe(dup_df, use_container_width=True)
|
| 245 |
+
|
| 246 |
+
# Mostrar apenas duplicadas, caso marcado
|
| 247 |
+
if mostrar_apenas_duplicadas:
|
| 248 |
+
df = df[df["Duplicidade Nota"] == True]
|
| 249 |
+
|
| 250 |
+
# =====================================================
|
| 251 |
+
# 📊 RESULTADOS
|
| 252 |
+
# =====================================================
|
| 253 |
+
st.subheader("📊 Resultados")
|
| 254 |
+
st.caption("A coluna **Duplicidade Nota** indica se há mais de um registro com o mesmo número de Nota Fiscal no resultado atual.")
|
| 255 |
+
st.dataframe(df, use_container_width=True)
|
| 256 |
+
|
| 257 |
+
# =====================================================
|
| 258 |
+
# 📥 EXPORTAÇÃO EXCEL
|
| 259 |
+
# =====================================================
|
| 260 |
+
buffer = BytesIO()
|
| 261 |
+
with pd.ExcelWriter(buffer, engine="openpyxl") as writer:
|
| 262 |
+
df.to_excel(writer, index=False, sheet_name="Consulta")
|
| 263 |
+
|
| 264 |
+
buffer.seek(0)
|
| 265 |
+
|
| 266 |
+
st.download_button(
|
| 267 |
+
label="⬇️ Exportar para Excel",
|
| 268 |
+
data=buffer,
|
| 269 |
+
file_name="consulta_equipamentos.xlsx",
|
| 270 |
+
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
finally:
|
| 274 |
+
db.close()
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
|
db_admin.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# db_admin.py
|
| 3 |
+
import streamlit as st
|
| 4 |
+
import os
|
| 5 |
+
import shutil
|
| 6 |
+
from sqlalchemy import text
|
| 7 |
+
from banco import engine, SessionLocal
|
| 8 |
+
from utils_permissoes import verificar_permissao
|
| 9 |
+
from utils_auditoria import registrar_log
|
| 10 |
+
|
| 11 |
+
# =====================================================
|
| 12 |
+
# MÓDULO GERAL DE ADMINISTRAÇÃO DE BANCO (SCHEMA)
|
| 13 |
+
# =====================================================
|
| 14 |
+
# Objetivo:
|
| 15 |
+
# - Permitir adicionar, renomear, excluir e alterar tipo de colunas via UI
|
| 16 |
+
# - Funciona com SQLite, PostgreSQL e MySQL (com diferenças por dialeto)
|
| 17 |
+
# - Em SQLite, oferece reconstrução assistida quando DDL não é suportado
|
| 18 |
+
#
|
| 19 |
+
# Segurança e boas práticas:
|
| 20 |
+
# - Recomendado fazer backup antes de operações (botão disponível para SQLite)
|
| 21 |
+
# - Operações DDL são críticas: exigir confirmação explícita
|
| 22 |
+
# - Acesso restrito ao perfil "admin"
|
| 23 |
+
#
|
| 24 |
+
# Logs:
|
| 25 |
+
# - registrar_log(...) é chamado em todas as operações
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# -------------------------
|
| 29 |
+
# Utilitário: Dialeto e versão
|
| 30 |
+
# -------------------------
|
| 31 |
+
def _dialeto():
|
| 32 |
+
try:
|
| 33 |
+
return engine.url.get_backend_name()
|
| 34 |
+
except Exception:
|
| 35 |
+
return "desconhecido"
|
| 36 |
+
|
| 37 |
+
def _sqlite_version():
|
| 38 |
+
if _dialeto() != "sqlite":
|
| 39 |
+
return None
|
| 40 |
+
try:
|
| 41 |
+
with engine.begin() as conn:
|
| 42 |
+
rv = conn.execute(text("select sqlite_version()")).scalar()
|
| 43 |
+
return rv
|
| 44 |
+
except Exception:
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# -------------------------
|
| 49 |
+
# Utilitário: Listar tabelas e colunas
|
| 50 |
+
# -------------------------
|
| 51 |
+
def _listar_tabelas():
|
| 52 |
+
d = _dialeto()
|
| 53 |
+
with engine.begin() as conn:
|
| 54 |
+
if d == "sqlite":
|
| 55 |
+
rows = conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")).fetchall()
|
| 56 |
+
return [r[0] for r in rows]
|
| 57 |
+
else:
|
| 58 |
+
q = text("""
|
| 59 |
+
SELECT table_name
|
| 60 |
+
FROM information_schema.tables
|
| 61 |
+
WHERE table_schema NOT IN ('pg_catalog','information_schema')
|
| 62 |
+
ORDER BY table_name
|
| 63 |
+
""")
|
| 64 |
+
rows = conn.execute(q).fetchall()
|
| 65 |
+
return [r[0] for r in rows]
|
| 66 |
+
|
| 67 |
+
def _listar_colunas(tabela: str):
|
| 68 |
+
d = _dialeto()
|
| 69 |
+
with engine.begin() as conn:
|
| 70 |
+
if d == "sqlite":
|
| 71 |
+
rows = conn.execute(text(f"PRAGMA table_info({tabela})")).fetchall()
|
| 72 |
+
# PRAGMA: (cid, name, type, notnull, dflt_value, pk)
|
| 73 |
+
return [{"name": r[1], "type": r[2], "notnull": bool(r[3]), "default": r[4], "pk": bool(r[5])} for r in rows]
|
| 74 |
+
else:
|
| 75 |
+
q = text("""
|
| 76 |
+
SELECT column_name, data_type, is_nullable, column_default
|
| 77 |
+
FROM information_schema.columns
|
| 78 |
+
WHERE table_name = :tbl
|
| 79 |
+
ORDER BY ordinal_position
|
| 80 |
+
""")
|
| 81 |
+
rows = conn.execute(q, {"tbl": tabela}).fetchall()
|
| 82 |
+
return [{"name": r[0], "type": r[1], "notnull": (str(r[2]).upper() == "NO"), "default": r[3], "pk": False} for r in rows]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# -------------------------
|
| 86 |
+
# Backup rápido (SQLite)
|
| 87 |
+
# -------------------------
|
| 88 |
+
def _sqlite_backup():
|
| 89 |
+
if _dialeto() != "sqlite":
|
| 90 |
+
st.info("Backup automático só disponível para SQLite via cópia de arquivo.")
|
| 91 |
+
return
|
| 92 |
+
db_path = engine.url.database
|
| 93 |
+
if not db_path or not os.path.exists(db_path):
|
| 94 |
+
st.error("Arquivo de banco SQLite não encontrado.")
|
| 95 |
+
return
|
| 96 |
+
dest = db_path + ".bak"
|
| 97 |
+
shutil.copyfile(db_path, dest)
|
| 98 |
+
st.success(f"Backup criado: {dest}")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# -------------------------
|
| 102 |
+
# DDL: Gerar comandos por dialeto
|
| 103 |
+
# -------------------------
|
| 104 |
+
def _ddl_add_column_sql(tabela, col_nome, col_tipo, notnull=False, default=None):
|
| 105 |
+
d = _dialeto()
|
| 106 |
+
nn = "NOT NULL" if notnull else "NULL"
|
| 107 |
+
def_clause = f" DEFAULT {default}" if (default is not None and str(default).strip() != "") else ""
|
| 108 |
+
if d == "sqlite":
|
| 109 |
+
# SQLite aceita tipo textual; notnull e default são respeitados no schema
|
| 110 |
+
return f"ALTER TABLE {tabela} ADD COLUMN {col_nome} {col_tipo} {nn}{def_clause};"
|
| 111 |
+
elif d in ("postgresql", "postgres"):
|
| 112 |
+
base = f'ALTER TABLE "{tabela}" ADD COLUMN "{col_nome}" {col_tipo}'
|
| 113 |
+
if default is not None and str(default).strip() != "":
|
| 114 |
+
base += f" DEFAULT {default}"
|
| 115 |
+
if notnull:
|
| 116 |
+
base += " NOT NULL"
|
| 117 |
+
return base + ";"
|
| 118 |
+
elif d in ("mysql", "mariadb"):
|
| 119 |
+
base = f"ALTER TABLE `{tabela}` ADD COLUMN `{col_nome}` {col_tipo}"
|
| 120 |
+
if default is not None and str(default).strip() != "":
|
| 121 |
+
base += f" DEFAULT {default}"
|
| 122 |
+
base += " NOT NULL" if notnull else " NULL"
|
| 123 |
+
return base + ";"
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
def _ddl_rename_column_sql(tabela, old, new):
|
| 127 |
+
d = _dialeto()
|
| 128 |
+
if d == "sqlite":
|
| 129 |
+
return f"ALTER TABLE {tabela} RENAME COLUMN {old} TO {new};"
|
| 130 |
+
elif d in ("postgresql", "postgres"):
|
| 131 |
+
return f'ALTER TABLE "{tabela}" RENAME COLUMN "{old}" TO "{new}";'
|
| 132 |
+
elif d in ("mysql", "mariadb"):
|
| 133 |
+
# MySQL requer tipo na renomeação; esta função não cobre tipo -> usar CHANGE COLUMN via UI de "Alterar tipo/renomear"
|
| 134 |
+
return None
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
+
def _ddl_drop_column_sql(tabela, col):
|
| 138 |
+
d = _dialeto()
|
| 139 |
+
if d == "sqlite":
|
| 140 |
+
return f"ALTER TABLE {tabela} DROP COLUMN {col};"
|
| 141 |
+
elif d in ("postgresql", "postgres"):
|
| 142 |
+
return f'ALTER TABLE "{tabela}" DROP COLUMN "{col}";'
|
| 143 |
+
elif d in ("mysql", "mariadb"):
|
| 144 |
+
return f"ALTER TABLE `{tabela}` DROP COLUMN `{col}`;"
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
def _ddl_alter_type_sql(tabela, col, new_type):
|
| 148 |
+
d = _dialeto()
|
| 149 |
+
if d == "sqlite":
|
| 150 |
+
# SQLite não altera type declarado via ALTER TYPE. Necessário reconstruir tabela.
|
| 151 |
+
return None
|
| 152 |
+
elif d in ("postgresql", "postgres"):
|
| 153 |
+
return f'ALTER TABLE "{tabela}" ALTER COLUMN "{col}" TYPE {new_type};'
|
| 154 |
+
elif d in ("mysql", "mariadb"):
|
| 155 |
+
return f"ALTER TABLE `{tabela}` MODIFY COLUMN `{col}` {new_type};"
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# -------------------------
|
| 160 |
+
# Reconstrução assistida (SQLite)
|
| 161 |
+
# -------------------------
|
| 162 |
+
def _sqlite_reconstruir_tabela(tabela, novas_colunas):
|
| 163 |
+
"""
|
| 164 |
+
Reconstrói tabela SQLite com "novas_colunas" (lista de dicts):
|
| 165 |
+
[{"name":..., "type":..., "notnull":bool, "default":..., "pk":bool}, ...]
|
| 166 |
+
- Cria tabela __tmp_<tabela> com o novo schema
|
| 167 |
+
- Copia dados das colunas compatíveis (mesmos nomes)
|
| 168 |
+
- Drop da tabela original e rename da temporária
|
| 169 |
+
"""
|
| 170 |
+
cols_def = []
|
| 171 |
+
copy_cols = []
|
| 172 |
+
pk_cols = [c["name"] for c in novas_colunas if c.get("pk")]
|
| 173 |
+
for c in novas_colunas:
|
| 174 |
+
nn = "NOT NULL" if c.get("notnull") else ""
|
| 175 |
+
default = c.get("default")
|
| 176 |
+
def_clause = f" DEFAULT {default}" if (default is not None and str(default).strip() != "") else ""
|
| 177 |
+
cols_def.append(f'{c["name"]} {c["type"]} {nn}{def_clause}'.strip())
|
| 178 |
+
copy_cols.append(c["name"])
|
| 179 |
+
pk_clause = f", PRIMARY KEY ({', '.join(pk_cols)})" if pk_cols else ""
|
| 180 |
+
|
| 181 |
+
create_sql = f"CREATE TABLE __tmp_{tabela} ({', '.join(cols_def)}{pk_clause});"
|
| 182 |
+
copy_sql = f"INSERT INTO __tmp_{tabela} ({', '.join(copy_cols)}) SELECT {', '.join(copy_cols)} FROM {tabela};"
|
| 183 |
+
drop_sql = f"DROP TABLE {tabela};"
|
| 184 |
+
rename_sql= f"ALTER TABLE __tmp_{tabela} RENAME TO {tabela};"
|
| 185 |
+
|
| 186 |
+
with engine.begin() as conn:
|
| 187 |
+
conn.execute(text(create_sql))
|
| 188 |
+
conn.execute(text(copy_sql))
|
| 189 |
+
conn.execute(text(drop_sql))
|
| 190 |
+
conn.execute(text(rename_sql))
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# -------------------------
|
| 194 |
+
# UI principal (admin)
|
| 195 |
+
# -------------------------
|
| 196 |
+
def main():
|
| 197 |
+
st.title("🛠️ Administração de Banco (Schema)")
|
| 198 |
+
|
| 199 |
+
# 🔐 Proteção por perfil
|
| 200 |
+
if not verificar_permissao("db_admin") and st.session_state.get("perfil") != "admin":
|
| 201 |
+
st.error("⛔ Acesso não autorizado.")
|
| 202 |
+
return
|
| 203 |
+
|
| 204 |
+
# Info do banco
|
| 205 |
+
dial = _dialeto()
|
| 206 |
+
st.caption(f"Dialeto: **{dial}**")
|
| 207 |
+
ver = _sqlite_version()
|
| 208 |
+
if ver:
|
| 209 |
+
st.caption(f"SQLite version: **{ver}**")
|
| 210 |
+
|
| 211 |
+
# Backup (SQLite)
|
| 212 |
+
if dial == "sqlite":
|
| 213 |
+
if st.button("💾 Backup rápido (SQLite)"):
|
| 214 |
+
_sqlite_backup()
|
| 215 |
+
|
| 216 |
+
# Tabelas disponíveis
|
| 217 |
+
tabelas = _listar_tabelas()
|
| 218 |
+
if not tabelas:
|
| 219 |
+
st.warning("Nenhuma tabela encontrada.")
|
| 220 |
+
return
|
| 221 |
+
|
| 222 |
+
tabela = st.selectbox("Tabela alvo:", tabelas, index=0)
|
| 223 |
+
colunas = _listar_colunas(tabela)
|
| 224 |
+
|
| 225 |
+
st.divider()
|
| 226 |
+
st.subheader("📋 Colunas atuais")
|
| 227 |
+
st.write(pd.DataFrame(colunas)) if 'pd' in globals() else st.write(colunas) # mostra estrutura atual
|
| 228 |
+
|
| 229 |
+
st.divider()
|
| 230 |
+
tabs = st.tabs(["➕ Adicionar coluna", "✏️ Renomear coluna", "🗑️ Excluir coluna", "♻️ Alterar tipo"])
|
| 231 |
+
|
| 232 |
+
# ----------------- Adicionar coluna -----------------
|
| 233 |
+
with tabs[0]:
|
| 234 |
+
st.markdown("**Adicionar uma nova coluna à tabela selecionada**")
|
| 235 |
+
novo_nome = st.text_input("Nome da nova coluna")
|
| 236 |
+
novo_tipo = st.text_input("Tipo (ex.: TEXT, INTEGER, VARCHAR(255))")
|
| 237 |
+
novo_notnull = st.checkbox("NOT NULL", value=False)
|
| 238 |
+
novo_default = st.text_input("DEFAULT (opcional)")
|
| 239 |
+
|
| 240 |
+
confirmar_add = st.checkbox("Confirmo a adição desta coluna (DDL).")
|
| 241 |
+
if st.button("Executar ADD COLUMN", type="primary") and confirmar_add:
|
| 242 |
+
sql = _ddl_add_column_sql(tabela, novo_nome, novo_tipo, notnull=novo_notnull, default=novo_default)
|
| 243 |
+
if not sql:
|
| 244 |
+
st.error("Dialeto não suportado para ADD COLUMN.")
|
| 245 |
+
else:
|
| 246 |
+
try:
|
| 247 |
+
with engine.begin() as conn:
|
| 248 |
+
conn.execute(text(sql))
|
| 249 |
+
registrar_log(st.session_state.get("usuario"), f"ADD COLUMN {novo_nome} {novo_tipo} em {tabela}", "schema", None)
|
| 250 |
+
st.success("✅ Coluna adicionada com sucesso.")
|
| 251 |
+
st.rerun()
|
| 252 |
+
except Exception as e:
|
| 253 |
+
st.error(f"Erro ao adicionar coluna: {e}")
|
| 254 |
+
|
| 255 |
+
# ----------------- Renomear coluna -----------------
|
| 256 |
+
with tabs[1]:
|
| 257 |
+
st.markdown("**Renomear uma coluna existente**")
|
| 258 |
+
col_nomes = [c["name"] for c in colunas]
|
| 259 |
+
antigo = st.selectbox("Coluna atual:", col_nomes) if col_nomes else ""
|
| 260 |
+
novo = st.text_input("Novo nome da coluna")
|
| 261 |
+
|
| 262 |
+
confirmar_ren = st.checkbox("Confirmo a renomeação desta coluna (DDL).")
|
| 263 |
+
if st.button("Executar RENAME COLUMN") and confirmar_ren:
|
| 264 |
+
d = _dialeto()
|
| 265 |
+
if d == "sqlite":
|
| 266 |
+
# Verifica suporte na versão
|
| 267 |
+
ver = _sqlite_version() or "0.0.0"
|
| 268 |
+
suportado = tuple(map(int, ver.split("."))) >= (3, 25, 0)
|
| 269 |
+
if suportado:
|
| 270 |
+
sql = _ddl_rename_column_sql(tabela, antigo, novo)
|
| 271 |
+
try:
|
| 272 |
+
with engine.begin() as conn:
|
| 273 |
+
conn.execute(text(sql))
|
| 274 |
+
registrar_log(st.session_state.get("usuario"), f"RENAME COLUMN {antigo}→{novo} em {tabela}", "schema", None)
|
| 275 |
+
st.success("✅ Coluna renomeada com sucesso.")
|
| 276 |
+
st.rerun()
|
| 277 |
+
except Exception as e:
|
| 278 |
+
st.error(f"Erro ao renomear: {e}")
|
| 279 |
+
else:
|
| 280 |
+
st.warning("SQLite < 3.25 não suporta RENAME COLUMN. Oferecendo reconstrução assistida.")
|
| 281 |
+
# Reconstrução: atualiza metadados e recria tabela
|
| 282 |
+
novas = []
|
| 283 |
+
for c in colunas:
|
| 284 |
+
nm = novo if c["name"] == antigo else c["name"]
|
| 285 |
+
novas.append({"name": nm, "type": c["type"], "notnull": c["notnull"], "default": c["default"], "pk": c["pk"]})
|
| 286 |
+
try:
|
| 287 |
+
_sqlite_reconstruir_tabela(tabela, novas)
|
| 288 |
+
registrar_log(st.session_state.get("usuario"), f"RENAME (rebuild) {antigo}→{novo} em {tabela}", "schema", None)
|
| 289 |
+
st.success("✅ Reconstrução concluída com sucesso.")
|
| 290 |
+
st.rerun()
|
| 291 |
+
except Exception as e:
|
| 292 |
+
st.error(f"Erro na reconstrução: {e}")
|
| 293 |
+
elif d in ("postgresql", "postgres"):
|
| 294 |
+
sql = _ddl_rename_column_sql(tabela, antigo, novo)
|
| 295 |
+
if not sql:
|
| 296 |
+
st.error("Renomeação não suportada.")
|
| 297 |
+
else:
|
| 298 |
+
try:
|
| 299 |
+
with engine.begin() as conn:
|
| 300 |
+
conn.execute(text(sql))
|
| 301 |
+
registrar_log(st.session_state.get("usuario"), f"RENAME COLUMN {antigo}→{novo} em {tabela}", "schema", None)
|
| 302 |
+
st.success("✅ Coluna renomeada com sucesso.")
|
| 303 |
+
st.rerun()
|
| 304 |
+
except Exception as e:
|
| 305 |
+
st.error(f"Erro ao renomear: {e}")
|
| 306 |
+
elif d in ("mysql", "mariadb"):
|
| 307 |
+
st.info("MySQL/MariaDB exigem 'CHANGE COLUMN' informando o novo tipo; use a aba 'Alterar tipo' para renomear junto com tipo.")
|
| 308 |
+
|
| 309 |
+
# ----------------- Excluir coluna -----------------
|
| 310 |
+
with tabs[2]:
|
| 311 |
+
st.markdown("**Excluir uma coluna existente**")
|
| 312 |
+
col_nomes = [c["name"] for c in colunas]
|
| 313 |
+
col_drop = st.selectbox("Coluna a excluir:", col_nomes) if col_nomes else ""
|
| 314 |
+
|
| 315 |
+
confirmar_drop = st.checkbox("Confirmo a exclusão desta coluna (DDL) e entendo que é irreversível.")
|
| 316 |
+
if st.button("Executar DROP COLUMN", type="secondary") and confirmar_drop:
|
| 317 |
+
d = _dialeto()
|
| 318 |
+
if d == "sqlite":
|
| 319 |
+
ver = _sqlite_version() or "0.0.0"
|
| 320 |
+
suportado = tuple(map(int, ver.split("."))) >= (3, 35, 0)
|
| 321 |
+
if suportado:
|
| 322 |
+
sql = _ddl_drop_column_sql(tabela, col_drop)
|
| 323 |
+
try:
|
| 324 |
+
with engine.begin() as conn:
|
| 325 |
+
conn.execute(text(sql))
|
| 326 |
+
registrar_log(st.session_state.get("usuario"), f"DROP COLUMN {col_drop} em {tabela}", "schema", None)
|
| 327 |
+
st.success("✅ Coluna excluída com sucesso.")
|
| 328 |
+
st.rerun()
|
| 329 |
+
except Exception as e:
|
| 330 |
+
st.error(f"Erro ao excluir: {e}")
|
| 331 |
+
else:
|
| 332 |
+
st.warning("SQLite < 3.35 não suporta DROP COLUMN. Oferecendo reconstrução assistida.")
|
| 333 |
+
novas = [c for c in colunas if c["name"] != col_drop]
|
| 334 |
+
try:
|
| 335 |
+
_sqlite_reconstruir_tabela(tabela, novas)
|
| 336 |
+
registrar_log(st.session_state.get("usuario"), f"DROP (rebuild) {col_drop} em {tabela}", "schema", None)
|
| 337 |
+
st.success("✅ Reconstrução concluída e coluna removida.")
|
| 338 |
+
st.rerun()
|
| 339 |
+
except Exception as e:
|
| 340 |
+
st.error(f"Erro na reconstrução: {e}")
|
| 341 |
+
elif d in ("postgresql", "postgres", "mysql", "mariadb"):
|
| 342 |
+
sql = _ddl_drop_column_sql(tabela, col_drop)
|
| 343 |
+
try:
|
| 344 |
+
with engine.begin() as conn:
|
| 345 |
+
conn.execute(text(sql))
|
| 346 |
+
registrar_log(st.session_state.get("usuario"), f"DROP COLUMN {col_drop} em {tabela}", "schema", None)
|
| 347 |
+
st.success("✅ Coluna excluída com sucesso.")
|
| 348 |
+
st.rerun()
|
| 349 |
+
except Exception as e:
|
| 350 |
+
st.error(f"Erro ao excluir: {e}")
|
| 351 |
+
|
| 352 |
+
# ----------------- Alterar tipo -----------------
|
| 353 |
+
with tabs[3]:
|
| 354 |
+
st.markdown("**Alterar tipo declarado de uma coluna**")
|
| 355 |
+
col_nomes = [c["name"] for c in colunas]
|
| 356 |
+
alvo = st.selectbox("Coluna alvo:", col_nomes) if col_nomes else ""
|
| 357 |
+
novo_tipo = st.text_input("Novo tipo (ex.: TEXT, INTEGER, VARCHAR(255))")
|
| 358 |
+
|
| 359 |
+
confirmar_type = st.checkbox("Confirmo a alteração de tipo (DDL).")
|
| 360 |
+
if st.button("Executar ALTER TYPE") and confirmar_type:
|
| 361 |
+
d = _dialeto()
|
| 362 |
+
if d == "sqlite":
|
| 363 |
+
st.warning("SQLite não suporta ALTER TYPE direto; oferecemos reconstrução assistida.")
|
| 364 |
+
novas = []
|
| 365 |
+
for c in colunas:
|
| 366 |
+
typ = novo_tipo if c["name"] == alvo else c["type"]
|
| 367 |
+
novas.append({"name": c["name"], "type": typ, "notnull": c["notnull"], "default": c["default"], "pk": c["pk"]})
|
| 368 |
+
try:
|
| 369 |
+
_sqlite_reconstruir_tabela(tabela, novas)
|
| 370 |
+
registrar_log(st.session_state.get("usuario"), f"ALTER TYPE (rebuild) {alvo}→{novo_tipo} em {tabela}", "schema", None)
|
| 371 |
+
st.success("✅ Tipo alterado com sucesso via reconstrução.")
|
| 372 |
+
st.rerun()
|
| 373 |
+
except Exception as e:
|
| 374 |
+
st.error(f"Erro na reconstrução: {e}")
|
| 375 |
+
elif d in ("postgresql", "postgres", "mysql", "mariadb"):
|
| 376 |
+
sql = _ddl_alter_type_sql(tabela, alvo, novo_tipo)
|
| 377 |
+
if not sql:
|
| 378 |
+
st.error("Dialeto não suportado para ALTER TYPE.")
|
| 379 |
+
else:
|
| 380 |
+
try:
|
| 381 |
+
with engine.begin() as conn:
|
| 382 |
+
conn.execute(text(sql))
|
| 383 |
+
registrar_log(st.session_state.get("usuario"), f"ALTER TYPE {alvo}→{novo_tipo} em {tabela}", "schema", None)
|
| 384 |
+
st.success("✅ Tipo alterado com sucesso.")
|
| 385 |
+
st.rerun()
|
| 386 |
+
except Exception as e:
|
| 387 |
+
st.error(f"Erro ao alterar tipo: {e}")
|
db_export_import.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
"""
|
| 4 |
+
db_export_import.py — Backup & Restore (Export/Import) do banco ATIVO (Produção/Teste)
|
| 5 |
+
|
| 6 |
+
Recursos:
|
| 7 |
+
• Exibe banco ativo (prod/test) e URL do engine
|
| 8 |
+
• Exporta todas as tabelas para:
|
| 9 |
+
- ZIP (CSV por tabela + manifest.json)
|
| 10 |
+
- Excel (.xlsx) (1 aba por tabela + manifest sheet)
|
| 11 |
+
• Importa (upload) de:
|
| 12 |
+
- ZIP (CSV por tabela)
|
| 13 |
+
- Excel (.xlsx)
|
| 14 |
+
• Modos de import: APPEND ou REPLACE (cuidado com FK)
|
| 15 |
+
• Snapshot físico para SQLite: cópia do arquivo (.db) — backup/restore rápido
|
| 16 |
+
|
| 17 |
+
Dependências:
|
| 18 |
+
- pandas, openpyxl, sqlalchemy, zipfile, io, json, datetime
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import os
|
| 22 |
+
import io
|
| 23 |
+
import json
|
| 24 |
+
import zipfile
|
| 25 |
+
from datetime import datetime
|
| 26 |
+
|
| 27 |
+
import streamlit as st
|
| 28 |
+
import pandas as pd
|
| 29 |
+
from sqlalchemy import inspect, text
|
| 30 |
+
|
| 31 |
+
from banco import get_engine, db_info, SessionLocal
|
| 32 |
+
from utils_auditoria import registrar_log
|
| 33 |
+
|
| 34 |
+
# Ambiente (prod/test) — se db_router não existir, fallback para 'prod'
|
| 35 |
+
try:
|
| 36 |
+
from db_router import current_db_choice
|
| 37 |
+
_HAS_ROUTER = True
|
| 38 |
+
except Exception:
|
| 39 |
+
_HAS_ROUTER = False
|
| 40 |
+
|
| 41 |
+
def current_db_choice() -> str:
|
| 42 |
+
return "prod"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# =========================
|
| 46 |
+
# Helpers: tabelas e I/O
|
| 47 |
+
# =========================
|
| 48 |
+
def list_tables(engine) -> list[str]:
|
| 49 |
+
"""Retorna nomes de todas as tabelas via SQLAlchemy inspection."""
|
| 50 |
+
inspector = inspect(engine)
|
| 51 |
+
return inspector.get_table_names()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _read_table_df(engine, table_name: str) -> pd.DataFrame:
|
| 55 |
+
"""Lê toda a tabela como DataFrame."""
|
| 56 |
+
try:
|
| 57 |
+
# pandas + SQLAlchemy: lê tabela diretamente
|
| 58 |
+
return pd.read_sql_table(table_name, con=engine)
|
| 59 |
+
except Exception:
|
| 60 |
+
# fallback: SELECT com aspas (útil para SQLite com nomes case-sensitive)
|
| 61 |
+
return pd.read_sql(f'SELECT * FROM "{table_name}"', con=engine)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _write_table_df(engine, table_name: str, df: pd.DataFrame, mode: str = "append"):
|
| 65 |
+
"""
|
| 66 |
+
Escreve DataFrame em tabela.
|
| 67 |
+
mode: "append" (adiciona) ou "replace" (sobrescreve todos os dados).
|
| 68 |
+
Observação: nos fluxos de import, quando 'replace' foi selecionado,
|
| 69 |
+
todas as tabelas são truncadas previamente, e aqui usamos 'append'.
|
| 70 |
+
"""
|
| 71 |
+
if mode not in ("append", "replace"):
|
| 72 |
+
mode = "append"
|
| 73 |
+
df.to_sql(table_name, con=engine, if_exists=mode, index=False)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# =========================
|
| 77 |
+
# Export: ZIP (CSV) & Excel
|
| 78 |
+
# =========================
|
| 79 |
+
def export_zip(engine, ambiente: str) -> bytes:
|
| 80 |
+
"""
|
| 81 |
+
Exporta todas as tabelas para um ZIP:
|
| 82 |
+
- 1 CSV por tabela (UTF-8-BOM)
|
| 83 |
+
- manifest.json com metadados (ambiente, timestamp, url, tabelas)
|
| 84 |
+
"""
|
| 85 |
+
tables = list_tables(engine)
|
| 86 |
+
buf = io.BytesIO()
|
| 87 |
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
| 88 |
+
for t in tables:
|
| 89 |
+
df = _read_table_df(engine, t)
|
| 90 |
+
csv_bytes = df.to_csv(index=False, encoding="utf-8-sig").encode("utf-8-sig")
|
| 91 |
+
z.writestr(f"{t}.csv", csv_bytes)
|
| 92 |
+
|
| 93 |
+
manifest = {
|
| 94 |
+
"ambiente": ambiente,
|
| 95 |
+
"timestamp": datetime.now().isoformat(),
|
| 96 |
+
"engine_url": str(engine.url),
|
| 97 |
+
"tables": tables,
|
| 98 |
+
"format": "zip/csv",
|
| 99 |
+
"version": "1.0",
|
| 100 |
+
}
|
| 101 |
+
z.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
| 102 |
+
buf.seek(0)
|
| 103 |
+
return buf.getvalue()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def export_excel(engine, ambiente: str) -> bytes:
|
| 107 |
+
"""
|
| 108 |
+
Exporta todas as tabelas para um Excel (.xlsx):
|
| 109 |
+
- 1 aba por tabela (limitada a 31 caracteres)
|
| 110 |
+
- "manifest" com metadados
|
| 111 |
+
"""
|
| 112 |
+
tables = list_tables(engine)
|
| 113 |
+
buf = io.BytesIO()
|
| 114 |
+
with pd.ExcelWriter(buf, engine="openpyxl") as writer:
|
| 115 |
+
# manifest
|
| 116 |
+
manifest = pd.DataFrame([{
|
| 117 |
+
"ambiente": ambiente,
|
| 118 |
+
"timestamp": datetime.now().isoformat(),
|
| 119 |
+
"engine_url": str(engine.url),
|
| 120 |
+
"tables": ", ".join(tables),
|
| 121 |
+
"format": "xlsx",
|
| 122 |
+
"version": "1.0",
|
| 123 |
+
}])
|
| 124 |
+
manifest.to_excel(writer, sheet_name="manifest", index=False)
|
| 125 |
+
|
| 126 |
+
# tabelas → 1 aba por tabela
|
| 127 |
+
for t in tables:
|
| 128 |
+
df = _read_table_df(engine, t)
|
| 129 |
+
sheet = t[:31] if len(t) > 31 else t
|
| 130 |
+
df.to_excel(writer, sheet_name=sheet, index=False)
|
| 131 |
+
buf.seek(0)
|
| 132 |
+
return buf.getvalue()
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# =========================
|
| 136 |
+
# Import: ZIP (CSV) & Excel
|
| 137 |
+
# =========================
|
| 138 |
+
def import_zip(engine, file_bytes: bytes, mode: str = "append") -> dict:
|
| 139 |
+
"""
|
| 140 |
+
Importa dados de um ZIP (CSV por tabela).
|
| 141 |
+
mode: "append" ou "replace".
|
| 142 |
+
Retorna um relatório {table: {"rows": int, "mode": str}}.
|
| 143 |
+
"""
|
| 144 |
+
report = {}
|
| 145 |
+
zbuf = io.BytesIO(file_bytes)
|
| 146 |
+
with zipfile.ZipFile(zbuf, "r") as z:
|
| 147 |
+
# Se replace, limpar tabelas (cuidado com FK)
|
| 148 |
+
if mode == "replace":
|
| 149 |
+
_truncate_all(engine)
|
| 150 |
+
|
| 151 |
+
for name in z.namelist():
|
| 152 |
+
if not name.lower().endswith(".csv"):
|
| 153 |
+
continue
|
| 154 |
+
table = os.path.splitext(os.path.basename(name))[0]
|
| 155 |
+
csv_bytes = z.read(name)
|
| 156 |
+
df = pd.read_csv(io.BytesIO(csv_bytes), dtype=str) # dtype=str para evitar coercões agressivas
|
| 157 |
+
# Conversões leves de datetime (best-effort)
|
| 158 |
+
for col in df.columns:
|
| 159 |
+
if "data" in col.lower() or "date" in col.lower():
|
| 160 |
+
try:
|
| 161 |
+
df[col] = pd.to_datetime(df[col], errors="ignore")
|
| 162 |
+
except Exception:
|
| 163 |
+
pass
|
| 164 |
+
# replace já truncou; aqui fazemos append
|
| 165 |
+
_write_table_df(engine, table, df, mode="append")
|
| 166 |
+
report[table] = {"rows": int(len(df)), "mode": mode}
|
| 167 |
+
return report
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def import_excel(engine, file_bytes: bytes, mode: str = "append") -> dict:
|
| 171 |
+
"""
|
| 172 |
+
Importa dados de um Excel (.xlsx) com múltiplas abas (1 por tabela).
|
| 173 |
+
mode: "append" ou "replace".
|
| 174 |
+
"""
|
| 175 |
+
report = {}
|
| 176 |
+
xbuf = io.BytesIO(file_bytes)
|
| 177 |
+
xls = pd.ExcelFile(xbuf, engine="openpyxl")
|
| 178 |
+
sheets = [s for s in xls.sheet_names if s.lower() != "manifest"]
|
| 179 |
+
|
| 180 |
+
if mode == "replace":
|
| 181 |
+
_truncate_all(engine)
|
| 182 |
+
|
| 183 |
+
for sheet in sheets:
|
| 184 |
+
df = xls.parse(sheet_name=sheet, dtype=str)
|
| 185 |
+
# best-effort para datas
|
| 186 |
+
for col in df.columns:
|
| 187 |
+
if "data" in col.lower() or "date" in col.lower():
|
| 188 |
+
try:
|
| 189 |
+
df[col] = pd.to_datetime(df[col], errors="ignore")
|
| 190 |
+
except Exception:
|
| 191 |
+
pass
|
| 192 |
+
table = sheet
|
| 193 |
+
_write_table_df(engine, table, df, mode="append")
|
| 194 |
+
report[table] = {"rows": int(len(df)), "mode": mode}
|
| 195 |
+
return report
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# =========================
|
| 199 |
+
# Truncate (REPLACE mode)
|
| 200 |
+
# =========================
|
| 201 |
+
def _truncate_all(engine):
|
| 202 |
+
"""
|
| 203 |
+
Limpa todas as tabelas do banco ativo (cuidado!).
|
| 204 |
+
• Para SQLite: desabilita FK temporariamente, apaga, e reabilita.
|
| 205 |
+
• Para outros bancos: executa DELETE tabela; considere ordem por FK se necessário.
|
| 206 |
+
"""
|
| 207 |
+
insp = inspect(engine)
|
| 208 |
+
tables = insp.get_table_names()
|
| 209 |
+
|
| 210 |
+
with engine.begin() as conn:
|
| 211 |
+
url = str(engine.url)
|
| 212 |
+
is_sqlite = url.startswith("sqlite")
|
| 213 |
+
if is_sqlite:
|
| 214 |
+
conn.execute(text("PRAGMA foreign_keys=OFF"))
|
| 215 |
+
|
| 216 |
+
# Apaga conteúdo (sem considerar ordem de FK — OK para SQLite com FK OFF)
|
| 217 |
+
for t in tables:
|
| 218 |
+
conn.execute(text(f'DELETE FROM "{t}"'))
|
| 219 |
+
|
| 220 |
+
if is_sqlite:
|
| 221 |
+
conn.execute(text("PRAGMA foreign_keys=ON"))
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# =========================
|
| 225 |
+
# Snapshot físico (SQLite)
|
| 226 |
+
# =========================
|
| 227 |
+
def snapshot_sqlite(engine, ambiente: str) -> bytes:
|
| 228 |
+
"""
|
| 229 |
+
Cria um snapshot (cópia física) do arquivo SQLite do banco ativo.
|
| 230 |
+
Retorna o conteúdo do arquivo para download.
|
| 231 |
+
"""
|
| 232 |
+
url = str(engine.url)
|
| 233 |
+
if not url.startswith("sqlite:///"):
|
| 234 |
+
raise RuntimeError("Snapshot físico disponível apenas para SQLite.")
|
| 235 |
+
db_path = url.replace("sqlite:///", "")
|
| 236 |
+
if not os.path.isfile(db_path):
|
| 237 |
+
raise FileNotFoundError(f"Arquivo SQLite não encontrado: {db_path}")
|
| 238 |
+
|
| 239 |
+
with open(db_path, "rb") as f:
|
| 240 |
+
data = f.read()
|
| 241 |
+
|
| 242 |
+
# auditoria
|
| 243 |
+
try:
|
| 244 |
+
registrar_log(usuario=st.session_state.get("usuario"),
|
| 245 |
+
acao=f"Snapshot SQLite ({ambiente})",
|
| 246 |
+
tabela="backup",
|
| 247 |
+
registro_id=None)
|
| 248 |
+
except Exception:
|
| 249 |
+
pass
|
| 250 |
+
|
| 251 |
+
return data
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# =========================
|
| 255 |
+
# UI (Streamlit)
|
| 256 |
+
# =========================
|
| 257 |
+
def main():
|
| 258 |
+
st.title("🗄️ Backup & Restore | Export/Import de Banco")
|
| 259 |
+
|
| 260 |
+
# Banco ativo e info
|
| 261 |
+
ambiente = current_db_choice()
|
| 262 |
+
info = db_info()
|
| 263 |
+
st.caption(f"🧭 Ambiente: {'Produção' if ambiente == 'prod' else 'Teste'}")
|
| 264 |
+
st.caption(f"🔗 Engine URL: {info.get('url')}")
|
| 265 |
+
|
| 266 |
+
engine = get_engine()
|
| 267 |
+
|
| 268 |
+
st.divider()
|
| 269 |
+
st.subheader("⬇️ Exportar dados")
|
| 270 |
+
|
| 271 |
+
colA, colB, colC = st.columns(3)
|
| 272 |
+
with colA:
|
| 273 |
+
if st.button("Exportar ZIP (CSV por tabela)", type="primary"):
|
| 274 |
+
try:
|
| 275 |
+
zip_bytes = export_zip(engine, ambiente)
|
| 276 |
+
fname = f"backup_{ambiente}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
|
| 277 |
+
st.download_button("📥 Baixar ZIP", data=zip_bytes, file_name=fname, mime="application/zip")
|
| 278 |
+
registrar_log(usuario=st.session_state.get("usuario"),
|
| 279 |
+
acao=f"Export ZIP (ambiente={ambiente})",
|
| 280 |
+
tabela="backup", registro_id=None)
|
| 281 |
+
except Exception as e:
|
| 282 |
+
st.error(f"Falha ao exportar ZIP: {e}")
|
| 283 |
+
|
| 284 |
+
with colB:
|
| 285 |
+
if st.button("Exportar Excel (.xlsx)", type="primary"):
|
| 286 |
+
try:
|
| 287 |
+
xlsx_bytes = export_excel(engine, ambiente)
|
| 288 |
+
fname = f"backup_{ambiente}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
| 289 |
+
st.download_button("📥 Baixar Excel", data=xlsx_bytes, file_name=fname,
|
| 290 |
+
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
| 291 |
+
registrar_log(usuario=st.session_state.get("usuario"),
|
| 292 |
+
acao=f"Export XLSX (ambiente={ambiente})",
|
| 293 |
+
tabela="backup", registro_id=None)
|
| 294 |
+
except Exception as e:
|
| 295 |
+
st.error(f"Falha ao exportar Excel: {e}")
|
| 296 |
+
|
| 297 |
+
with colC:
|
| 298 |
+
# Snapshot físico apenas para SQLite
|
| 299 |
+
url = str(engine.url)
|
| 300 |
+
if url.startswith("sqlite:///"):
|
| 301 |
+
if st.button("Snapshot físico (SQLite)", type="secondary"):
|
| 302 |
+
try:
|
| 303 |
+
snap_bytes = snapshot_sqlite(engine, ambiente)
|
| 304 |
+
fname = f"snapshot_{ambiente}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
|
| 305 |
+
st.download_button("📥 Baixar Snapshot (.db)", data=snap_bytes, file_name=fname, mime="application/octet-stream")
|
| 306 |
+
except Exception as e:
|
| 307 |
+
st.error(f"Falha ao criar snapshot: {e}")
|
| 308 |
+
else:
|
| 309 |
+
st.caption("ℹ️ Snapshot físico disponível apenas para SQLite.")
|
| 310 |
+
|
| 311 |
+
st.divider()
|
| 312 |
+
st.subheader("⬆️ Importar dados")
|
| 313 |
+
|
| 314 |
+
mode = st.radio("Modo de importação:", ["APPEND (adicionar)", "REPLACE (substituir tudo)"], horizontal=True)
|
| 315 |
+
mode_val = "append" if "APPEND" in mode else "replace"
|
| 316 |
+
|
| 317 |
+
up_col1, up_col2 = st.columns(2)
|
| 318 |
+
with up_col1:
|
| 319 |
+
zip_file = st.file_uploader("Upload ZIP (CSV por tabela)", type=["zip"])
|
| 320 |
+
if zip_file is not None and st.button("Importar do ZIP", type="primary"):
|
| 321 |
+
try:
|
| 322 |
+
report = import_zip(engine, zip_file.read(), mode=mode_val)
|
| 323 |
+
st.success(f"Import ZIP concluído ({mode_val}).")
|
| 324 |
+
st.json(report)
|
| 325 |
+
registrar_log(usuario=st.session_state.get("usuario"),
|
| 326 |
+
acao=f"Import ZIP ({mode_val}, ambiente={ambiente})",
|
| 327 |
+
tabela="restore", registro_id=None)
|
| 328 |
+
except Exception as e:
|
| 329 |
+
st.error(f"Falha ao importar ZIP: {e}")
|
| 330 |
+
|
| 331 |
+
with up_col2:
|
| 332 |
+
xls_file = st.file_uploader("Upload Excel (.xlsx)", type=["xlsx"])
|
| 333 |
+
if xls_file is not None and st.button("Importar do Excel", type="primary"):
|
| 334 |
+
try:
|
| 335 |
+
report = import_excel(engine, xls_file.read(), mode=mode_val)
|
| 336 |
+
st.success(f"Import Excel concluído ({mode_val}).")
|
| 337 |
+
st.json(report)
|
| 338 |
+
registrar_log(usuario=st.session_state.get("usuario"),
|
| 339 |
+
acao=f"Import XLSX ({mode_val}, ambiente={ambiente})",
|
| 340 |
+
tabela="restore", registro_id=None)
|
| 341 |
+
except Exception as e:
|
| 342 |
+
st.error(f"Falha ao importar Excel: {e}")
|
| 343 |
+
|
| 344 |
+
st.divider()
|
| 345 |
+
st.info("⚠️ Recomendações:\n"
|
| 346 |
+
"• Para restore completo com integridade referencial, prefira snapshot físico no SQLite, ou migrações controladas em bancos como Postgres/SQL Server.\n"
|
| 347 |
+
"• O modo REPLACE desabilita FK temporariamente no SQLite para permitir limpeza; use com cautela.\n"
|
| 348 |
+
"• Em produção, considere gerar backups com versionamento e retenção (ex.: timestamp no nome do arquivo).")
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def render():
|
| 352 |
+
# compatível com seu roteador/menu
|
| 353 |
+
main()
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
if __name__ == "__main__":
|
| 357 |
+
st.set_page_config(page_title="Backup & Restore | ARM", layout="wide")
|
| 358 |
+
main()
|
| 359 |
+
|
db_monitor.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# db_monitor.py
|
| 3 |
+
import streamlit as st
|
| 4 |
+
import os
|
| 5 |
+
import shutil
|
| 6 |
+
import time
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from sqlalchemy import text
|
| 9 |
+
# ✅ Use sempre o engine do BANCO ATIVO (em vez de um engine fixo)
|
| 10 |
+
from banco import get_engine, SessionLocal, db_info
|
| 11 |
+
from utils_permissoes import verificar_permissao
|
| 12 |
+
from utils_auditoria import registrar_log
|
| 13 |
+
|
| 14 |
+
# ===============================
|
| 15 |
+
# MONITOR & BACKUP DO BANCO
|
| 16 |
+
# ===============================
|
| 17 |
+
# Objetivo:
|
| 18 |
+
# - Mostrar estatísticas do banco (tamanho, páginas, espaço em disco)
|
| 19 |
+
# - Definir limiar/capacidade alvo e exibir ocupação (%)
|
| 20 |
+
# - Planejar backup (frequência em dias) e retenção (N arquivos)
|
| 21 |
+
# - Executar backup e limpar antigos com confirmação
|
| 22 |
+
# - Acesso restrito por perfil admin
|
| 23 |
+
#
|
| 24 |
+
# Observações:
|
| 25 |
+
# - Em SQLite: usa PRAGMA page_count/page_size + arquivo .db
|
| 26 |
+
# - Em outros dialetos: exibe dialeto e recomenda backup externo
|
| 27 |
+
# - Pasta padrão de backup: ./backups (pode alterar na UI)
|
| 28 |
+
# - Auditoria: registrar_log(usuario, acao="BACKUP/CLEAN/MONITOR", tabela="schema")
|
| 29 |
+
|
| 30 |
+
# (Opcional) rótulo amigável do ambiente atual (Produção/Teste/Treinamento)
|
| 31 |
+
try:
|
| 32 |
+
from db_router import current_db_choice, bank_label
|
| 33 |
+
_HAS_ROUTER = True
|
| 34 |
+
except Exception:
|
| 35 |
+
_HAS_ROUTER = False
|
| 36 |
+
def current_db_choice() -> str:
|
| 37 |
+
return "prod"
|
| 38 |
+
def bank_label(choice: str) -> str:
|
| 39 |
+
return {"prod": "Banco 1 (Produção)", "test": "Banco 2 (Teste)", "treinamento": "Banco 3 (Treinamento)"}\
|
| 40 |
+
.get(choice, choice)
|
| 41 |
+
|
| 42 |
+
# -------------------------
|
| 43 |
+
# Auxiliares de dialeto
|
| 44 |
+
# -------------------------
|
| 45 |
+
def _engine():
|
| 46 |
+
"""Retorna o engine do banco ATIVO (de acordo com a escolha no login)."""
|
| 47 |
+
return get_engine()
|
| 48 |
+
|
| 49 |
+
def _dialeto():
|
| 50 |
+
try:
|
| 51 |
+
return _engine().url.get_backend_name()
|
| 52 |
+
except Exception:
|
| 53 |
+
return "desconhecido"
|
| 54 |
+
|
| 55 |
+
def _sqlite_version():
|
| 56 |
+
if _dialeto() != "sqlite":
|
| 57 |
+
return None
|
| 58 |
+
try:
|
| 59 |
+
with _engine().begin() as conn:
|
| 60 |
+
return conn.execute(text("select sqlite_version()")).scalar()
|
| 61 |
+
except Exception:
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
# -------------------------
|
| 65 |
+
# Info do banco
|
| 66 |
+
# -------------------------
|
| 67 |
+
def _db_file_path():
|
| 68 |
+
# Para SQLite, engine.url.database aponta para o arquivo .db
|
| 69 |
+
try:
|
| 70 |
+
eng = _engine()
|
| 71 |
+
return eng.url.database if eng.url.get_backend_name() == "sqlite" else None
|
| 72 |
+
except Exception:
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
def _sqlite_stats():
|
| 76 |
+
# Retorna dict com stats do SQLite
|
| 77 |
+
db_path = _db_file_path()
|
| 78 |
+
if not db_path or not os.path.exists(db_path):
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
size_bytes = os.path.getsize(db_path)
|
| 82 |
+
dir_path = os.path.dirname(os.path.abspath(db_path)) or "."
|
| 83 |
+
total, used, free = shutil.disk_usage(dir_path)
|
| 84 |
+
|
| 85 |
+
with _engine().begin() as conn:
|
| 86 |
+
page_count = conn.execute(text("PRAGMA page_count")).scalar()
|
| 87 |
+
page_size = conn.execute(text("PRAGMA page_size")).scalar()
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
"db_path": db_path,
|
| 91 |
+
"size_bytes": size_bytes,
|
| 92 |
+
"page_count": page_count,
|
| 93 |
+
"page_size": page_size,
|
| 94 |
+
"calc_bytes": (page_count or 0) * (page_size or 0),
|
| 95 |
+
"disk_total": total,
|
| 96 |
+
"disk_free": free,
|
| 97 |
+
"disk_used": used,
|
| 98 |
+
"sqlite_version": _sqlite_version(),
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
# -------------------------
|
| 102 |
+
# Backup
|
| 103 |
+
# -------------------------
|
| 104 |
+
def _ensure_dir(path: str):
|
| 105 |
+
os.makedirs(path, exist_ok=True)
|
| 106 |
+
|
| 107 |
+
def _fmt_bytes(b: int) -> str:
|
| 108 |
+
# Formata bytes em unidades legíveis
|
| 109 |
+
for unit in ["B","KB","MB","GB","TB"]:
|
| 110 |
+
if b < 1024.0:
|
| 111 |
+
return f"{b:,.2f} {unit}".replace(",", ".")
|
| 112 |
+
b /= 1024.0
|
| 113 |
+
return f"{b:,.2f} PB".replace(",", ".")
|
| 114 |
+
|
| 115 |
+
def _listar_backups(backup_dir: str, base_name: str):
|
| 116 |
+
"""Lista backups para o banco atual. Formato: base_name-YYYYMMDD-HHMMSS.db (ou .zip futuramente)"""
|
| 117 |
+
if not os.path.isdir(backup_dir):
|
| 118 |
+
return []
|
| 119 |
+
files = []
|
| 120 |
+
for f in os.listdir(backup_dir):
|
| 121 |
+
if f.startswith(base_name + "-") and f.endswith(".db"):
|
| 122 |
+
full = os.path.join(backup_dir, f)
|
| 123 |
+
files.append((f, full, os.path.getmtime(full)))
|
| 124 |
+
return sorted(files, key=lambda x: x[2], reverse=True) # ordem decrescente
|
| 125 |
+
|
| 126 |
+
def _executar_backup(backup_dir: str):
|
| 127 |
+
"""Copia o .db para backups/ com timestamp. Registra auditoria."""
|
| 128 |
+
db_path = _db_file_path()
|
| 129 |
+
if not db_path or not os.path.exists(db_path):
|
| 130 |
+
st.error("Arquivo de banco SQLite não encontrado.")
|
| 131 |
+
return False
|
| 132 |
+
|
| 133 |
+
_ensure_dir(backup_dir)
|
| 134 |
+
base_name = os.path.splitext(os.path.basename(db_path))[0]
|
| 135 |
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
| 136 |
+
dest = os.path.join(backup_dir, f"{base_name}-{stamp}.db")
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
shutil.copyfile(db_path, dest)
|
| 140 |
+
registrar_log(st.session_state.get("usuario"), f"BACKUP criado: {os.path.basename(dest)}", "schema", None)
|
| 141 |
+
st.success(f"✅ Backup criado: {dest}")
|
| 142 |
+
return True
|
| 143 |
+
except Exception as e:
|
| 144 |
+
st.error(f"Erro ao criar backup: {e}")
|
| 145 |
+
return False
|
| 146 |
+
|
| 147 |
+
def _limpar_antigos(backup_dir: str, base_name: str, manter: int):
|
| 148 |
+
"""Remove backups antigos, mantendo N mais recentes. Registra auditoria."""
|
| 149 |
+
lst = _listar_backups(backup_dir, base_name)
|
| 150 |
+
if len(lst) <= manter:
|
| 151 |
+
st.info("Nada para remover: já dentro da retenção.")
|
| 152 |
+
return 0
|
| 153 |
+
remover = lst[manter:]
|
| 154 |
+
count = 0
|
| 155 |
+
for _, full, _ in remover:
|
| 156 |
+
try:
|
| 157 |
+
os.remove(full)
|
| 158 |
+
count += 1
|
| 159 |
+
except Exception as e:
|
| 160 |
+
st.error(f"Erro ao remover {full}: {e}")
|
| 161 |
+
if count > 0:
|
| 162 |
+
registrar_log(st.session_state.get("usuario"), f"CLEAN backups antigos: {count} removidos (retain={manter})", "schema", None)
|
| 163 |
+
st.success(f"🧹 {count} backup(s) antigo(s) removido(s).")
|
| 164 |
+
return count
|
| 165 |
+
|
| 166 |
+
# -------------------------
|
| 167 |
+
# UI principal
|
| 168 |
+
# -------------------------
|
| 169 |
+
def main():
|
| 170 |
+
st.title("🗄️ Monitor e Backup do Banco")
|
| 171 |
+
|
| 172 |
+
# 🔐 Proteção: apenas admin
|
| 173 |
+
if st.session_state.get("perfil") != "admin":
|
| 174 |
+
st.error("⛔ Acesso restrito ao administrador.")
|
| 175 |
+
return
|
| 176 |
+
|
| 177 |
+
# Badge/URL do banco ativo (opcional)
|
| 178 |
+
try:
|
| 179 |
+
amb = current_db_choice()
|
| 180 |
+
st.caption(f"🧭 Ambiente: {bank_label(amb)}")
|
| 181 |
+
except Exception:
|
| 182 |
+
pass
|
| 183 |
+
try:
|
| 184 |
+
info = db_info()
|
| 185 |
+
st.caption(f"🔗 Engine URL: {info.get('url')}")
|
| 186 |
+
except Exception:
|
| 187 |
+
pass
|
| 188 |
+
|
| 189 |
+
dial = _dialeto()
|
| 190 |
+
st.caption(f"Dialeto do banco: **{dial}**")
|
| 191 |
+
|
| 192 |
+
# Estatísticas
|
| 193 |
+
stats = _sqlite_stats() if dial == "sqlite" else None
|
| 194 |
+
|
| 195 |
+
# Se não for SQLite, exibe recomendações
|
| 196 |
+
if dial != "sqlite":
|
| 197 |
+
st.info("Este monitor está otimizado para SQLite. Para PostgreSQL/MySQL, configure backup via ferramenta da plataforma (pg_dump/mysqldump) e agendamento externo.")
|
| 198 |
+
st.stop()
|
| 199 |
+
|
| 200 |
+
if not stats:
|
| 201 |
+
st.error("Banco SQLite não encontrado ou inacessível. Verifique o arquivo do banco ativo.")
|
| 202 |
+
return
|
| 203 |
+
|
| 204 |
+
# Painel de estatísticas
|
| 205 |
+
st.subheader("📊 Estatísticas")
|
| 206 |
+
colA, colB, colC = st.columns(3)
|
| 207 |
+
with colA:
|
| 208 |
+
st.metric("Arquivo", os.path.basename(stats["db_path"]))
|
| 209 |
+
st.metric("Tamanho do banco (arquivo)", _fmt_bytes(stats["size_bytes"]))
|
| 210 |
+
with colB:
|
| 211 |
+
st.metric("Páginas (PRAGMA)", f'{stats["page_count"]} × {stats["page_size"]} B')
|
| 212 |
+
st.metric("Cálculo (page_count×page_size)", _fmt_bytes(stats["calc_bytes"]))
|
| 213 |
+
with colC:
|
| 214 |
+
st.metric("Espaço livre no disco", _fmt_bytes(stats["disk_free"]))
|
| 215 |
+
st.metric("SQLite version", stats["sqlite_version"] or "—")
|
| 216 |
+
|
| 217 |
+
st.divider()
|
| 218 |
+
|
| 219 |
+
# Capacidade alvo e ocupação
|
| 220 |
+
st.subheader("🎯 Capacidade & Ocupação")
|
| 221 |
+
capacidade_gb = st.number_input("Capacidade alvo (GB) — alerta quando ultrapassar", min_value=0.1, value=1.0, step=0.1)
|
| 222 |
+
ocupacao_perc = min(100.0, (stats["size_bytes"] / (capacidade_gb * 1024**3)) * 100.0) if capacidade_gb > 0 else 0.0
|
| 223 |
+
|
| 224 |
+
st.progress(min(1.0, ocupacao_perc / 100.0))
|
| 225 |
+
st.caption(f"Ocupação estimada: **{ocupacao_perc:,.2f}%** de {capacidade_gb} GB")
|
| 226 |
+
|
| 227 |
+
if ocupacao_perc >= 80.0:
|
| 228 |
+
st.warning("⚠️ Ocupação acima de 80%. Considere backup/arquivamento.")
|
| 229 |
+
|
| 230 |
+
st.divider()
|
| 231 |
+
|
| 232 |
+
# Planejamento de backup
|
| 233 |
+
st.subheader("🗓️ Planejamento de Backup")
|
| 234 |
+
backup_dir = st.text_input("Pasta de backups", value="backups")
|
| 235 |
+
_ensure_dir(backup_dir) # garante a pasta
|
| 236 |
+
base_name = os.path.splitext(os.path.basename(stats["db_path"]))[0]
|
| 237 |
+
backups = _listar_backups(backup_dir, base_name)
|
| 238 |
+
|
| 239 |
+
# Último e próximo
|
| 240 |
+
ultimo_backup_dt = datetime.fromtimestamp(backups[0][2]) if backups else None
|
| 241 |
+
freq_dias = st.number_input("Frequência (dias)", min_value=1, value=7)
|
| 242 |
+
retencao = st.number_input("Retenção máx. de backups (arquivos)", min_value=1, value=10)
|
| 243 |
+
proximo_backup_dt = (ultimo_backup_dt + timedelta(days=freq_dias)) if ultimo_backup_dt else (datetime.now() + timedelta(days=freq_dias))
|
| 244 |
+
|
| 245 |
+
col1, col2, col3 = st.columns(3)
|
| 246 |
+
with col1:
|
| 247 |
+
st.metric("Último backup", ultimo_backup_dt.strftime("%d/%m/%Y %H:%M:%S") if ultimo_backup_dt else "—")
|
| 248 |
+
with col2:
|
| 249 |
+
st.metric("Próximo previsto", proximo_backup_dt.strftime("%d/%m/%Y %H:%M:%S"))
|
| 250 |
+
with col3:
|
| 251 |
+
st.metric("Backups atuais", len(backups))
|
| 252 |
+
|
| 253 |
+
# Aviso se vencido
|
| 254 |
+
if ultimo_backup_dt and datetime.now() >= proximo_backup_dt:
|
| 255 |
+
st.warning("⏰ Backup previsto já venceu. Execute agora para manter o plano.")
|
| 256 |
+
|
| 257 |
+
# Ações
|
| 258 |
+
st.subheader("⚙️ Ações")
|
| 259 |
+
colX, colY, colZ = st.columns(3)
|
| 260 |
+
with colX:
|
| 261 |
+
if st.button("💾 Backup agora"):
|
| 262 |
+
if _executar_backup(backup_dir):
|
| 263 |
+
st.rerun()
|
| 264 |
+
with colY:
|
| 265 |
+
if st.button("🧹 Limpar antigos (manter retenção)"):
|
| 266 |
+
_limpar_antigos(backup_dir, base_name, int(retencao))
|
| 267 |
+
st.rerun()
|
| 268 |
+
with colZ:
|
| 269 |
+
# Apenas mostra lista dos últimos backups
|
| 270 |
+
if backups:
|
| 271 |
+
st.write("Últimos backups:")
|
| 272 |
+
for f, full, mtime in backups[:5]:
|
| 273 |
+
dt = datetime.fromtimestamp(mtime).strftime("%d/%m/%Y %H:%M:%S")
|
| 274 |
+
st.caption(f"• {f} ({dt})")
|
| 275 |
+
|
| 276 |
+
# Auditoria de visualização (opcional)
|
| 277 |
+
registrar_log(st.session_state.get("usuario"), "MONITOR DB", "schema", None)
|
| 278 |
+
|
db_router.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|