From 807f2d318bd23d3992fafb3323cc280f99cfa490 Mon Sep 17 00:00:00 2001 From: Michel Date: Sat, 27 Sep 2025 11:31:48 +0200 Subject: [PATCH] =?UTF-8?q?Cr=C3=A9ation=20onglet=20journal=20erreurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 6 +- app/domo91.py | 709 ++++++++++++++++++++++++++++--------- app/supervisor_watchdog.py | 2 +- 3 files changed, 554 insertions(+), 163 deletions(-) diff --git a/.env b/.env index 4305bd6..0f7b127 100644 --- a/.env +++ b/.env @@ -2,13 +2,13 @@ #connexion mysql DB_HOST=162.19.78.131 DB_USER=sondes -DB_PASS=TX.)-U1!zq5Axdk4 +DB_PASS='TX.)-U1!zq5Axdk4' DB_NAME=Sondes # MQTT MQTT_HOST=54.36.188.119 MQTT_USER=Bwps -MQTT_PASS="scJ5ACj2keRfI^" +MQTT_PASS='scJ5ACj2keRfI^' # Boucle rapide du gyro GYRO_MODE=mqtt @@ -33,7 +33,7 @@ SMTP_HOST=ssl0.ovh.net SMTP_PORT=587 SMTP_SECURITY=STARTTLS SMTP_USER=services@domo91.fr -SMTP_PASS=6ZiCsVtSf9@nEHv@$^0 +SMTP_PASS='VHq3278YA#sGV*bh#mR' MAIL_FROM=services@domo91.fr MAIL_TO_SACLAY=robots@domo91.fr,nicolas.thibaut@bw-paris-saclay.com MAIL_FROM_SACLAY="DOMO91 Saclay " diff --git a/app/domo91.py b/app/domo91.py index 715dfe7..67caf9f 100644 --- a/app/domo91.py +++ b/app/domo91.py @@ -1,26 +1,49 @@ # -*- coding: utf-8 -*- -import streamlit as st +import os +import random +import traceback +from datetime import datetime, date, time + +import bcrypt +import matplotlib.dates as mdates +import matplotlib.pyplot as plt import mysql.connector import pandas as pd -import matplotlib.pyplot as plt -import matplotlib.dates as mdates -import os +import streamlit as st +from contextlib import closing from dotenv import load_dotenv -from datetime import datetime, date, time from fpdf import FPDF -import bcrypt -import traceback -import random - -# Charger les variables d'environnement -load_dotenv() +# ========================================================= +# Config de page +# ========================================================= st.set_page_config(page_title="Domo91 - Surveillance", layout="wide") - st.title("📊 Domo91 - Surveillance des sondes") st.write("Bienvenue sur l’application de supervision.") -# Initialisation session state avec valeurs sĂ»res +# ========================================================= +# ENV & DB +# ========================================================= +load_dotenv() +db_config = { + "host": os.getenv("DB_HOST"), + "user": os.getenv("DB_USER"), + "password": os.getenv("DB_PASS"), + "database": os.getenv("DB_NAME"), + "autocommit": False, +} + +SITES_AUTORISES = {"Saclay", "Meudon", "Roissy"} # anti-injection sur noms de tables + +def get_connection(): + return mysql.connector.connect(**db_config) + +def get_conn(): + return mysql.connector.connect(**db_config) + +# ========================================================= +# Session state +# ========================================================= for key, default in { "authenticated": False, "role": None, @@ -32,28 +55,122 @@ for key, default in { }.items(): st.session_state.setdefault(key, default) -# Configuration MySQL -db_config = { - "host": os.getenv("DB_HOST"), - "user": os.getenv("DB_USER"), - "password": os.getenv("DB_PASS"), - "database": os.getenv("DB_NAME") -} - - -def get_connection(): - return mysql.connector.connect(**db_config) - - +# ========================================================= +# SĂ©curitĂ© mots de passe +# ========================================================= def hash_password(plain_password): - return bcrypt.hashpw(plain_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - + return bcrypt.hashpw(plain_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") def verifier_password(input_password, hash_en_base): - return bcrypt.checkpw(input_password.encode('utf-8'), hash_en_base.encode('utf-8')) + return bcrypt.checkpw(input_password.encode("utf-8"), hash_en_base.encode("utf-8")) +# ========================================================= +# Bootstrap schĂ©ma : crĂ©e Journal_Erreurs si absent +# GĂšre 2 cas : +# - VERSION A : colonne gĂ©nĂ©rĂ©e (recommandĂ©e) +# - VERSION B (fallback) : colonne simple + triggers pour normaliser Source_Id_norm +# ========================================================= +def ensure_schema(): + ddl_generated = """ + CREATE TABLE IF NOT EXISTS Journal_Erreurs ( + Id INT AUTO_INCREMENT PRIMARY KEY, + Site VARCHAR(50) NOT NULL, + Sonde VARCHAR(100) NOT NULL, + DateJour DATE NOT NULL, + Type VARCHAR(30) NOT NULL, + Source_Id INT NULL, + Source_Id_norm INT GENERATED ALWAYS AS (COALESCE(Source_Id, 0)) STORED, + Resume TEXT NOT NULL, + Statut VARCHAR(20) NOT NULL DEFAULT 'Nouveau', + Priorite TINYINT NOT NULL DEFAULT 3, + Assignation VARCHAR(100) NULL, + Commentaire TEXT NULL, + Tag VARCHAR(50) NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_site_sonde_date_type (Site, Sonde, DateJour, Type, Source_Id_norm), + KEY idx_journal_date_site (DateJour, Site), + KEY idx_journal_statut (Statut) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """ + ddl_fallback = """ + CREATE TABLE IF NOT EXISTS Journal_Erreurs ( + Id INT AUTO_INCREMENT PRIMARY KEY, + Site VARCHAR(50) NOT NULL, + Sonde VARCHAR(100) NOT NULL, + DateJour DATE NOT NULL, + Type VARCHAR(30) NOT NULL, + Source_Id INT NULL, + Source_Id_norm INT NULL DEFAULT 0, + Resume TEXT NOT NULL, + Statut VARCHAR(20) NOT NULL DEFAULT 'Nouveau', + Priorite TINYINT NOT NULL DEFAULT 3, + Assignation VARCHAR(100) NULL, + Commentaire TEXT NULL, + Tag VARCHAR(50) NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """ + idxs_fallback = [ + "CREATE UNIQUE INDEX IF NOT EXISTS uk_site_sonde_date_type ON Sondes.Journal_Erreurs (Site, Sonde, DateJour, Type, Source_Id_norm)", + "CREATE INDEX IF NOT EXISTS idx_journal_date_site ON Sondes.Journal_Erreurs (DateJour, Site)", + "CREATE INDEX IF NOT EXISTS idx_journal_statut ON Sondes.Journal_Erreurs (Statut)", + ] + triggers_fallback = [ + """ + CREATE TRIGGER IF NOT EXISTS trg_je_bi + BEFORE INSERT ON Sondes.Journal_Erreurs + FOR EACH ROW + BEGIN + SET NEW.Source_Id_norm = IFNULL(NEW.Source_Id, 0); + END + """, + """ + CREATE TRIGGER IF NOT EXISTS trg_je_bu + BEFORE UPDATE ON Sondes.Journal_Erreurs + FOR EACH ROW + BEGIN + SET NEW.Source_Id_norm = IFNULL(NEW.Source_Id, 0); + END + """, + ] -# --- Connexion utilisateur --- + try: + with closing(get_conn()) as cnx, closing(cnx.cursor()) as cur: + cur.execute(ddl_generated) + cnx.commit() + except Exception: + # fallback si la colonne gĂ©nĂ©rĂ©e n'est pas supportĂ©e + with closing(get_conn()) as cnx, closing(cnx.cursor()) as cur: + cur.execute(ddl_fallback) + for q in idxs_fallback: + try: + cur.execute(q) + except Exception: + pass + # MariaDB/MySQL n'ont pas tous IF NOT EXISTS sur triggers → on tente drop/try + for name in ("trg_je_bi", "trg_je_bu"): + try: + cur.execute(f"DROP TRIGGER IF EXISTS {name}") + except Exception: + pass + for q in triggers_fallback: + try: + cur.execute(q) + except Exception: + pass + cnx.commit() + +# ExĂ©cution Ă  l’import +try: + ensure_schema() +except Exception as e: + st.warning(f"Init schĂ©ma Journal_Erreurs : {e}") + +# ========================================================= +# Connexion utilisateur +# ========================================================= if not st.session_state.get("authenticated", False): login = st.sidebar.text_input("Nom d'utilisateur") password = st.sidebar.text_input("Mot de passe", type="password") @@ -62,14 +179,15 @@ if not st.session_state.get("authenticated", False): try: conn = get_connection() cursor = conn.cursor(dictionary=True) - - # On interroge la bonne table - cursor.execute(""" - SELECT NomUtilisateur, role, MotDePasseHash, Site, DateExpiration - FROM Acces.Utilisateurs - WHERE NomUtilisateur = %s - LIMIT 1 - """, (login,)) + cursor.execute( + """ + SELECT NomUtilisateur, role, MotDePasseHash, Site, DateExpiration + FROM Acces.Utilisateurs + WHERE NomUtilisateur = %s + LIMIT 1 + """, + (login,), + ) result = cursor.fetchone() if not result: @@ -79,24 +197,25 @@ if not st.session_state.get("authenticated", False): elif not verifier_password(password, result["MotDePasseHash"]): st.sidebar.error("Identifiants invalides") else: - # Authentification rĂ©ussie st.session_state.update({ "authenticated": True, "role": result["role"], - "site_autorise": result["Site"] + "site_autorise": result["Site"], + "onglet_actif": "Accueil", # 👈 reset }) - now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - cursor.execute(""" - INSERT INTO Sondes.Connexion_Log (Utilisateur, Lieu, Date_Connexion) - VALUES (%s, %s, %s) - """, (result["NomUtilisateur"], result["Site"], now_str)) + cursor.execute( + """ + INSERT INTO Sondes.Connexion_Log (Utilisateur, Lieu, Date_Connexion) + VALUES (%s, %s, %s) + """, + (result["NomUtilisateur"], result["Site"], now_str), + ) conn.commit() st.rerun() cursor.close() conn.close() - except Exception as e: st.sidebar.error(f"Erreur connexion : {e}") else: @@ -104,25 +223,33 @@ else: if st.sidebar.button("🔓 DĂ©connexion"): for key in ["authenticated", "role", "site_autorise"]: st.session_state[key] = False if key == "authenticated" else None + st.session_state["onglet_actif"] = "Accueil" # 👈 reset st.rerun() - - -# --- Fonction de gĂ©nĂ©ration PDF --- +# ========================================================= +# PDF +# ========================================================= def generer_pdf(site, date_str, periode): st.info(f"GĂ©nĂ©ration du rapport PDF pour {site} Ă  la date {date_str} ({periode})") try: - conn = mysql.connector.connect(**db_config) + conn = get_connection() pdf_cursor = conn.cursor(dictionary=True) - # RequĂȘte principale - pdf_cursor.execute(f"SELECT Sonde, Date, Temperature FROM `{site}` WHERE DATE(Date) = %s ORDER BY Sonde, Date", (date_str,)) + # RelevĂ©s + pdf_cursor.execute( + f"SELECT Sonde, Date, Temperature FROM `{site}` WHERE DATE(Date) = %s ORDER BY Sonde, Date", + (date_str,), + ) rows = pdf_cursor.fetchall() df = pd.DataFrame(rows) + if df.empty: + st.warning("Aucune donnĂ©e ce jour.") + return + df["Heure"] = pd.to_datetime(df["Date"]).dt.strftime("%H:%M") df["Heure_obj"] = pd.to_datetime(df["Date"]).dt.time - # --- Filtrage par pĂ©riode --- + # PĂ©riodes plages = { "Toute la journĂ©e": (time(0, 0), time(23, 59)), "Matin (6h-12h)": (time(6, 0), time(12, 0)), @@ -135,42 +262,42 @@ def generer_pdf(site, date_str, periode): else: df = df[(df["Heure_obj"] >= heure_debut) | (df["Heure_obj"] <= heure_fin)] - # --- Structuration des relevĂ©s --- releves = {} - for sonde in df["Sonde"].unique(): + for sonde in sorted(df["Sonde"].unique()): df_sonde = df[df["Sonde"] == sonde] releves[sonde] = list(zip(df_sonde["Heure"], df_sonde["Temperature"])) - # --- RequĂȘte alertes --- + # Alertes table_alertes = f"Alertes_{site}" - pdf_cursor.execute(f"SELECT Sonde, Debut_defaut, Etat FROM {table_alertes} WHERE DATE(Debut_defaut) = %s", (date_str,)) + pdf_cursor.execute( + f"SELECT Sonde, Debut_defaut, Etat FROM `{table_alertes}` WHERE DATE(Debut_defaut) = %s", + (date_str,), + ) alertes = pdf_cursor.fetchall() pdf_cursor.close() conn.close() - # --- Classe PDF --- class RapportPDF(FPDF): def header(self): self.set_font("Arial", "B", 14) self.cell(0, 10, "Rapport de surveillance des sondes", ln=1, align="C") self.set_font("Arial", "", 12) - self.cell(0, 10, f"Date : {date_str}", ln=1, align="C") - self.cell(0, 10, f"Periode : {getattr(self, 'periode', '')}", ln=1, align="C") - self.ln(5) + self.cell(0, 8, f"Date : {date_str}", ln=1, align="C") + self.cell(0, 8, f"PĂ©riode : {getattr(self, 'periode', '')}", ln=1, align="C") + self.ln(4) def site_info(self, site_name): self.set_font("Arial", "B", 12) - self.cell(0, 10, f"Site : {site_name}", ln=1) + self.cell(0, 8, f"Site : {site_name}", ln=1) self.ln(2) def releves_section(self, data): self.set_font("Arial", "B", 12) - self.cell(0, 10, "RelevĂ©s de tempĂ©rature", ln=1) - + self.cell(0, 8, "RelevĂ©s de tempĂ©rature", ln=1) for sonde, mesures in data.items(): self.set_font("Arial", "B", 11) - self.cell(0, 8, f"Sonde : {sonde}", ln=1) + self.cell(0, 7, f"Sonde : {sonde}", ln=1) col1 = mesures[::2] col2 = mesures[1::2] @@ -197,16 +324,18 @@ def generer_pdf(site, date_str, periode): self.cell(40, 6, h2, border=1) self.cell(30, 6, f"{t2:.2f}", border=1) self.ln() - self.ln(4) + self.ln(3) def alertes_section(self, data): self.set_font("Arial", "B", 12) - self.cell(0, 10, "Alertes enregistrĂ©es", ln=1) + self.cell(0, 8, "Alertes enregistrĂ©es", ln=1) self.set_font("Arial", "", 10) - for a in data: - self.cell(0, 6, f"{a['Sonde']} - {a['Debut_defaut']} - {a['Status']}", ln=1) + if not data: + self.cell(0, 6, "Aucune alerte ce jour.", ln=1) + else: + for a in data: + self.cell(0, 6, f"{a['Sonde']} - {a['Debut_defaut']} - {a['Etat']}", ln=1) - # --- GĂ©nĂ©ration du PDF --- pdf = RapportPDF() pdf.periode = periode pdf.add_page() @@ -214,11 +343,10 @@ def generer_pdf(site, date_str, periode): pdf.releves_section(releves) pdf.alertes_section(alertes) - file_name = f"rapport_{site}_{date_str}.pdf" output_dir = "PDF" os.makedirs(output_dir, exist_ok=True) + file_name = f"rapport_{site}_{date_str}.pdf" output_path = os.path.join(output_dir, file_name) - pdf.output(output_path) with open(output_path, "rb") as f: @@ -226,36 +354,293 @@ def generer_pdf(site, date_str, periode): label="đŸ“„ TĂ©lĂ©charger le rapport PDF", data=f, file_name=file_name, - mime="application/pdf" + mime="application/pdf", ) except Exception as err1: st.error(f"Erreur lors de la gĂ©nĂ©ration du PDF : {err1}") -# --- AFFICHAGE GLOBAL DES ALERTES NON ACQUITTÉES --- +# ========================================================= +# Fonctions Journal erreurs (SQL) +# ========================================================= +def _get_site_courant(): + role = st.session_state.get("role") + if role != "superviseur": + return st.session_state.get("site_autorise") + return st.session_state.get("selected_site", "Saclay") + +def load_alertes(site: str, jour: date): + table_alertes = f"Alertes_{site}" + q = f""" + SELECT + a.Id AS Source_Id, + %s AS Site, + a.Sonde AS Sonde, + DATE(a.Debut_defaut) AS DateJour, + 'Alerte' AS Type, + CONCAT('Alerte ', a.Etat, ' depuis ', DATE_FORMAT(a.Debut_defaut,'%H:%i')) AS Resume, + a.Etat AS Etat + FROM `{table_alertes}` a + WHERE DATE(a.Debut_defaut) = %s + OR (a.Etat <> 'AcquittĂ©' AND DATE(a.Debut_defaut) <= %s); + """ + with closing(get_conn()) as cnx, closing(cnx.cursor(dictionary=True)) as cur: + cur.execute(q, (site, jour, jour)) + rows = cur.fetchall() + cols = ["Source_Id","Site","Sonde","DateJour","Type","Resume","Etat"] + return pd.DataFrame(rows, columns=cols) if rows else pd.DataFrame(columns=cols) + +def load_anomalies_auto(site: str, jour: date): + """ + DĂ©tection d'anomalies "Auto" sur la date choisie : + - GAPS : vrais trous = diff entre 2 mesures consĂ©cutives >= gap_threshold_min + - JUMPS : saut de tempĂ©rature > jump_deg entre 2 mesures consĂ©cutives + - BOUNDS : valeurs hors bornes physiques + """ + table_mesures = site + gap_threshold_min = 20 # seuil "trou" (ex. mesures toutes 5 min → 20 min = 4 crĂ©neaux manquĂ©s) + jump_deg = 10 # saut suspect + min_phys, max_phys = -60, 120 + + q = f""" + WITH J AS ( + SELECT Sonde, `Date`, Temperature + FROM `{table_mesures}` + WHERE DATE(`Date`) = %s + ), + L AS ( + SELECT Sonde, + `Date`, + Temperature, + LAG(`Date`) OVER (PARTITION BY Sonde ORDER BY `Date`) AS prev_date, + LAG(Temperature) OVER (PARTITION BY Sonde ORDER BY `Date`) AS prev_temp + FROM J + ), + gaps AS ( + SELECT Sonde, + COUNT(*) AS nb_gaps, + MAX(TIMESTAMPDIFF(MINUTE, prev_date, `Date`)) AS max_gap_min + FROM L + WHERE prev_date IS NOT NULL + AND TIMESTAMPDIFF(MINUTE, prev_date, `Date`) >= {gap_threshold_min} + GROUP BY Sonde + ), + jumps AS ( + SELECT Sonde, + COUNT(*) AS nb_jumps, + MAX(ABS(Temperature - prev_temp)) AS max_jump + FROM L + WHERE prev_temp IS NOT NULL + AND ABS(Temperature - prev_temp) > {jump_deg} + GROUP BY Sonde + ), + bounds AS ( + SELECT Sonde, + MIN(Temperature) AS tmin, + MAX(Temperature) AS tmax + FROM J + GROUP BY Sonde + HAVING MIN(Temperature) < {min_phys} OR MAX(Temperature) > {max_phys} + ) + SELECT NULL AS Source_Id, %s AS Site, g.Sonde AS Sonde, %s AS DateJour, 'Auto' AS Type, + CONCAT('Trous de mesures : ', nb_gaps, ' fois (max ', max_gap_min, ' min)') AS Resume + FROM gaps g + + UNION ALL + SELECT NULL, %s, j.Sonde, %s, 'Auto', + CONCAT('Sauts de tempĂ©rature suspects : ', nb_jumps, ' (max ', ROUND(max_jump,1), '°C)') + FROM jumps j + + UNION ALL + SELECT NULL, %s, b.Sonde, %s, 'Auto', + CONCAT('Valeurs hors bornes physiques (min=', ROUND(tmin,1), '°C, max=', ROUND(tmax,1), '°C)') + FROM bounds b + + ORDER BY Sonde; + """ + params = (jour, site, jour, site, jour, site, jour) + with closing(get_conn()) as cnx, closing(cnx.cursor(dictionary=True)) as cur: + cur.execute(q, params) + rows = cur.fetchall() + cols = ["Source_Id","Site","Sonde","DateJour","Type","Resume"] + return pd.DataFrame(rows, columns=cols) if rows else pd.DataFrame(columns=cols) + + +def load_journal_existants(site: str, jour: date): + q = """ + SELECT Id, Site, Sonde, DateJour, Type, Source_Id, Resume, + Statut, Priorite, Assignation, Commentaire, Tag + FROM Sondes.Journal_Erreurs + WHERE Site=%s AND DateJour=%s; + """ + with closing(get_conn()) as cnx, closing(cnx.cursor(dictionary=True)) as cur: + cur.execute(q, (site, jour)) + rows = cur.fetchall() + cols = ["Id","Site","Sonde","DateJour","Type","Source_Id","Resume","Statut","Priorite","Assignation","Commentaire","Tag"] + return pd.DataFrame(rows, columns=cols) if rows else pd.DataFrame(columns=cols) + +def upsert_journal(rows: list[dict]): + if not rows: + return + q_insert = """ + INSERT INTO Sondes.Journal_Erreurs + (Site, Sonde, DateJour, Type, Source_Id, Resume, Statut, Priorite, Assignation, Commentaire, Tag) + VALUES (%(Site)s, %(Sonde)s, %(DateJour)s, %(Type)s, %(Source_Id)s, %(Resume)s, + %(Statut)s, %(Priorite)s, %(Assignation)s, %(Commentaire)s, %(Tag)s) + ON DUPLICATE KEY UPDATE + Resume=VALUES(Resume), + Statut=VALUES(Statut), + Priorite=VALUES(Priorite), + Assignation=VALUES(Assignation), + Commentaire=VALUES(Commentaire), + Tag=VALUES(Tag), + UpdatedAt=CURRENT_TIMESTAMP; + """ + with closing(get_conn()) as cnx, closing(cnx.cursor()) as cur: + cur.executemany(q_insert, rows) + cnx.commit() + +# ========================================================= +# Page Journal erreurs +# ========================================================= +def page_journal_erreurs(): + st.header("📝 Journal des erreurs") + site = _get_site_courant() + if not site: + st.warning("Veuillez d’abord choisir un site sur la page d’accueil.") + return + if site not in SITES_AUTORISES: + st.error("Site invalide.") + return + + jour = st.date_input("Date de vision", value=st.session_state.get("selected_date", date.today())) + st.caption(f"Site : {site} — Date : {jour.strftime('%d/%m/%Y')}") + + # --- Chargement des sources + df_alertes = load_alertes(site, jour) + df_auto = load_anomalies_auto(site, jour) + df_saved = load_journal_existants(site, jour) + + base = pd.concat([df_auto, df_alertes], ignore_index=True) + if base.empty and df_saved.empty: + st.info("Aucune anomalie dĂ©tectĂ©e ni alerte pour cette date.") + return + + # Colonnes attendues + for col in ["Statut","Priorite","Assignation","Commentaire","Tag","Id","Source_Id"]: + if col not in base.columns: + base[col] = pd.NA + + key_join = ["Site","Sonde","DateJour","Type","Source_Id","Resume"] + df = base.merge(df_saved, on=key_join, how="left", suffixes=("","_saved")) + + # Garantir l'existence des *_saved si df_saved est vide + for c in ["Statut_saved","Priorite_saved","Assignation_saved","Commentaire_saved", + "Tag_saved","Id_saved","Source_Id_saved"]: + if c not in df.columns: + df[c] = pd.NA + + # Valeurs par dĂ©faut depuis sauvegarde + df["Statut"] = df["Statut"].fillna(df["Statut_saved"]).fillna("Nouveau") + df["Priorite"] = df["Priorite"].fillna(df["Priorite_saved"]).fillna(3) + df["Assignation"] = df["Assignation"].fillna(df["Assignation_saved"]) + df["Commentaire"] = df["Commentaire"].fillna(df["Commentaire_saved"]) + df["Tag"] = df["Tag"].fillna(df["Tag_saved"]) + df["Id"] = df["Id"].fillna(df["Id_saved"]) + df["Source_Id"] = df["Source_Id"].fillna(df["Source_Id_saved"]) + + # --- Types compatibles pour l'Ă©diteur --- + text_cols = ["Sonde", "Type", "Resume", "Statut", "Assignation", "Tag", "Commentaire"] + for c in text_cols: + if c not in df.columns: + df[c] = pd.Series(dtype="string") # colonne vide typĂ©e texte + else: + df[c] = df[c].astype("string").fillna("") # tout en string, pas de NaN + + # PrioritĂ© = entier nullable (Ă©vite 3.0) + df["Priorite"] = pd.to_numeric(df["Priorite"], errors="coerce").fillna(3).astype("Int64") + # --- Éditeur + st.subheader("SynthĂšse (Ă©ditable)") + edit_cols = ["Sonde","Type","Resume","Statut","Priorite","Assignation","Tag","Commentaire"] + disabled_cols = ["Sonde","Type","Resume"] + + st.subheader("SynthĂšse (Ă©ditable)") + edit_cols = ["Sonde", "Type", "Resume", "Statut", "Priorite", "Assignation", "Tag", "Commentaire"] + disabled_cols = ["Sonde", "Type", "Resume"] + + editable = st.data_editor( + df[edit_cols].copy(), # <<< important + disabled=disabled_cols, + use_container_width=True, + hide_index=True, + column_config={ + "Statut": st.column_config.SelectboxColumn( + "Statut", options=["Nouveau", "En cours", "PlanifiĂ©", "Clos"], help="État de la tĂąche" + ), + "Priorite": st.column_config.NumberColumn( + "PrioritĂ©", min_value=1, max_value=3, step=1, format="%d", + help="1=Haute, 2=Moyenne, 3=Basse" + ), + "Assignation": st.column_config.TextColumn("Assignation", help="Technicien / ticket"), + "Tag": st.column_config.TextColumn("Tag", help="Intermittent, RĂ©seau, Capteur, etc."), + "Commentaire": st.column_config.TextColumn( + "Commentaire", help="Notes (SHIFT+EntrĂ©e pour un saut de ligne)", max_chars=2000 + ), + } + ) + + # --- Sauvegarde + def _none_if_empty(x): + if x is None: return None + if isinstance(x, float) and pd.isna(x): return None + if isinstance(x, str) and x.strip() == "": return None + return x + + if st.session_state.get("role") == "superviseur": + if st.button("đŸ’Ÿ Enregistrer les modifications"): + # On rattache les clĂ©s (Site/Sonde/DateJour/Type/Source_Id/Resume) aux lignes Ă©ditĂ©es + df_keys = df[["Site","Sonde","DateJour","Type","Source_Id","Resume"]] + df_to_save = editable.merge(df_keys, on=["Sonde","Type","Resume"], how="left") + + payload = [] + for _, r in df_to_save.iterrows(): + payload.append({ + "Site": r["Site"], + "Sonde": r["Sonde"], + "DateJour": r["DateJour"], + "Type": r["Type"], + "Source_Id": int(r["Source_Id"]) if pd.notna(r.get("Source_Id")) else None, + "Resume": r["Resume"], + "Statut": _none_if_empty(r.get("Statut")) or "Nouveau", + "Priorite": int(r.get("Priorite", 3)) if pd.notna(r.get("Priorite", 3)) else 3, + "Assignation": _none_if_empty(r.get("Assignation")), + "Commentaire": _none_if_empty(r.get("Commentaire")), + "Tag": _none_if_empty(r.get("Tag")), + }) + upsert_journal(payload) + st.success("Journal mis Ă  jour.") + else: + st.info("Lecture seule (rĂ©servĂ© aux superviseurs pour l’édition).") + + +# ========================================================= +# Affichage ALERTES non acquittĂ©es (global) +# ========================================================= if st.session_state.get("authenticated"): try: role = st.session_state.get("role") - # si superviseur → utilise selected_site (par dĂ©faut Saclay) - # sinon → utilise site_autorise (imposĂ© Ă  la connexion) site_selectionne = ( st.session_state.get("site_autorise") if role != "superviseur" else st.session_state.get("selected_site", "Saclay") ) - if not site_selectionne: - # pas de site → on n’essaie pas de lire la table st.info("Connectez-vous et choisissez un site pour afficher les alertes.") else: - conn = mysql.connector.connect(**db_config) - cursor = conn.cursor(dictionary=True) - - # Optionnel: whitelist pour Ă©viter toute injection sur le nom de table - SITES_AUTORISES = {"Saclay", "Meudon", "Roissy"} if site_selectionne not in SITES_AUTORISES: raise ValueError(f"Site invalide: {site_selectionne}") - + conn = get_connection() + cursor = conn.cursor(dictionary=True) table_alertes = f"Alertes_{site_selectionne}" cursor.execute( f"SELECT Sonde, Debut_defaut, Etat " @@ -264,37 +649,50 @@ if st.session_state.get("authenticated"): f"ORDER BY Debut_defaut DESC" ) alertes = cursor.fetchall() - if alertes: df_alertes = pd.DataFrame(alertes) st.subheader("🚹 Alertes non acquittĂ©es") st.dataframe(df_alertes, use_container_width=True) else: st.success("✅ Aucune alerte en cours.") - cursor.close() conn.close() - except Exception as e: st.error(f"Erreur lors de la rĂ©cupĂ©ration des alertes : {e}") else: st.info("Connectez-vous pour voir les alertes en cours.") -# --- Navigation --- +# ========================================================= +# Navigation +# ========================================================= if st.session_state["authenticated"]: - onglets = ["Accueil", "Entretien"] if st.session_state["role"] != "superviseur" else ["Accueil", "Statistiques", - "Entretien", "Traffic"] - onglet_selectionne = st.sidebar.radio("📁 Navigation", onglets, - index=onglets.index(st.session_state["onglet_actif"])) - st.session_state["onglet_actif"] = onglet_selectionne + onglets = ( + ["Accueil", "Entretien"] + if st.session_state["role"] != "superviseur" + else ["Accueil", "Statistiques", "Entretien", "Traffic", "Journal erreurs"] + ) + onglets = ( + ["Accueil", "Entretien"] + if st.session_state["role"] != "superviseur" + else ["Accueil", "Statistiques", "Entretien", "Traffic", "Journal erreurs"] + ) - site_actuel = st.session_state.get("site_autorise") if st.session_state[ - "role"] != "superviseur" else st.session_state.get( - "selected_site", "Saclay") + # 🔒 Normaliser l'onglet actif si absent de la liste (ex. compte non-admin) + if st.session_state.get("onglet_actif") not in onglets: + st.session_state["onglet_actif"] = onglets[0] + + onglet_selectionne = st.sidebar.radio( + "📁 Navigation", onglets, index=onglets.index(st.session_state["onglet_actif"]) + ) + + site_actuel = ( + st.session_state.get("site_autorise") + if st.session_state["role"] != "superviseur" + else st.session_state.get("selected_site", "Saclay") + ) date_selectionnee = st.session_state.get("selected_date", date.today()) - periode_selectionnee = st.session_state.get("selected_periode", "Toute la journĂ©e") - # --- Onglet Accueil --- + # ------------------ Accueil ------------------ if onglet_selectionne == "Accueil": try: conn = get_connection() @@ -309,8 +707,10 @@ if st.session_state["authenticated"]: date_selectionnee = st.date_input("📅 Date du relevĂ©", value=date_selectionnee) st.session_state["selected_date"] = date_selectionnee - cursor.execute(f"SELECT * FROM `{site_actuel}` WHERE DATE(Date) = %s ORDER BY Sonde, Date DESC", - (date_selectionnee.strftime("%Y-%m-%d"),)) + cursor.execute( + f"SELECT * FROM `{site_actuel}` WHERE DATE(Date) = %s ORDER BY Sonde, Date DESC", + (date_selectionnee.strftime("%Y-%m-%d"),), + ) rows = cursor.fetchall() if rows: @@ -321,16 +721,20 @@ if st.session_state["authenticated"]: df_sonde = df[df["Sonde"] == sonde_choisie].copy() df_sonde["Heure"] = df_sonde["Date"].dt.hour - tranche = st.radio("🕒 Tranche horaire :", - ["Toute la journĂ©e", "Matin (6h-12h)", "AprĂšs-midi (12h-18h)", "Nuit (18h-6h)"]) + tranche = st.radio( + "🕒 Tranche horaire :", + ["Toute la journĂ©e", "Matin (6h-12h)", "AprĂšs-midi (12h-18h)", "Nuit (18h-6h)"], + ) st.session_state["selected_periode"] = tranche - # Bouton de gĂ©nĂ©ration du PDF + + # GĂ©nĂ©ration PDF if st.button("đŸ§Ÿ GĂ©nĂ©rer le PDF du jour"): generer_pdf( site_actuel, date_selectionnee.strftime("%Y-%m-%d"), - st.session_state.get("selected_periode", "Toute la journĂ©e") + st.session_state.get("selected_periode", "Toute la journĂ©e"), ) + if tranche == "Matin (6h-12h)": df_sonde = df_sonde[(df_sonde["Heure"] >= 6) & (df_sonde["Heure"] < 12)] elif tranche == "AprĂšs-midi (12h-18h)": @@ -339,46 +743,44 @@ if st.session_state["authenticated"]: df_sonde = df_sonde[(df_sonde["Heure"] >= 18) | (df_sonde["Heure"] < 6)] seuil_temp = 10 - cursor.execute("SELECT Temp_Max FROM Sondes.Chambres_froides WHERE Lieu = %s AND Sonde = %s", - (site_actuel, sonde_choisie)) + cursor.execute( + "SELECT Temp_Max FROM Sondes.Chambres_froides WHERE Lieu = %s AND Sonde = %s", + (site_actuel, sonde_choisie), + ) seuil = cursor.fetchone() if seuil: seuil_temp = seuil["Temp_Max"] st.subheader("📊 Tableau des relevĂ©s") - def surlignage_temp(val): try: if float(val) > seuil_temp: return "color: red; font-weight: bold" - except: + except Exception: pass return "" - - styled_df = df_sonde.style.applymap(surlignage_temp, subset=["Temperature"]) + styled_df = df_sonde.style.map(surlignage_temp, subset=["Temperature"]) st.dataframe(styled_df, use_container_width=True) - st.subheader("📈 Évolution de la tempĂ©rature") fig, ax = plt.subplots(figsize=(10, 4)) - ax.plot(df_sonde["Date"], df_sonde["Temperature"], marker='o') - ax.axhline(seuil_temp, color='red', linestyle='--', label=f"Seuil {seuil_temp}°C") + ax.plot(df_sonde["Date"], df_sonde["Temperature"], marker="o") + ax.axhline(seuil_temp, linestyle="--", label=f"Seuil {seuil_temp}°C") ax.set_xlabel("Heure") ax.set_ylabel("TempĂ©rature (°C)") ax.set_title(f"{sonde_choisie} - {date_selectionnee.strftime('%d/%m/%Y')}") - ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M')) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) ax.legend() st.pyplot(fig) cursor.close() conn.close() - except Exception as e: st.error(f"Erreur : {e}") st.text(traceback.format_exc()) - # --- Onglet Statistiques --- + # ------------------ Statistiques ------------------ elif onglet_selectionne == "Statistiques": st.markdown("## 📈 Statistiques de tempĂ©rature") site = ( @@ -389,24 +791,14 @@ if st.session_state["authenticated"]: date_val = st.session_state.get("selected_date", date.today()) try: - conn = mysql.connector.connect(**db_config) + conn = get_connection() cursor = conn.cursor(dictionary=True) - - site = ( - st.session_state["site_autorise"] - if st.session_state["role"] != "superviseur" - else st.session_state.get("selected_site", "Saclay") - ) - - date_val = st.session_state.get("selected_date", date.today()) - cursor.execute( f"SELECT * FROM `{site}` WHERE DATE(Date) = %s ORDER BY Sonde, Date", - (date_val.strftime("%Y-%m-%d"),) + (date_val.strftime("%Y-%m-%d"),), ) rows = cursor.fetchall() df = pd.DataFrame(rows) - if df.empty: st.info("Aucune donnĂ©e pour cette date.") else: @@ -414,34 +806,29 @@ if st.session_state["authenticated"]: sondes = sorted(df["Sonde"].unique()) sonde = st.selectbox("Choisir une sonde :", sondes, key="selectbox_stats") df_sonde = df[df["Sonde"] == sonde] - st.subheader("Évolution journaliĂšre") fig, ax = plt.subplots(figsize=(10, 4)) - ax.plot(df_sonde["Date"], df_sonde["Temperature"], marker='o') + ax.plot(df_sonde["Date"], df_sonde["Temperature"], marker="o") ax.set_title(f"{sonde} - {date_val.strftime('%d/%m/%Y')}") ax.set_xlabel("Heure") ax.set_ylabel("TempĂ©rature (°C)") - ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M')) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) st.pyplot(fig) - cursor.close() conn.close() - except Exception as e: st.error(f"Erreur chargement statistiques : {e}") - # Tableau consignes chambres froides + # Admin Chambres froides if st.session_state["role"] == "superviseur": with st.expander("đŸ› ïž Gestion des chambres froides (administrateur)", expanded=True): if st.button("🔄 Actualiser la liste"): st.session_state["refresh_admin"] = random.randint(0, 9999) - try: - conn_admin = mysql.connector.connect(**db_config) + conn_admin = get_connection() cursor_admin = conn_admin.cursor(dictionary=True) cursor_admin.execute("SELECT * FROM Sondes.Chambres_froides WHERE Lieu = %s", (site,)) chambres = cursor_admin.fetchall() - if not chambres: st.warning("Aucune chambre froide pour ce site.") else: @@ -449,12 +836,13 @@ if st.session_state["authenticated"]: col1, col2, col3 = st.columns([3, 1, 2]) with col1: st.markdown(f"**{chambre['Sonde']}**") - with col2: - etat = st.checkbox("ON", value=(chambre["Etat"] == "ON"), - key=f"etat_{chambre['Id']}_{st.session_state.get('refresh_admin', 0)}") + etat = st.checkbox( + "ON", + value=(chambre["Etat"] == "ON"), + key=f"etat_{chambre['Id']}_{st.session_state.get('refresh_admin', 0)}", + ) new_etat = "ON" if etat else "OFF" - with col3: temp_max = chambre["Temp_Max"] moins, temp_display, plus = st.columns([1, 2, 1]) @@ -462,51 +850,52 @@ if st.session_state["authenticated"]: if st.button("â–Œ", key=f"moins_{chambre['Id']}"): temp_max -= 1 with temp_display: - st.markdown(f"
{temp_max}°C
", - unsafe_allow_html=True) + st.markdown( + f"
{temp_max}°C
", + unsafe_allow_html=True, + ) with plus: if st.button("â–Č", key=f"plus_{chambre['Id']}"): temp_max += 1 - if new_etat != chambre["Etat"] or temp_max != chambre["Temp_Max"]: cursor_admin.execute( "UPDATE Sondes.Chambres_froides SET Etat = %s, Temp_Max = %s WHERE Id = %s", - (new_etat, temp_max, chambre["Id"]) + (new_etat, temp_max, chambre["Id"]), ) conn_admin.commit() st.success(f"{chambre['Sonde']} mise Ă  jour") - cursor_admin.close() conn_admin.close() - except Exception as e: st.error(f"Erreur SQL (admin) : {e}") - # --- Onglet Entretien --- + # ------------------ Entretien ------------------ elif onglet_selectionne == "Entretien": st.header("🧰 Gestion Entretien") try: conn = get_connection() cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT Id, Sonde, En_entretien FROM Sondes.Chambres_froides WHERE Lieu = %s", (site_actuel,)) + cursor.execute( + "SELECT Id, Sonde, En_entretien FROM Sondes.Chambres_froides WHERE Lieu = %s", + (site_actuel,), + ) sondes = cursor.fetchall() - for sonde in sondes: - checked = st.checkbox(f"{sonde['Sonde']}", value=sonde['En_entretien']) - if checked != sonde['En_entretien']: - cursor.execute("UPDATE Sondes.Chambres_froides SET En_entretien = %s WHERE Id = %s", - (checked, sonde['Id'])) + checked = st.checkbox(f"{sonde['Sonde']}", value=sonde["En_entretien"]) + if checked != sonde["En_entretien"]: + cursor.execute( + "UPDATE Sondes.Chambres_froides SET En_entretien = %s WHERE Id = %s", + (checked, sonde["Id"]), + ) conn.commit() st.success(f"{sonde['Sonde']} {'mise' if checked else 'retirĂ©e'} en entretien.") - cursor.close() conn.close() - except Exception as e: st.error(f"Erreur : {e}") st.text(traceback.format_exc()) - # --- Onglet Traffic --- + # ------------------ Traffic ------------------ elif onglet_selectionne == "Traffic": st.header("🚩 Connexions rĂ©centes") try: @@ -517,10 +906,12 @@ if st.session_state["authenticated"]: ) logs = cursor.fetchall() df_logs = pd.DataFrame(logs) - st.dataframe(df_logs) + st.dataframe(df_logs, use_container_width=True) cursor.close() conn.close() except Exception as e: st.error(f"Erreur : {e}") - st.text(traceback.format_exc()) + # ------------------ Journal erreurs ------------------ + elif onglet_selectionne == "Journal erreurs": + page_journal_erreurs() diff --git a/app/supervisor_watchdog.py b/app/supervisor_watchdog.py index f7feeef..60e0b4e 100644 --- a/app/supervisor_watchdog.py +++ b/app/supervisor_watchdog.py @@ -36,7 +36,7 @@ if envoyer_mail: try: with smtplib.SMTP_SSL("smtp.mail.ovh.net", 465) as server: - server.login("services@domo91.fr", "6ZiCsVtSf9@nEHv@$^0") + server.login("services@domo91.fr", "VHq3278YA#sGV*bh#mR") server.sendmail(msg["From"], [msg["To"]], msg.as_string()) print("📧 Mail envoyĂ©.") except Exception as e: