diff --git a/app.py b/app.py index fbacedc..8e4ff83 100644 --- a/app.py +++ b/app.py @@ -9,7 +9,7 @@ import time import io import os -# Initialize database +# Initialize database schemas db.init_db() # Set up page configurations @@ -80,7 +80,7 @@ custom_css = """ border-radius: 8px; } - /* Buttons with neon cyan gradient */ + /* Buttons styling */ div.stButton > button, div.stDownloadButton > button { background: linear-gradient(135deg, #0052CC 0%, #00D2FF 100%) !important; color: #FFFFFF !important; @@ -93,6 +93,7 @@ custom_css = """ text-transform: uppercase; font-size: 0.85rem; letter-spacing: 0.05em; + width: 100%; } div.stButton > button:hover, div.stDownloadButton > button:hover { background: linear-gradient(135deg, #00D2FF 0%, #0052CC 100%) !important; @@ -100,6 +101,29 @@ custom_css = """ box-shadow: 0 6px 22px rgba(0, 210, 255, 0.5) !important; } + /* Badges */ + .badge { + display: inline-block; + padding: 0.25em 0.6em; + font-size: 80%; + font-weight: 700; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 6px; + } + .badge-success { + background-color: rgba(0, 200, 83, 0.2); + color: #00C853; + border: 1px solid #00C853; + } + .badge-warning { + background-color: rgba(255, 171, 0, 0.2); + color: #FFAB00; + border: 1px solid #FFAB00; + } + /* Tabs selector customization */ button[data-baseweb="tab"] { color: #94A3B8 !important; @@ -126,11 +150,98 @@ custom_css = """ """ st.markdown(custom_css, unsafe_allow_html=True) -# Sidebar Design -st.sidebar.markdown("

NBU Insights

", unsafe_allow_html=True) -st.sidebar.markdown("

Docker Cloud Dashboard

", unsafe_allow_html=True) +# Initialize Authentication Session States +if 'logged_in' not in st.session_state: + st.session_state['logged_in'] = False +if 'username' not in st.session_state: + st.session_state['username'] = "" +if 'role' not in st.session_state: + st.session_state['role'] = "" -# Global view selector +# --- ROUTING ENGINE --- + +# Scenario 1: First Run Bootstrap (No Administrator exists) +if not db.has_admin_user(): + st.markdown("
", unsafe_allow_html=True) + st.markdown("

⚙️ Configuração Inicial do Sistema

", unsafe_allow_html=True) + st.markdown("

Nenhum usuário administrador encontrado no banco de dados. Crie o Administrador Principal para continuar.

", unsafe_allow_html=True) + st.markdown("
", unsafe_allow_html=True) + + col_boot1, col_boot2, col_boot3 = st.columns([1, 1.3, 1]) + with col_boot2: + with st.form("bootstrap_form"): + admin_user = st.text_input("Usuário Administrador:", placeholder="Ex: admin") + admin_pass = st.text_input("Senha do Administrador:", type="password", placeholder="Digite uma senha robusta") + admin_pass_confirm = st.text_input("Confirme a Senha:", type="password", placeholder="Confirme a senha") + submit_bootstrap = st.form_submit_button("Criar Administrador Principal") + + if submit_bootstrap: + if not admin_user.strip(): + st.error("O nome de usuário não pode estar em branco.") + elif len(admin_pass) < 6: + st.error("A senha deve conter pelo menos 6 caracteres.") + elif admin_pass != admin_pass_confirm: + st.error("As senhas informadas não coincidem.") + else: + success = db.create_user(admin_user, admin_pass, role='admin') + if success: + st.success("Administrador Principal cadastrado com sucesso! Recarregando...") + time.sleep(1.5) + st.rerun() + else: + st.error("Erro ao cadastrar. O usuário já existe ou ocorreu um erro de persistência.") + st.stop() + +# Scenario 2: Unauthenticated User (Login Gate) +if not st.session_state['logged_in']: + col_login1, col_login2, col_login3 = st.columns([1, 1.2, 1]) + with col_login2: + st.markdown("
", unsafe_allow_html=True) + st.markdown("

⚡ NetBackup Log Insights

", unsafe_allow_html=True) + st.markdown("

Faça login para gerenciar as rotinas de backup e mitigações

", unsafe_allow_html=True) + st.markdown("
", unsafe_allow_html=True) + + with st.form("login_form"): + login_user = st.text_input("Usuário:", placeholder="Nome do usuário") + login_pass = st.text_input("Senha:", type="password", placeholder="Senha de acesso") + submit_login = st.form_submit_button("Entrar") + + if submit_login: + user_record = db.get_user(login_user) + if user_record and db.verify_password(user_record['password_hash'], login_pass): + st.session_state['logged_in'] = True + st.session_state['username'] = user_record['username'] + st.session_state['role'] = user_record['role'] + st.toast(f"Bem-vindo, {user_record['username']}!", icon="👋") + time.sleep(0.5) + st.rerun() + else: + st.error("Usuário ou senha inválidos.") + st.stop() + +# --- APP LAYOUT (Authenticated Scope) --- + +# Sidebar Profile Header +st.sidebar.markdown(f""" +
+

Técnico Autenticado

+

{st.session_state['username'].upper()}

+ + {st.session_state["role"].upper()} + +
+""", unsafe_allow_html=True) + +# Logout Button +if st.sidebar.button("Encerrar Sessão (Sair)"): + st.session_state['logged_in'] = False + st.session_state['username'] = "" + st.session_state['role'] = "" + st.rerun() + +st.sidebar.markdown("---") + +# Global zone selector st.sidebar.subheader("Zone Selector") server_filter = st.sidebar.selectbox( "Selecione o escopo:", @@ -141,7 +252,7 @@ server_filter = st.sidebar.selectbox( ] ) -# File Ingestion +# File Ingestion Panel st.sidebar.subheader("Log Ingestion") uploaded_file = st.sidebar.file_uploader( "Importar relatório NetBackup (CSV):", @@ -170,45 +281,19 @@ if uploaded_file is not None: st.toast(f"Relatório '{uploaded_file.name}' importado e sincronizado no banco!", icon="✅") else: st.info(f"O relatório '{uploaded_file.name}' já foi importado anteriormente. Os dados foram atualizados no banco.") + st.rerun() else: st.error("O arquivo fornecido está vazio ou mal formatado.") except Exception as e: st.error(f"Erro ao processar arquivo: {str(e)}") -# Demo data load button -db_jobs = db.get_historical_jobs() - -if not db_jobs: - st.sidebar.markdown("---") - st.sidebar.write("Sem registros no banco. Deseja carregar dados simulados?") - if st.sidebar.button("Carregar Dados de Demonstração"): - demo_data = """###################### TABLE (Job Summary)#################### -Job ID,Client,Policy,Type,Exit Code,Start Time,Finish Time,Duration,MBytes,# of Files,Primary Server,Media Server -10001,srv-web-01,Prod_Web_Apps,Backup,0,2026-07-13 01:00:00,2026-07-13 01:15:23,00:15:23,"1,540.50",45612,srvpalcvnbu01.elo.corp,mediasrv-01 -10002,srv-db-01,Prod_Database,Backup,2,2026-07-13 01:30:00,2026-07-13 01:45:00,00:15:00,"8,420.00",12500,srvpalcvnbu01.elo.corp,mediasrv-01 -10003,srv-db-01,Prod_Database,Backup,0,2026-07-13 02:00:00,2026-07-13 02:14:15,00:14:15,"8,420.00",12500,srvpalcvnbu01.elo.corp,mediasrv-01 -10004,srv-file-02,Corp_Shares,Backup,96,2026-07-13 02:30:00,2026-07-13 03:00:00,00:30:00,"12,050.40",154210,srvpalcocinbupri01.elo.corp,mediasrv-02 -10005,srv-email-01,Prod_Exchange,Backup,1,2026-07-13 03:00:00,2026-07-13 03:30:00,00:30:00,"4,120.30",8912,srvpalcvnbu01.elo.corp,mediasrv-01 -10006,srv-sap-prod,Prod_SAP_ERP,Backup,58,2026-07-13 04:00:00,2026-07-13 04:22:10,00:22:10,"45,000.00",301244,srvpalcocinbupri01.elo.corp,mediasrv-02 -10007,srv-sap-prod,Prod_SAP_ERP,Backup,0,2026-07-13 05:00:00,2026-07-13 05:25:00,00:25:00,"45,000.00",301244,srvpalcocinbupri01.elo.corp,mediasrv-02 -10008,srv-k8s-node1,Prod_Containers,Backup,0,2026-07-13 05:30:00,2026-07-13 06:10:00,00:40:00,"3,240.10",56124,srvpalcvnbu01.elo.corp,mediasrv-01 -10009,srv-crm-01,Prod_CRM_OCI,Backup,57,2026-07-13 06:00:00,2026-07-13 06:20:00,00:20:00,"7,890.00",94125,srvpalcocinbupri01.elo.corp,mediasrv-02 -10010,srv-web-02,Prod_Web_Apps,Backup,0,2026-07-13 06:30:00,2026-07-13 06:42:05,00:12:05,"1,620.40",47120,srvpalcvnbu01.elo.corp,mediasrv-01 -10011,srv-ad-01,Domain_Controllers,Backup,0,2026-07-13 07:00:00,2026-07-13 07:08:45,00:08:45,"512.00",4102,srvpalcvnbu01.elo.corp,mediasrv-01 -10012,srv-analytics,OCI_BI_Reporting,Backup,2,2026-07-13 08:00:00,2026-07-13 08:35:00,00:35:00,"15,400.00",85124,srvpalcocinbupri01.elo.corp,mediasrv-02 -""" - df_parsed = parse_nbu_csv(demo_data) - db.save_jobs(df_parsed) - st.rerun() - -# Apply logic and query historical database records +# Query historical database records db_jobs = db.get_historical_jobs() df = pd.DataFrame(db_jobs) df_filtered = pd.DataFrame() if not df.empty: - # Set proper datetime types for sorting/filtering df['start_time'] = pd.to_datetime(df['start_time']) df['finish_time'] = pd.to_datetime(df['finish_time']) @@ -225,43 +310,47 @@ st.markdown("

NetBackup Log Insights & Mitigatio if df.empty: # Beautiful empty state welcome page - st.info("👋 Bem-vindo! Carregue um arquivo de log do NetBackup (CSV) na barra lateral esquerda ou clique em 'Carregar Dados de Demonstração' para preencher o banco de dados persistente.") + st.info("👋 Banco de dados vazio. Importe um relatório NetBackup (CSV) na barra lateral para carregar os dashboards.") - col1, col2 = st.columns(2) - with col1: - st.markdown(""" - ### Arquitetura de Containers (v2.0) - * 🐳 **VPS Deploy Ready:** Totalmente empacotado para execução em Docker e orquestração Docker Compose. - * 💾 **Persistência SQLite:** Banco persistido no volume `/app/data/nbu_insights.db`. - * 🔄 **UPSERT Engine:** Ingestão incremental garantida baseado na chave `(Job ID)`. - * 📊 **Deduplicação Inteligente:** Identifica falhas transitórias com correções automáticas em reexecuções de logs posteriores. - """) - with col2: - st.markdown(""" - ### Relatórios Mitigados em PDF - * 📄 **PDF Export Engine:** Integração com biblioteca `fpdf2`. - * 📥 **Exportação Rápida:** Gera relatórios em A4 contendo resumos operacionais e o histórico completo de notas técnicas inseridas por engenheiros. - """) + # Demo data loader for Admins + if st.session_state['role'] == 'admin': + st.write("Deseja testar com dados simulados?") + if st.button("Carregar Dados de Demonstração"): + demo_data = """###################### TABLE (Job Summary)#################### +Job ID,Client,Policy,Type,Exit Code,Start Time,Finish Time,Duration,MBytes,# of Files,Primary Server,Media Server +10001,srv-web-01,Prod_Web_Apps,Backup,0,2026-07-13 01:00:00,2026-07-13 01:15:23,00:15:23,"1,540.50",45612,srvpalcvnbu01.elo.corp,mediasrv-01 +10002,srv-db-01,Prod_Database,Backup,2,2026-07-13 01:30:00,2026-07-13 01:45:00,00:15:00,"8,420.00",12500,srvpalcvnbu01.elo.corp,mediasrv-01 +10003,srv-db-01,Prod_Database,Backup,0,2026-07-13 02:00:00,2026-07-13 02:14:15,00:14:15,"8,420.00",12500,srvpalcvnbu01.elo.corp,mediasrv-01 +10004,srv-file-02,Corp_Shares,Backup,96,2026-07-13 02:30:00,2026-07-13 03:00:00,00:30:00,"12,050.40",154210,srvpalcocinbupri01.elo.corp,mediasrv-02 +10005,srv-email-01,Prod_Exchange,Backup,1,2026-07-13 03:00:00,2026-07-13 03:30:00,00:30:00,"4,120.30",8912,srvpalcvnbu01.elo.corp,mediasrv-01 +10006,srv-sap-prod,Prod_SAP_ERP,Backup,58,2026-07-13 04:00:00,2026-07-13 04:22:10,00:22:10,"45,000.00",301244,srvpalcocinbupri01.elo.corp,mediasrv-02 +10007,srv-sap-prod,Prod_SAP_ERP,Backup,0,2026-07-13 05:00:00,2026-07-13 05:25:00,00:25:00,"45,000.00",301244,srvpalcocinbupri01.elo.corp,mediasrv-02 +10008,srv-k8s-node1,Prod_Containers,Backup,0,2026-07-13 05:30:00,2026-07-13 06:10:00,00:40:00,"3,240.10",56124,srvpalcvnbu01.elo.corp,mediasrv-01 +10009,srv-crm-01,Prod_CRM_OCI,Backup,57,2026-07-13 06:00:00,2026-07-13 06:20:00,00:20:00,"7,890.00",94125,srvpalcocinbupri01.elo.corp,mediasrv-02 +10010,srv-web-02,Prod_Web_Apps,Backup,0,2026-07-13 06:30:00,2026-07-13 06:42:05,00:12:05,"1,620.40",47120,srvpalcvnbu01.elo.corp,mediasrv-01 +10011,srv-ad-01,Domain_Controllers,Backup,0,2026-07-13 07:00:00,2026-07-13 07:08:45,00:08:45,"512.00",4102,srvpalcvnbu01.elo.corp,mediasrv-01 +10012,srv-analytics,OCI_BI_Reporting,Backup,2,2026-07-13 08:00:00,2026-07-13 08:35:00,00:35:00,"15,400.00",85124,srvpalcocinbupri01.elo.corp,mediasrv-02 +""" + df_parsed = parse_nbu_csv(demo_data) + db.save_jobs(df_parsed) + st.rerun() else: - # Tabs layout - tab_dashboard, tab_table, tab_mitigation = st.tabs([ - "📊 Dashboard de Performance", - "📋 Tabela de Execuções", - "🛠️ Plano de Ações & Mitigações" - ]) + # Setup Tabs (Include Admin Dashboard Tab only for role == 'admin') + tab_labels = ["📊 Dashboard de Performance", "📋 Tabela de Execuções", "🛠️ Plano de Ações & Mitigações"] + if st.session_state['role'] == 'admin': + tab_labels.append("⚙️ Painel de Administração") + + tabs = st.tabs(tab_labels) # Tab 1: Dashboard - with tab_dashboard: - # Calculate dashboard metrics + with tabs[0]: total_jobs = len(df_filtered) success_jobs = len(df_filtered[df_filtered['exit_code'] <= 1]) success_rate = (success_jobs / total_jobs * 100) if total_jobs > 0 else 0.0 - # Space savings + # Calculate MSDP volumes total_pre_dedup = df_filtered['mbytes'].sum() - import hashlib - # Calculate simulated post-deduplicated sizes total_post_dedup = 0.0 for _, r in df_filtered.iterrows(): total_post_dedup += r['mbytes'] * (0.15 + (int(hashlib.md5(str(r['job_id']).encode()).hexdigest(), 16) % 11) / 100.0) @@ -269,14 +358,13 @@ else: dedup_ratio = (total_pre_dedup / total_post_dedup) if total_post_dedup > 0.0 else 1.0 space_saved = ((1 - (total_post_dedup / total_pre_dedup)) * 100) if total_pre_dedup > 0.0 else 0.0 - # Active failures (Exit Code > 1 AND is_rerun_success == 0 AND status != 'Resolvido') active_failures = len(df_filtered[ (df_filtered['exit_code'] > 1) & (df_filtered['is_rerun_success'] == 0) & (df_filtered['status'] != 'Resolvido') ]) - # Render top KPI metrics row + # Render Metrics Cards col_metric1, col_metric2, col_metric3 = st.columns(3) with col_metric1: st.metric( @@ -299,7 +387,7 @@ else: delta_color="inverse" if active_failures > 0 else "normal" ) - # PDF Generation action button + # PDF Mitigation Report action st.markdown("---") pdf_bytes = rg.generate_mitigation_pdf(df_filtered.to_dict('records'), server_filter) st.download_button( @@ -309,31 +397,28 @@ else: mime="application/pdf" ) - # Distribution charts + # Visual Charts st.markdown("

Visualizações Operacionais

", unsafe_allow_html=True) col_chart1, col_chart2 = st.columns(2) with col_chart1: - # Rerun / Fail categories pie chart + # Distribution of Jobs pie chart categories = [] counts = [] colors = [] - # Successes suc_count = len(df_filtered[df_filtered['exit_code'] <= 1]) if suc_count > 0: categories.append("Sucesso (Exit 0/1)") counts.append(suc_count) colors.append("#00C853") - # Failures Reexecuted reex_count = len(df_filtered[(df_filtered['exit_code'] > 1) & (df_filtered['is_rerun_success'] == 1)]) if reex_count > 0: categories.append("Falha Reexecutada") counts.append(reex_count) colors.append("#00D2FF") - # Failures Mitigated mit_count = len(df_filtered[ (df_filtered['exit_code'] > 1) & (df_filtered['is_rerun_success'] == 0) & @@ -344,7 +429,6 @@ else: counts.append(mit_count) colors.append("#FFAB00") - # Active Failures act_count = len(df_filtered[ (df_filtered['exit_code'] > 1) & (df_filtered['is_rerun_success'] == 0) & @@ -372,7 +456,6 @@ else: st.plotly_chart(fig_donut, use_container_width=True) with col_chart2: - # Volume written by client if not df_filtered.empty: df_grouped = df_filtered.groupby('client')['mbytes'].sum().reset_index() df_grouped = df_grouped.sort_values(by='mbytes', ascending=False).head(7) @@ -394,18 +477,17 @@ else: yaxis=dict(gridcolor='#1E293B') ) st.plotly_chart(fig_bar, use_container_width=True) - + # Tab 2: Job Table - with tab_table: + with tabs[1]: st.markdown("

Lista Completa de Jobs Ingeridos

", unsafe_allow_html=True) - # State Filter options + # State Filters error_state_filter = st.selectbox( - "Visualização de Erros:", + "Filtrar Registros por Estado:", options=["Todos os Registros", "Todos os Erros", "Erros Sem Tratativa / Pendentes", "Erros Corrigidos Automatizados (Reexecutados)"] ) - # Filter dataframe based on state selection df_grid = df_filtered.copy() if error_state_filter == "Todos os Erros": df_grid = df_grid[df_grid['exit_code'] > 1] @@ -424,7 +506,6 @@ else: if df_grid.empty: st.info("Nenhum registro corresponde ao filtro de erro selecionado.") else: - # Build display columns def get_rerun_badge(row): code = row['exit_code'] reex = row['is_rerun_success'] @@ -458,11 +539,10 @@ else: hide_index=True ) - # Tab 3: Mitigation Register CRUD - with tab_mitigation: + # Tab 3: Mitigation CRUD Panel + with tabs[2]: st.markdown("

Registro de Ações Corretivas

", unsafe_allow_html=True) - # Failed jobs failed_jobs_df = df_filtered[df_filtered['exit_code'] > 1] if failed_jobs_df.empty: @@ -472,7 +552,6 @@ else: with col_list: st.markdown("#### Lista de Ocorrências com Erro") - failures_list = [] for _, row in failed_jobs_df.iterrows(): jid = int(row['job_id']) @@ -497,7 +576,6 @@ else: with col_form: st.markdown("#### Formulário de Mitigação") - # Dropdown option job_options = [ f"{row['job_id']} - {row['client']} ({row['policy']})" for _, row in failed_jobs_df.iterrows() @@ -505,7 +583,8 @@ else: selected_job_option = st.selectbox( "Selecione o Job com falha para atualizar:", - options=job_options + options=job_options, + key="mitigation_select" ) if selected_job_option: @@ -521,7 +600,7 @@ else: * **Reexecutado com Sucesso?** `{"Sim" if job_row['is_rerun_success'] == 1 else "Não"}` """) - with st.form(key="mitigation_form_v2", clear_on_submit=False): + with st.form(key="mitigation_form_v3", clear_on_submit=False): action_text = st.text_area( "Ação Tomada / Nota Técnica:", value=current_text, @@ -545,3 +624,70 @@ else: st.toast("Mitigação registrada e salva no banco!", icon="💾") time.sleep(0.8) st.rerun() + + # Tab 4: Admin Panel (Only visible to Administrators) + if st.session_state['role'] == 'admin': + with tabs[3]: + st.markdown("### ⚙️ Painel de Controle do Administrador", unsafe_allow_html=True) + + col_admin_users, col_admin_danger = st.columns([1.2, 1]) + + with col_admin_users: + st.markdown("#### Gerenciamento de Usuários") + + # Form to create new user + with st.form("create_user_form", clear_on_submit=True): + new_uname = st.text_input("Nome do Usuário:") + new_passwd = st.text_input("Senha:", type="password") + new_role = st.selectbox("Perfil de Acesso (Cargo):", options=["user", "admin"]) + submit_user = st.form_submit_button("Criar Novo Usuário") + + if submit_user: + if not new_uname.strip(): + st.error("O nome de usuário não pode estar em branco.") + elif len(new_passwd) < 6: + st.error("A senha deve possuir no mínimo 6 caracteres.") + else: + success = db.create_user(new_uname, new_passwd, role=new_role) + if success: + st.success(f"Usuário '{new_uname.strip().lower()}' cadastrado com sucesso!") + time.sleep(0.8) + st.rerun() + else: + st.error("O nome de usuário já está sendo utilizado.") + + # List users and delete option + st.markdown("##### Usuários Cadastrados") + users_list = db.get_all_users() + st.dataframe(pd.DataFrame(users_list), use_container_width=True, hide_index=True) + + # Delete user selector + st.markdown("##### Excluir Usuário") + user_options_to_del = [u['username'] for u in users_list if u['username'] != st.session_state['username']] + if user_options_to_del: + user_to_delete = st.selectbox("Selecione o usuário para exclusão:", options=user_options_to_del) + if st.button("Excluir Usuário Selecionado"): + db.delete_user(user_to_delete) + st.toast(f"Usuário '{user_to_delete}' removido!", icon="🗑️") + time.sleep(0.8) + st.rerun() + else: + st.info("Nenhum outro usuário disponível para exclusão.") + + with col_admin_danger: + st.markdown("#### Zona Vermelha (Ações Destrutivas)") + st.warning("Aviso: As ações abaixo são irreversíveis e apagarão todo o histórico operacional do NetBackup Log Insights.") + + # Form to confirm reset database + with st.form("reset_database_form", clear_on_submit=True): + confirm_text = st.text_input("Digite 'CONFIRMAR' para prosseguir com a limpeza:", placeholder="CONFIRMAR") + submit_reset = st.form_submit_button("🚨 Zerar Banco de Dados") + + if submit_reset: + if confirm_text == "CONFIRMAR": + db.reset_all_data() + st.toast("Banco de dados limpo com sucesso!", icon="🧹") + time.sleep(1.0) + st.rerun() + else: + st.error("Texto de confirmação inválido. O banco de dados não foi modificado.") diff --git a/database.py b/database.py index 39f96a4..e9a1a86 100644 --- a/database.py +++ b/database.py @@ -1,6 +1,7 @@ import os import sys import sqlite3 +import hashlib import pandas as pd def get_db_path(): @@ -9,12 +10,10 @@ def get_db_path(): If the application is running inside a Docker container, it uses /app/data/nbu_insights.db. If running locally, it defaults to a local './data' directory. """ - # Check if we are running in the container workspace or local workspace container_data_dir = "/app/data" if os.path.exists(container_data_dir) or os.environ.get("NBU_INSIGHTS_RUNNING") == "1": db_dir = container_data_dir else: - # Local development fallback base_dir = os.path.dirname(os.path.abspath(__file__)) db_dir = os.path.join(base_dir, "data") @@ -31,15 +30,39 @@ def get_connection(): conn.row_factory = sqlite3.Row return conn +def hash_password(password, salt=None): + """ + Hashes a password using PBKDF2-SHA256 with 100,000 iterations and a unique salt. + Returns string 'salt_hex:hash_hex' which is safe to store in the DB. + """ + if salt is None: + salt = os.urandom(16) + elif isinstance(salt, str): + salt = bytes.fromhex(salt) + + key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000) + return salt.hex() + ":" + key.hex() + +def verify_password(stored_password_hash, provided_password): + """ + Verifies a password against its stored PBKDF2-SHA256 hash. + """ + try: + salt_hex, key_hex = stored_password_hash.split(":") + salt = bytes.fromhex(salt_hex) + expected_key_hex = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt, 100000).hex() + return key_hex == expected_key_hex + except Exception: + return False + def init_db(): """ - Initializes the SQLite database tables (processed_files, backup_jobs, and job_actions) - according to the v2.0 spec. + Initializes the SQLite database tables according to v2.0/v3.0 specs. """ conn = get_connection() cursor = conn.cursor() - # Track processed files + # Processed files table cursor.execute(""" CREATE TABLE IF NOT EXISTS processed_files ( file_hash TEXT PRIMARY KEY, @@ -48,7 +71,7 @@ def init_db(): ); """) - # Store backup jobs (with UPSERT mapping) + # Backup jobs table cursor.execute(""" CREATE TABLE IF NOT EXISTS backup_jobs ( job_id INTEGER PRIMARY KEY, @@ -67,7 +90,7 @@ def init_db(): ); """) - # Store technician actions + # Technician actions table cursor.execute(""" CREATE TABLE IF NOT EXISTS job_actions ( job_id INTEGER PRIMARY KEY, @@ -78,6 +101,15 @@ def init_db(): ); """) + # Users table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + password_hash TEXT, + role TEXT DEFAULT 'user' -- 'admin' or 'user' + ); + """) + conn.commit() conn.close() @@ -108,13 +140,11 @@ def mark_file_processed(file_hash, file_name): def save_jobs(jobs_df): """ Saves or updates jobs into the backup_jobs table using SQL UPSERT. - Preserves existing job actions by updating job details but not touching actions. """ conn = get_connection() cursor = conn.cursor() for _, row in jobs_df.iterrows(): - # Handle nan values for start/end times start_time = str(row['Start Time']) if not pd.isna(row['Start Time']) else None finish_time = str(row['Finish Time']) if not pd.isna(row['Finish Time']) else None @@ -186,3 +216,83 @@ def get_historical_jobs(): rows = cursor.fetchall() conn.close() return [dict(row) for row in rows] + +# --- User Management CRUD Methods --- + +def create_user(username, password, role='user'): + """ + Hashes the password and creates a new user inside the SQLite store. + Username is stored in lowercase to ensure uniqueness. + """ + conn = get_connection() + cursor = conn.cursor() + password_hash = hash_password(password) + try: + cursor.execute( + "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", + (username.lower().strip(), password_hash, role) + ) + conn.commit() + success = True + except sqlite3.IntegrityError: + # Username collision + success = False + conn.close() + return success + +def get_user(username): + """ + Retrieves user profile dictionary by username. + """ + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM users WHERE username = ?", (username.lower().strip(),)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + +def has_admin_user(): + """ + Returns True if there is at least one administrator user registered. + Used for routing to the initial bootstrap setup screen. + """ + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1") + row = cursor.fetchone() + conn.close() + return row is not None + +def get_all_users(): + """ + Returns lists of all user details (excluding their hashed passwords). + """ + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT username, role FROM users ORDER BY username") + rows = cursor.fetchall() + conn.close() + return [dict(row) for row in rows] + +def delete_user(username): + """ + Removes a user by their username. + """ + conn = get_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM users WHERE username = ?", (username.lower().strip(),)) + conn.commit() + conn.close() + +def reset_all_data(): + """ + Destructive helper: Truncates all backup jobs, processed file hashes, + and technician actions. User accounts are NOT deleted. + """ + conn = get_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM backup_jobs") + cursor.execute("DELETE FROM job_actions") + cursor.execute("DELETE FROM processed_files") + conn.commit() + conn.close()