Consolidation de Domo91

This commit is contained in:
2025-12-14 15:55:15 +01:00
parent 0c3457f30a
commit a0b6d22727
5 changed files with 773 additions and 695 deletions

17
.env
View File

@@ -6,21 +6,12 @@ DB_NAME=Sondes
AUTH_USERS=[{"user":"Michel","pass":"210462"}]
# MQTT Saclay
MQTT_HOST=54.36.188.119
MQTT_USER=Bwps
MQTT_PASS=scJ5ACj2keRfI^
# --- MQTT Meudon ---
MQTT_HOST_MEUDON=162.19.78.131
MQTT_USER_MEUDON=sondes
MQTT_PASS_MEUDON=3J@bjYP0
# MQTT
MQTT_HOST=162.19.78.131
MQTT_USER=sondes
MQTT_PASS=3J@bjYP0
MQTT_PORT_MEUDON=1883
# Topic gyrophare Meudon
GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare
# Boucle rapide du gyro
GYRO_MODE=mqtt
GYRO_CHECK_SEC=20

File diff suppressed because it is too large Load Diff

View File

@@ -27,11 +27,11 @@ DB_USER = os.getenv("DB_USER")
DB_PASS = os.getenv("DB_PASS")
DB_NAME = os.getenv("DB_NAME")
# --- MQTT Meudon ---
MQTT_HOST = os.getenv("MQTT_HOST_MEUDON")
MQTT_USER = os.getenv("MQTT_USER_MEUDON")
MQTT_PASS = os.getenv("MQTT_PASS_MEUDON")
MQTT_PORT = int(os.getenv("MQTT_PORT_MEUDON", "1883"))
# --- MQTT ---
MQTT_HOST = os.getenv("MQTT_HOST")
MQTT_USER = os.getenv("MQTT_USER")
MQTT_PASS = os.getenv("MQTT_PASS")
MQTT_PORT = int(os.getenv("MQTT_PORT", "1883"))
# Client ID (configurable, sinon suffixé avec le hostname)
MQTT_CLIENT_ID = os.getenv(

View File

@@ -26,9 +26,9 @@ DB_USER = os.getenv("DB_USER")
DB_PASS = os.getenv("DB_PASS")
DB_NAME = os.getenv("DB_NAME")
MQTT_HOST = os.getenv("MQTT_HOST")
MQTT_USER = os.getenv("MQTT_USER")
MQTT_PASS = os.getenv("MQTT_PASS")
MQTT_HOST = "54.36.188.119"
MQTT_USER = "Bwps"
MQTT_PASS = "scJ5ACj2keRfI^"
MQTT_PORT = int(os.getenv("MQTT_PORT", 1883))
GYRO_TOPIC_SACLAY = os.getenv("GYRO_MQTT_TOPIC_SACLAY", "Saclay/gyrophare")

View File

@@ -3,19 +3,16 @@ import os
import random
import traceback
from datetime import datetime, date, time
from contextlib import closing
import bcrypt
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import mysql.connector
import pandas as pd
pd.set_option('future.no_silent_downcasting', True)
pd.set_option("future.no_silent_downcasting", True)
import streamlit as st
from contextlib import closing
from dotenv import find_dotenv, load_dotenv
env_file = find_dotenv(usecwd=True)
if env_file:
load_dotenv(env_file)
from fpdf import FPDF
# =========================================================
@@ -28,7 +25,10 @@ st.write("Bienvenue sur lapplication de supervision.")
# =========================================================
# ENV & DB
# =========================================================
load_dotenv()
env_file = find_dotenv(usecwd=True)
if env_file:
load_dotenv(env_file)
db_config = {
"host": os.getenv("DB_HOST"),
"user": os.getenv("DB_USER"),
@@ -37,15 +37,51 @@ db_config = {
"autocommit": False,
}
SITES_AUTORISES = {"Saclay", "Meudon", "Roissy"} # anti-injection sur noms de tables
# Roissy n'existe pas actuellement => on garde Saclay / Meudon
SITES_AUTORISES = {"Saclay", "Meudon"} # anti-injection sur noms de tables
SITES_LISTE = sorted(SITES_AUTORISES)
def get_connection():
return mysql.connector.connect(**db_config)
# --- Gyro: lecture + badge (auto) ---
def assert_site_ok(site: str):
if site not in SITES_AUTORISES:
raise ValueError(f"Site invalide: {site}")
# =========================================================
# Session state
# =========================================================
for key, default in {
"authenticated": False,
"role": None,
"site_autorise": None,
"onglet_actif": "Accueil",
"selected_date": date.today(),
"selected_site": "Saclay",
"selected_periode": "Toute la journée",
}.items():
st.session_state.setdefault(key, default)
# =========================================================
# Sécurité mots de passe
# =========================================================
def hash_password(plain_password: str) -> str:
return bcrypt.hashpw(plain_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verifier_password(input_password: str, hash_en_base: str) -> bool:
return bcrypt.checkpw(input_password.encode("utf-8"), hash_en_base.encode("utf-8"))
# =========================================================
# Gyro: lecture + badge
# =========================================================
def fetch_gyro(site: str):
"""Retourne (etat, ts) depuis la vue v_gyro_last pour le site donné."""
assert_site_ok(site)
q = """
SELECT Etat, `Date`
FROM Sondes.v_gyro_last
@@ -53,9 +89,7 @@ def fetch_gyro(site: str):
ORDER BY `Date` DESC
LIMIT 1
"""
cnx = get_connection()
try:
cur = cnx.cursor(dictionary=True)
with closing(get_connection()) as cnx, closing(cnx.cursor(dictionary=True)) as cur:
cur.execute(q, (site,))
row = cur.fetchone()
if not row:
@@ -63,36 +97,25 @@ def fetch_gyro(site: str):
etat = (row.get("Etat") or "").strip().upper()
ts = row.get("Date")
return etat, ts
finally:
try:
cur.close()
except Exception:
pass
try:
cnx.close()
except Exception:
pass
def render_gyro_badge(site: str, stale_after_min: int = 10):
"""Affiche un voyant Gyro (vert/rouge/orange) + fraîcheur des données."""
etat, ts = fetch_gyro(site)
# Etat → couleur/label
if etat in ("ON", "1"):
color, label = "#ef4444", "GYRO ON" # Rouge = gyro actif
color, label = "#ef4444", "GYRO ON"
elif etat in ("OFF", "0"):
color, label = "#22c55e", "GYRO OFF" # Vert = gyro arrêté
color, label = "#22c55e", "GYRO OFF"
elif etat in ("ALERTE", "ALARM", "ALARMED"):
color, label = "#f59e0b", "GYRO ALERTE" # Orange = alerte
color, label = "#f59e0b", "GYRO ALERTE"
else:
color, label = "#9E9E9E", "GYRO INCONNU"
# Fraîcheur
stale = True
age_txt = ""
if ts is not None:
try:
# ts provient normalement de MySQL déjà en datetime
from datetime import datetime as _dt
now = _dt.now(ts.tzinfo) if hasattr(ts, "tzinfo") and ts.tzinfo else _dt.now()
mins = int((now - ts).total_seconds() // 60)
@@ -117,37 +140,10 @@ def render_gyro_badge(site: str, stale_after_min: int = 10):
</div>
</div>
""", unsafe_allow_html=True)
def get_conn():
return mysql.connector.connect(**db_config)
# =========================================================
# Session state
# =========================================================
for key, default in {
"authenticated": False,
"role": None,
"site_autorise": None,
"onglet_actif": "Accueil",
"selected_date": date.today(),
"selected_site": "Saclay",
"selected_periode": "Toute la journée",
}.items():
st.session_state.setdefault(key, default)
# =========================================================
# Sécurité mots de passe
# =========================================================
def hash_password(plain_password):
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"))
# =========================================================
# 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
# Bootstrap schéma : Journal_Erreurs
# =========================================================
def ensure_schema():
ddl_generated = """
@@ -198,7 +194,7 @@ def ensure_schema():
]
triggers_fallback = [
"""
CREATE TRIGGER IF NOT EXISTS trg_je_bi
CREATE TRIGGER trg_je_bi
BEFORE INSERT ON Sondes.Journal_Erreurs
FOR EACH ROW
BEGIN
@@ -206,7 +202,7 @@ def ensure_schema():
END
""",
"""
CREATE TRIGGER IF NOT EXISTS trg_je_bu
CREATE TRIGGER trg_je_bu
BEFORE UPDATE ON Sondes.Journal_Erreurs
FOR EACH ROW
BEGIN
@@ -216,19 +212,17 @@ def ensure_schema():
]
try:
with closing(get_conn()) as cnx, closing(cnx.cursor()) as cur:
with closing(get_connection()) 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:
with closing(get_connection()) 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}")
@@ -241,12 +235,13 @@ def ensure_schema():
pass
cnx.commit()
# Exécution à limport
try:
ensure_schema()
except Exception as e:
st.warning(f"Init schéma Journal_Erreurs : {e}")
# =========================================================
# Connexion utilisateur
# =========================================================
@@ -256,8 +251,7 @@ if not st.session_state.get("authenticated", False):
if st.sidebar.button("Se connecter"):
try:
conn = get_connection()
cursor = conn.cursor(dictionary=True)
with closing(get_connection()) as conn, closing(conn.cursor(dictionary=True)) as cursor:
cursor.execute(
"""
SELECT NomUtilisateur, role, MotDePasseHash, Site, DateExpiration
@@ -280,8 +274,9 @@ if not st.session_state.get("authenticated", False):
"authenticated": True,
"role": result["role"],
"site_autorise": result["Site"],
"onglet_actif": "Accueil", # 👈 reset
"onglet_actif": "Accueil",
})
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
cursor.execute(
"""
@@ -293,33 +288,40 @@ if not st.session_state.get("authenticated", False):
conn.commit()
st.rerun()
cursor.close()
conn.close()
except Exception as e:
st.sidebar.error(f"Erreur connexion : {e}")
st.sidebar.text(traceback.format_exc())
else:
st.sidebar.success(f"Connecté ({st.session_state.get('role')})")
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.session_state["onglet_actif"] = "Accueil"
st.rerun()
# =========================================================
# PDF
# =========================================================
def generer_pdf(site, date_str, periode):
def generer_pdf(site: str, date_str: str, periode: str):
assert_site_ok(site)
st.info(f"Génération du rapport PDF pour {site} à la date {date_str} ({periode})")
try:
conn = get_connection()
pdf_cursor = conn.cursor(dictionary=True)
# Relevés
pdf_cursor.execute(
plages = {
"Toute la journée": (time(0, 0), time(23, 59)),
"Matin (6h-12h)": (time(6, 0), time(12, 0)),
"Après-midi (12h-18h)": (time(12, 0), time(18, 0)),
"Nuit (18h-6h)": (time(18, 0), time(6, 0)),
}
try:
with closing(get_connection()) as conn, closing(conn.cursor(dictionary=True)) as cur:
cur.execute(
f"SELECT Sonde, Date, Temperature FROM `{site}` WHERE DATE(Date) = %s ORDER BY Sonde, Date",
(date_str,),
)
rows = pdf_cursor.fetchall()
rows = cur.fetchall()
df = pd.DataFrame(rows)
if df.empty:
st.warning("Aucune donnée ce jour.")
@@ -328,13 +330,6 @@ def generer_pdf(site, date_str, periode):
df["Heure"] = pd.to_datetime(df["Date"]).dt.strftime("%H:%M")
df["Heure_obj"] = pd.to_datetime(df["Date"]).dt.time
# Périodes
plages = {
"Toute la journée": (time(0, 0), time(23, 59)),
"Matin (6h-12h)": (time(6, 0), time(12, 0)),
"Après-midi (12h-18h)": (time(12, 0), time(18, 0)),
"Nuit (18h-6h)": (time(18, 0), time(6, 0)),
}
heure_debut, heure_fin = plages.get(periode, (time(0, 0), time(23, 59)))
if heure_debut < heure_fin:
df = df[(df["Heure_obj"] >= heure_debut) & (df["Heure_obj"] <= heure_fin)]
@@ -346,16 +341,12 @@ def generer_pdf(site, date_str, periode):
df_sonde = df[df["Sonde"] == sonde]
releves[sonde] = list(zip(df_sonde["Heure"], df_sonde["Temperature"]))
# Alertes
table_alertes = f"Alertes_{site}"
pdf_cursor.execute(
cur.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()
alertes = cur.fetchall()
class RapportPDF(FPDF):
def header(self):
@@ -397,7 +388,9 @@ def generer_pdf(site, date_str, periode):
self.cell(30, 6, f"{t1:.2f}", border=1)
else:
self.cell(70, 6, "", border=0)
self.cell(20, 6, "", border=0)
if i < len(col2):
h2, t2 = col2[i]
self.cell(40, 6, h2, border=1)
@@ -436,11 +429,13 @@ def generer_pdf(site, date_str, periode):
mime="application/pdf",
)
except Exception as err1:
st.error(f"Erreur lors de la génération du PDF : {err1}")
except Exception as err:
st.error(f"Erreur lors de la génération du PDF : {err}")
st.text(traceback.format_exc())
# =========================================================
# Fonctions Journal erreurs (SQL)
# Journal erreurs (SQL)
# =========================================================
def _get_site_courant():
role = st.session_state.get("role")
@@ -448,7 +443,9 @@ def _get_site_courant():
return st.session_state.get("site_autorise")
return st.session_state.get("selected_site", "Saclay")
def load_alertes(site: str, jour: date):
assert_site_ok(site)
table_alertes = f"Alertes_{site}"
q = f"""
SELECT
@@ -463,22 +460,18 @@ def load_alertes(site: str, jour: date):
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:
with closing(get_connection()) 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"]
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
"""
assert_site_ok(site)
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
gap_threshold_min = 20
jump_deg = 10
min_phys, max_phys = -60, 120
q = f"""
@@ -538,26 +531,29 @@ def load_anomalies_auto(site: str, jour: date):
ORDER BY Sonde;
"""
params = (jour, site, jour, site, jour, site, jour)
with closing(get_conn()) as cnx, closing(cnx.cursor(dictionary=True)) as cur:
with closing(get_connection()) as cnx, closing(cnx.cursor(dictionary=True)) as cur:
cur.execute(q, params)
rows = cur.fetchall()
cols = ["Source_Id","Site","Sonde","DateJour","Type","Resume"]
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):
assert_site_ok(site)
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:
with closing(get_connection()) 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"]
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
@@ -575,15 +571,17 @@ def upsert_journal(rows: list[dict]):
Tag=VALUES(Tag),
UpdatedAt=CURRENT_TIMESTAMP;
"""
with closing(get_conn()) as cnx, closing(cnx.cursor()) as cur:
with closing(get_connection()) 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 dabord choisir un site sur la page daccueil.")
@@ -595,7 +593,6 @@ def page_journal_erreurs():
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)
@@ -605,54 +602,69 @@ def page_journal_erreurs():
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
def _source_norm(x):
return 0 if pd.isna(x) else int(x)
key_join = ["Site","Sonde","DateJour","Type","Source_Id","Resume"]
df = base.merge(df_saved, on=key_join, how="left", suffixes=("","_saved"))
# base : garantir Source_Id + Source_Id_norm
if "Source_Id" not in base.columns:
base["Source_Id"] = pd.NA
base["Source_Id_norm"] = base["Source_Id"].apply(_source_norm)
# 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
# df_saved : garantir colonnes et Key, même si vide
if df_saved is None or df_saved.empty:
df_saved = pd.DataFrame(columns=["Key", "Statut", "Priorite", "Assignation", "Commentaire", "Tag"])
else:
df[c] = df[c].astype("string").fillna("") # tout en string, pas de NaN
if "Source_Id" not in df_saved.columns:
df_saved["Source_Id"] = pd.NA
df_saved["Source_Id_norm"] = df_saved["Source_Id"].apply(_source_norm)
# Priorité = entier nullable (évite 3.0)
df_saved["Key"] = (
df_saved["Site"].astype(str) + "|" +
df_saved["Sonde"].astype(str) + "|" +
df_saved["DateJour"].astype(str) + "|" +
df_saved["Type"].astype(str) + "|" +
df_saved["Source_Id_norm"].astype(str)
)
for c in ["Statut", "Priorite", "Assignation", "Commentaire", "Tag"]:
if c not in df_saved.columns:
df_saved[c] = pd.NA
# base Key (toujours)
base["Key"] = (
base["Site"].astype(str) + "|" +
base["Sonde"].astype(str) + "|" +
base["DateJour"].astype(str) + "|" +
base["Type"].astype(str) + "|" +
base["Source_Id_norm"].astype(str)
)
df = base.merge(
df_saved[["Key", "Statut", "Priorite", "Assignation", "Commentaire", "Tag"]],
on="Key",
how="left",
)
# Defaults & types
df["Statut"] = df["Statut"].fillna("Nouveau").astype("string")
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"]
for c in ["Assignation", "Commentaire", "Tag"]:
df[c] = df[c].astype("string").fillna("")
for c in ["Sonde", "Type", "Resume"]:
df[c] = df[c].astype("string").fillna("")
st.subheader("Synthèse (éditable)")
edit_cols = ["Sonde", "Type", "Resume", "Statut", "Priorite", "Assignation", "Tag", "Commentaire"]
disabled_cols = ["Sonde", "Type", "Resume"]
view_cols = ["Key", "Sonde", "Type", "Resume", "Statut", "Priorite", "Assignation", "Tag", "Commentaire"]
disabled_cols = ["Key", "Sonde", "Type", "Resume"]
editable = st.data_editor(
df[edit_cols].copy(), # <<< important
df[view_cols].copy(),
disabled=disabled_cols,
use_container_width=True,
hide_index=True,
column_config={
"Key": st.column_config.TextColumn("Key", disabled=True),
"Statut": st.column_config.SelectboxColumn(
"Statut", options=["Nouveau", "En cours", "Planifié", "Clos"], help="État de la tâche"
),
@@ -668,18 +680,19 @@ def page_journal_erreurs():
}
)
# --- 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
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")
df_keys = df[["Key", "Site", "Sonde", "DateJour", "Type", "Source_Id", "Resume"]].copy()
df_to_save = editable.merge(df_keys, on="Key", how="left")
payload = []
for _, r in df_to_save.iterrows():
@@ -713,14 +726,10 @@ if st.session_state.get("authenticated"):
if role != "superviseur"
else st.session_state.get("selected_site", "Saclay")
)
if not site_selectionne:
st.info("Connectez-vous et choisissez un site pour afficher les alertes.")
else:
if site_selectionne not in SITES_AUTORISES:
raise ValueError(f"Site invalide: {site_selectionne}")
conn = get_connection()
cursor = conn.cursor(dictionary=True)
if site_selectionne:
assert_site_ok(site_selectionne)
table_alertes = f"Alertes_{site_selectionne}"
with closing(get_connection()) as conn, closing(conn.cursor(dictionary=True)) as cursor:
cursor.execute(
f"SELECT Sonde, Debut_defaut, Etat "
f"FROM `{table_alertes}` "
@@ -728,45 +737,47 @@ 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)
st.dataframe(pd.DataFrame(alertes), use_container_width=True)
else:
st.success("✅ Aucune alerte en cours.")
cursor.close()
conn.close()
else:
st.info("Connectez-vous et choisissez un site pour afficher les alertes.")
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.")
st.text(traceback.format_exc())
else:
pass
# =========================================================
# Navigation
# Navigation + Pages (CORRIGÉ : pour superviseur ET utilisateur)
# =========================================================
if st.session_state["authenticated"]:
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"]
)
if st.session_state.get("authenticated"):
# 🔒 Normaliser l'onglet actif si absent de la liste (ex. compte non-admin)
# Onglets selon rôle
if st.session_state.get("role") != "superviseur":
onglets = ["Accueil", "Statistiques"]
else:
onglets = ["Accueil", "Statistiques", "Traffic", "Journal erreurs"]
# Normaliser l'onglet actif
if st.session_state.get("onglet_actif") not in onglets:
st.session_state["onglet_actif"] = onglets[0]
# Menu (créé pour tous)
onglet_selectionne = st.sidebar.radio(
"📁 Navigation", onglets, index=onglets.index(st.session_state["onglet_actif"])
"📁 Navigation",
onglets,
index=onglets.index(st.session_state["onglet_actif"]),
)
st.session_state["onglet_actif"] = onglet_selectionne
# Contexte commun
site_actuel = (
st.session_state.get("site_autorise")
if st.session_state["role"] != "superviseur"
if st.session_state.get("role") != "superviseur"
else st.session_state.get("selected_site", "Saclay")
)
date_selectionnee = st.session_state.get("selected_date", date.today())
@@ -774,28 +785,40 @@ if st.session_state["authenticated"]:
# ------------------ Accueil ------------------
if onglet_selectionne == "Accueil":
try:
# Site imposé ou sélection admin
if st.session_state.get("role") == "superviseur":
# sélection possible
if site_actuel not in SITES_LISTE:
site_actuel = SITES_LISTE[0]
site_actuel = st.selectbox(
"📍 Choisissez un site :",
SITES_LISTE,
index=SITES_LISTE.index(site_actuel) if site_actuel in SITES_LISTE else 0
)
st.session_state["selected_site"] = site_actuel
else:
st.info(f"Site imposé : {site_actuel}")
# --- Voyant Gyro pour le site courant ---
assert_site_ok(site_actuel)
# Voyant Gyro
st.subheader(f"🚨 Statut Gyro — {site_actuel}")
try:
st.autorefresh(interval=30000, key="gyro_autorefresh")
except Exception:
pass
render_gyro_badge(site_actuel)
# ----------------------------------------
conn = get_connection()
cursor = conn.cursor(dictionary=True)
if st.session_state["role"] == "superviseur":
site_actuel = st.selectbox("📍 Choisissez un site :", ["Saclay", "Meudon"], index=0)
st.session_state["selected_site"] = site_actuel
else:
st.info(f"Site imposé : {site_actuel}")
# Date
date_selectionnee = st.date_input("📅 Date du relevé", value=date_selectionnee)
st.session_state["selected_date"] = date_selectionnee
rows = []
df_sonde = pd.DataFrame()
seuil_temp = 10.0
sonde_choisie = None
with closing(get_connection()) as conn, closing(conn.cursor(dictionary=True)) as cursor:
cursor.execute(
f"SELECT * FROM `{site_actuel}` WHERE DATE(Date) = %s ORDER BY Sonde, Date DESC",
(date_selectionnee.strftime("%Y-%m-%d"),),
@@ -807,6 +830,7 @@ if st.session_state["authenticated"]:
df["Date"] = pd.to_datetime(df["Date"])
sondes = sorted(df["Sonde"].unique())
sonde_choisie = st.selectbox("🧪 Choisissez une sonde :", sondes)
df_sonde = df[df["Sonde"] == sonde_choisie].copy()
df_sonde["Heure"] = df_sonde["Date"].dt.hour
@@ -816,7 +840,6 @@ if st.session_state["authenticated"]:
)
st.session_state["selected_periode"] = tranche
# Génération PDF
if st.button("🧾 Générer le PDF du jour"):
generer_pdf(
site_actuel,
@@ -824,6 +847,7 @@ if st.session_state["authenticated"]:
st.session_state.get("selected_periode", "Toute la journée"),
)
# Filtre tranche
if tranche == "Matin (6h-12h)":
df_sonde = df_sonde[(df_sonde["Heure"] >= 6) & (df_sonde["Heure"] < 12)]
elif tranche == "Après-midi (12h-18h)":
@@ -831,15 +855,16 @@ if st.session_state["authenticated"]:
elif tranche == "Nuit (18h-6h)":
df_sonde = df_sonde[(df_sonde["Heure"] >= 18) | (df_sonde["Heure"] < 6)]
seuil_temp = 10
# Seuil
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"]
if seuil and seuil.get("Temp_Max") is not None:
seuil_temp = float(seuil["Temp_Max"])
if rows and not df_sonde.empty:
st.subheader("📊 Tableau des relevés")
def surlignage_temp(val):
@@ -852,6 +877,7 @@ if st.session_state["authenticated"]:
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")
@@ -862,9 +888,9 @@ if st.session_state["authenticated"]:
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
ax.legend()
st.pyplot(fig)
elif not rows:
st.info("Aucun relevé pour cette date.")
cursor.close()
conn.close()
except Exception as e:
st.error(f"Erreur : {e}")
st.text(traceback.format_exc())
@@ -872,21 +898,24 @@ if st.session_state["authenticated"]:
# ------------------ Statistiques ------------------
elif onglet_selectionne == "Statistiques":
st.markdown("## 📈 Statistiques de température")
site = (
st.session_state["site_autorise"]
if st.session_state["role"] != "superviseur"
st.session_state.get("site_autorise")
if st.session_state.get("role") != "superviseur"
else st.session_state.get("selected_site", "Saclay")
)
assert_site_ok(site)
date_val = st.session_state.get("selected_date", date.today())
try:
conn = get_connection()
cursor = conn.cursor(dictionary=True)
with closing(get_connection()) as conn, closing(conn.cursor(dictionary=True)) as cursor:
cursor.execute(
f"SELECT * FROM `{site}` WHERE DATE(Date) = %s ORDER BY Sonde, Date",
(date_val.strftime("%Y-%m-%d"),),
)
rows = cursor.fetchall()
df = pd.DataFrame(rows)
if df.empty:
st.info("Aucune donnée pour cette date.")
@@ -895,6 +924,7 @@ 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")
@@ -903,21 +933,22 @@ if st.session_state["authenticated"]:
ax.set_ylabel("Température (°C)")
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}")
st.text(traceback.format_exc())
# Admin Chambres froides
if st.session_state["role"] == "superviseur":
if st.session_state.get("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 = get_connection()
cursor_admin = conn_admin.cursor(dictionary=True)
with closing(get_connection()) as conn_admin, closing(conn_admin.cursor(dictionary=True)) as cursor_admin:
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:
@@ -925,6 +956,7 @@ 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",
@@ -932,8 +964,9 @@ if st.session_state["authenticated"]:
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"]
temp_max = int(chambre["Temp_Max"])
moins, temp_display, plus = st.columns([1, 2, 1])
with moins:
if st.button("", key=f"moins_{chambre['Id']}"):
@@ -946,6 +979,7 @@ if st.session_state["authenticated"]:
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",
@@ -953,54 +987,36 @@ if st.session_state["authenticated"]:
)
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}")
# ------------------ 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,),
)
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"]),
)
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())
# ------------------ Traffic ------------------
elif onglet_selectionne == "Traffic":
st.header("🚦 Connexions récentes")
try:
conn = get_connection()
cursor = conn.cursor(dictionary=True)
with closing(get_connection()) as conn, closing(conn.cursor(dictionary=True)) as cursor:
cursor.execute(
"SELECT Utilisateur, Lieu, Date_Connexion FROM Sondes.Connexion_Log ORDER BY Date_Connexion DESC LIMIT 100"
"SELECT Utilisateur, Lieu, Date_Connexion "
"FROM Sondes.Connexion_Log "
"WHERE Utilisateur NOT LIKE %s "
"ORDER BY Date_Connexion DESC "
"LIMIT 100",
("Michel%",),
)
logs = cursor.fetchall()
df_logs = pd.DataFrame(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()
else:
st.info("Connectez-vous pour accéder à lapplication.")