Compare commits

...

2 Commits

Author SHA1 Message Date
088e7a4821 Cosmétique Tracker et sécurité 2025-08-26 14:09:52 +02:00
cd6bb7b5cf Ajout date mise en service Tracker 2025-08-26 11:05:06 +02:00
4 changed files with 147 additions and 78 deletions

1
.env
View File

@@ -3,6 +3,7 @@ DB_HOST=162.19.78.131
DB_USER=excel_auth DB_USER=excel_auth
DB_PASSWORD=%n#%3Lay1MPa$%kR^5@ DB_PASSWORD=%n#%3Lay1MPa$%kR^5@
DB_NAME=Sondes DB_NAME=Sondes
AUTH_USERS=[{"user":"Michel","pass":"921#%!LC3^71509PiyK"}]
# paramètres mail # paramètres mail
SMTP_HOST=smtp.mail.ovh.net SMTP_HOST=smtp.mail.ovh.net

View File

@@ -1,34 +0,0 @@
🔁 Mise à jour de la branche product depuis develop : workflow recommandé
⚙️ Étapes standards :
Travaille uniquement sur develop
Quand tu es prêt à mettre en production :
git checkout product
git merge develop
git push origin product
Cela fusionne proprement toutes les modifications testées de develop vers product.
🖥️ Et sur le VPS ?
Si ton VPS suit la branche product, alors une fois que tu as fait :
git push origin product (sur pycharm)
Tu peux te connecter à ton VPS et faire :
cd /home/debian/Gestion_sondes git pull origin product
🔵 Mettre à jour supervisor
cd /etc/supervisor/conf.d
supervisorctl restart all
Et pour vérifier le bon état des services
supervisorctl status
exit
exit
🔵 4. Vérification
pour vérifier que les scripts tournent bien.
#tail -f /home/debian/Gestion_sondes/Logs/monitor.csv

BIN
README.md

Binary file not shown.

View File

@@ -2,12 +2,19 @@
# ------------------------------------------------------------- # -------------------------------------------------------------
# Streamlit — Gestion de la table MySQL Sondes.tracker # Streamlit — Gestion de la table MySQL Sondes.tracker
# ------------------------------------------------------------- # -------------------------------------------------------------
# Schéma attendu : id (PK), address, lieu, repere (optionnel), res_bits, date (timestamp) # Schéma attendu : id (PK), address, lieu, repere, mise_en_service (DATE), res_bits, date (timestamp)
# Authentification intégrée via .env (AUTH_USERS JSON)
# ------------------------------------------------------------- # -------------------------------------------------------------
import os import os
import re import re
import time import time
import json
import hmac
from hashlib import sha256 # (non utilisé directement mais laissé si besoin d'évolution)
from typing import Optional
from datetime import date
import pandas as pd import pandas as pd
import streamlit as st import streamlit as st
import mysql.connector as mysql import mysql.connector as mysql
@@ -17,15 +24,26 @@ from dotenv import load_dotenv
# ========================== # ==========================
# Configuration / Constantes # Configuration / Constantes
# ========================== # ==========================
load_dotenv() # lit .env si présent
TABLE_DB = os.getenv("MYSQL_DB", "Sondes") TABLE_DB = os.getenv("MYSQL_DB", "Sondes")
TABLE_NAME = "tracker" TABLE_NAME = "tracker"
COL_ID = "id" COL_ID = "id"
COL_ADDRESS = "address" COL_ADDRESS = "address"
COL_LIEU = "lieu" COL_LIEU = "lieu"
COL_REPERE = "repere" # 🆕 Nouveau champ COL_REPERE = "repere"
COL_MES = "mise_en_service" # DATE
COL_RESBITS = "res_bits" COL_RESBITS = "res_bits"
COL_DATE = "date" COL_DATE = "date"
DB_CFG = dict(
host=os.getenv("DB_HOST"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD"),
database=os.getenv("DB_NAME"),
port=int(os.getenv("MYSQL_PORT", "3306")),
)
# Regex d'une ROM code DS18B20 au format {0x28,0xFF,0xAA,0xBB,0xCC,0xDD,0xEE,0xCRC} # Regex d'une ROM code DS18B20 au format {0x28,0xFF,0xAA,0xBB,0xCC,0xDD,0xEE,0xCRC}
ROM_REGEX = re.compile(r"^\{(?:0x[0-9A-Fa-f]{2},){7}0x[0-9A-Fa-f]{2}\}$") ROM_REGEX = re.compile(r"^\{(?:0x[0-9A-Fa-f]{2},){7}0x[0-9A-Fa-f]{2}\}$")
@@ -37,19 +55,55 @@ RES_MAP = {
12: {"precision": 0.0625,"tconv_ms": 750}, 12: {"precision": 0.0625,"tconv_ms": 750},
} }
# ==================
# Authentification via .env (AUTH_USERS)
# ==================
AUTH_USERS_RAW = os.getenv("AUTH_USERS", "[]")
def _load_users() -> dict:
try:
data = json.loads(AUTH_USERS_RAW)
return {str(d.get("user", "")).strip(): str(d.get("pass", "")) for d in data if d.get("user") and d.get("pass")}
except Exception:
return {}
USERS = _load_users()
def _constant_time_equals(a: str, b: str) -> bool:
return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
def verify_credentials(username: str, password: str) -> bool:
if not username or not password:
return False
expected = USERS.get(username.strip())
if expected is None:
return False
# NOTE : stockage en clair pour l'exemple. En prod, préférer un hash (bcrypt/argon2).
return _constant_time_equals(password, expected)
def require_login() -> Optional[str]:
if st.session_state.get("auth_ok") and st.session_state.get("auth_user"):
return st.session_state.get("auth_user")
st.markdown("<h2>🔒 Accès restreint</h2>", unsafe_allow_html=True)
with st.form("login_form", clear_on_submit=False):
username = st.text_input("Utilisateur")
password = st.text_input("Mot de passe", type="password")
ok = st.form_submit_button("Se connecter")
if ok:
if verify_credentials(username, password):
st.session_state["auth_ok"] = True
st.session_state["auth_user"] = username.strip()
st.success("Connexion réussie.")
time.sleep(0.3)
st.rerun()
else:
st.error("Identifiants invalides.")
st.stop()
# ================== # ==================
# Accès Base de Données # Accès Base de Données
# ================== # ==================
load_dotenv() # lit .env si présent
DB_CFG = dict(
host=os.getenv("DB_HOST"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD"),
database=os.getenv("DB_NAME"),
port=int(os.getenv("MYSQL_PORT", "3306")),
)
@contextmanager @contextmanager
def get_conn(): def get_conn():
conn = mysql.connect(**DB_CFG) conn = mysql.connect(**DB_CFG)
@@ -64,7 +118,7 @@ def get_conn():
def fetch_trackers(where_lieu: str | None = None) -> pd.DataFrame: def fetch_trackers(where_lieu: str | None = None) -> pd.DataFrame:
query = ( query = (
f"SELECT {COL_ID}, {COL_ADDRESS}, {COL_LIEU}, {COL_REPERE}, {COL_RESBITS}, {COL_DATE} " f"SELECT {COL_ID}, {COL_ADDRESS}, {COL_LIEU}, {COL_REPERE}, {COL_MES}, {COL_RESBITS}, {COL_DATE} "
f"FROM {TABLE_NAME}" f"FROM {TABLE_NAME}"
) )
params = [] params = []
@@ -77,27 +131,40 @@ def fetch_trackers(where_lieu: str | None = None) -> pd.DataFrame:
return df return df
def insert_tracker(address: str, lieu: str, res_bits: int, repere: str | None = None) -> int: def insert_tracker(address: str, lieu: str, res_bits: int, repere: str | None = None, mise_en_service: date | None = None) -> int:
sql = f""" sql = f"""
INSERT INTO {TABLE_NAME} ({COL_ADDRESS}, {COL_LIEU}, {COL_REPERE}, {COL_RESBITS}) INSERT INTO {TABLE_NAME} ({COL_ADDRESS}, {COL_LIEU}, {COL_REPERE}, {COL_MES}, {COL_RESBITS})
VALUES (%s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s)
""" """
with get_conn() as conn: with get_conn() as conn:
cur = conn.cursor() cur = conn.cursor()
cur.execute(sql, (address, lieu, (repere.strip() if repere and repere.strip() else None), res_bits)) cur.execute(sql, (
address,
lieu,
(repere.strip() if repere and str(repere).strip() else None),
mise_en_service,
res_bits,
))
conn.commit() conn.commit()
return cur.lastrowid return cur.lastrowid
def update_tracker(row_id: int, address: str, lieu: str, res_bits: int, repere: str | None) -> None: def update_tracker(row_id: int, address: str, lieu: str, res_bits: int, repere: str | None, mise_en_service: date | None) -> None:
sql = f""" sql = f"""
UPDATE {TABLE_NAME} UPDATE {TABLE_NAME}
SET {COL_ADDRESS}=%s, {COL_LIEU}=%s, {COL_REPERE}=%s, {COL_RESBITS}=%s SET {COL_ADDRESS}=%s, {COL_LIEU}=%s, {COL_REPERE}=%s, {COL_MES}=%s, {COL_RESBITS}=%s
WHERE {COL_ID}=%s WHERE {COL_ID}=%s
""" """
with get_conn() as conn: with get_conn() as conn:
cur = conn.cursor() cur = conn.cursor()
cur.execute(sql, (address, lieu, (repere.strip() if repere and repere.strip() else None), res_bits, row_id)) cur.execute(sql, (
address,
lieu,
(repere.strip() if repere and str(repere).strip() else None),
mise_en_service,
res_bits,
row_id,
))
conn.commit() conn.commit()
@@ -112,38 +179,53 @@ def delete_tracker(row_id: int) -> None:
# Utilitaires UI # Utilitaires UI
# -------------- # --------------
def is_valid_rom(address: str) -> bool:
return bool(ROM_REGEX.match(address.strip()))
def rom_help() -> str: def rom_help() -> str:
"""Message d'aide sur le format des adresses ROM DS18B20."""
return ( return (
"Format attendu : `{0x28,0xFF,0xAA,0xBB,0xCC,0xDD,0xEE,0x12}` (8 octets en hex).\n" "Format attendu : `{0x28,0xFF,0xAA,0xBB,0xCC,0xDD,0xEE,0x12}` "
"(8 octets en hex).\n"
"Le premier octet (famille) est souvent 0x28 pour DS18B20." "Le premier octet (famille) est souvent 0x28 pour DS18B20."
) )
def is_valid_rom(address: str) -> bool:
"""Vérifie que l'adresse saisie correspond bien au format attendu."""
import re
ROM_REGEX = re.compile(r"^\{(?:0x[0-9A-Fa-f]{2},){7}0x[0-9A-Fa-f]{2}\}$")
return bool(ROM_REGEX.match(address.strip()))
def res_label(bits: int) -> str: def res_label(bits: int) -> str:
"""Retourne un label lisible pour la résolution DS18B20."""
RES_MAP = {
9: {"precision": 0.5, "tconv_ms": 94},
10: {"precision": 0.25, "tconv_ms": 188},
11: {"precision": 0.125, "tconv_ms": 375},
12: {"precision": 0.0625,"tconv_ms": 750},
}
info = RES_MAP.get(bits) info = RES_MAP.get(bits)
if not info: if not info:
return f"{bits} bits (inconnu)" return f"{bits} bits (inconnu)"
return f"{bits} bits (±{info['precision']}°C, {info['tconv_ms']} ms)" return f"{bits} bits (±{info['precision']}°C, {info['tconv_ms']} ms)"
# ================== # ==================
# Application Streamlit # Application Streamlit
# ================== # ==================
st.set_page_config(page_title="Gestion des sondes — tracker", page_icon="🌡️", layout="wide") st.set_page_config(page_title="Tracker", page_icon="🌡️", layout="wide")
st.title("🌡️ Gestion de Sondes.tracker") # 🔐 Exige la connexion avant d'afficher l'app
user = require_login()
st.title("🌡️ Gestion du parc sondes en stock ou installées")
with st.expander("Paramètres de connexion (lecture seule)"): with st.expander("Paramètres de connexion (lecture seule)"):
st.write({k: ("***" if k in {"password"} else v) for k, v in DB_CFG.items()}) st.write({k: ("***" if k in {"password"} else v) for k, v in DB_CFG.items()})
st.caption("Configurez ces valeurs via le fichier .env (MYSQL_HOST, MYSQL_DB, MYSQL_USER, MYSQL_PASSWORD, MYSQL_PORT)") st.caption("Configurez ces valeurs via le fichier .env (MYSQL_HOST, MYSQL_DB, MYSQL_USER, MYSQL_PASSWORD, MYSQL_PORT, AUTH_USERS)")
# Barre latérale — Filtres & Actions # Barre latérale — Filtres & Actions
st.sidebar.header("Filtres & Actions") st.sidebar.header("Filtres & Actions")
# Info utilisateur connecté
st.sidebar.caption(f"Connecté en tant que **{user}**")
# Récupération de la liste des lieux existants # Récupération de la liste des lieux existants
_all = fetch_trackers() _all = fetch_trackers()
lieux = sorted([x for x in _all[COL_LIEU].dropna().unique()]) if not _all.empty else [] lieux = sorted([x for x in _all[COL_LIEU].dropna().unique()]) if not _all.empty else []
@@ -164,7 +246,8 @@ st.sidebar.subheader("Ajouter une sonde")
with st.sidebar.form("add_form", clear_on_submit=True): with st.sidebar.form("add_form", clear_on_submit=True):
new_address = st.text_input("Adresse ROM", placeholder="{0x28,0xFF,...}", help=rom_help()) new_address = st.text_input("Adresse ROM", placeholder="{0x28,0xFF,...}", help=rom_help())
new_lieu = st.text_input("Lieu d'installation", placeholder="Ex: Chaufferie / Saclay") new_lieu = st.text_input("Lieu d'installation", placeholder="Ex: Chaufferie / Saclay")
new_repere = st.text_input("Repère (optionnel)", placeholder="Ex: R1, Panneau N°, Local 3…") # 🆕 new_repere = st.text_input("Repère (optionnel)", placeholder="Ex: R1, Panneau N°, Local 3…")
new_mes = st.date_input("Mise en service (optionnel)", value=None, format="YYYY-MM-DD")
new_res = st.selectbox("Résolution (bits)", options=[9,10,11,12]) new_res = st.selectbox("Résolution (bits)", options=[9,10,11,12])
submitted = st.form_submit_button("Ajouter") submitted = st.form_submit_button("Ajouter")
if submitted: if submitted:
@@ -173,22 +256,28 @@ with st.sidebar.form("add_form", clear_on_submit=True):
elif not new_lieu.strip(): elif not new_lieu.strip():
st.warning("Lieu requis.") st.warning("Lieu requis.")
else: else:
rid = insert_tracker(new_address.strip(), new_lieu.strip(), int(new_res), new_repere) rid = insert_tracker(
new_address.strip(),
new_lieu.strip(),
int(new_res),
new_repere,
new_mes if isinstance(new_mes, date) else None,
)
st.success(f"Sonde ajoutée (id={rid}).") st.success(f"Sonde ajoutée (id={rid}).")
time.sleep(0.6) time.sleep(0.6)
st.rerun() st.rerun()
# Bouton de déconnexion (EXIT) # Bouton de déconnexion (session Streamlit)
st.sidebar.divider() st.sidebar.divider()
st.sidebar.subheader("Sécurité") if st.sidebar.button("Se déconnecter"):
if st.sidebar.button("EXIT / Déconnexion", type="primary"):
for _k in list(st.session_state.keys()): for _k in list(st.session_state.keys()):
try: try:
del st.session_state[_k] del st.session_state[_k]
except Exception: except Exception:
pass pass
st.markdown('<meta http-equiv="refresh" content="0; url=/logout">', unsafe_allow_html=True) st.success("Déconnecté.")
st.stop() time.sleep(0.3)
st.rerun()
# Vue principale (liste / édition) # Vue principale (liste / édition)
@@ -204,16 +293,17 @@ else:
df["resolution"] = df[COL_RESBITS].apply(res_label) df["resolution"] = df[COL_RESBITS].apply(res_label)
st.subheader("Enregistrements") st.subheader("Enregistrements")
st.caption("Double-cliquez pour éditer les cellules. Les colonnes *resolution* et *date* sont non éditables.") st.caption("Double-cliquez pour éditer les cellules. *resolution* et *date* sont non éditables.")
edited = st.data_editor( edited = st.data_editor(
df[[COL_ID, COL_ADDRESS, COL_LIEU, COL_REPERE, COL_RESBITS, "resolution", COL_DATE]], df[[COL_ID, COL_ADDRESS, COL_LIEU, COL_REPERE, COL_MES, COL_RESBITS, "resolution", COL_DATE]],
hide_index=True, hide_index=True,
column_config={ column_config={
COL_ID: st.column_config.NumberColumn("ID", disabled=True), COL_ID: st.column_config.NumberColumn("ID", disabled=True),
COL_ADDRESS: st.column_config.TextColumn("Adresse (ROM)", help=rom_help()), COL_ADDRESS: st.column_config.TextColumn("Adresse (ROM)", help=rom_help()),
COL_LIEU: st.column_config.TextColumn("Lieu"), COL_LIEU: st.column_config.TextColumn("Lieu"),
COL_REPERE: st.column_config.TextColumn("Repère"), # 🆕 éditable COL_REPERE: st.column_config.TextColumn("Repère"),
COL_MES: st.column_config.DateColumn("Mise en service", format="YYYY-MM-DD"),
COL_RESBITS: st.column_config.NumberColumn("Résolution (bits)", min_value=9, max_value=12, step=1), COL_RESBITS: st.column_config.NumberColumn("Résolution (bits)", min_value=9, max_value=12, step=1),
"resolution": st.column_config.TextColumn("Détails", disabled=True), "resolution": st.column_config.TextColumn("Détails", disabled=True),
COL_DATE: st.column_config.DatetimeColumn("Date insertion", disabled=True, format="YYYY-MM-DD HH:mm:ss"), COL_DATE: st.column_config.DatetimeColumn("Date insertion", disabled=True, format="YYYY-MM-DD HH:mm:ss"),
@@ -222,9 +312,8 @@ else:
num_rows="dynamic", num_rows="dynamic",
) )
# Détection des suppressions (si des lignes ont disparu) # Détections
removed_ids = set(df[COL_ID]) - set(edited[COL_ID]) removed_ids = set(df[COL_ID]) - set(edited[COL_ID])
# Détection des modifications cellule par cellule
to_update = [] to_update = []
for _, row in edited.iterrows(): for _, row in edited.iterrows():
orig = df.loc[df[COL_ID] == row[COL_ID]].iloc[0] orig = df.loc[df[COL_ID] == row[COL_ID]].iloc[0]
@@ -232,9 +321,21 @@ else:
(row[COL_ADDRESS] != orig[COL_ADDRESS]) or (row[COL_ADDRESS] != orig[COL_ADDRESS]) or
(row[COL_LIEU] != orig[COL_LIEU]) or (row[COL_LIEU] != orig[COL_LIEU]) or
((str(row.get(COL_REPERE) or "").strip()) != (str(orig.get(COL_REPERE) or "").strip())) or ((str(row.get(COL_REPERE) or "").strip()) != (str(orig.get(COL_REPERE) or "").strip())) or
((str(row.get(COL_MES) or "")[:10]) != (str(orig.get(COL_MES) or "")[:10])) or
(int(row[COL_RESBITS]) != int(orig[COL_RESBITS])) (int(row[COL_RESBITS]) != int(orig[COL_RESBITS]))
) )
if changed: if changed:
# Mise en service : convertir en date ou None
mes_val = row.get(COL_MES)
if pd.isna(mes_val):
mes_val = None
elif isinstance(mes_val, pd.Timestamp):
mes_val = mes_val.date()
elif isinstance(mes_val, str) and mes_val:
try:
mes_val = pd.to_datetime(mes_val).date()
except Exception:
mes_val = None
to_update.append( to_update.append(
( (
int(row[COL_ID]), int(row[COL_ID]),
@@ -242,6 +343,7 @@ else:
str(row[COL_LIEU]), str(row[COL_LIEU]),
int(row[COL_RESBITS]), int(row[COL_RESBITS]),
(str(row.get(COL_REPERE)).strip() if row.get(COL_REPERE) and str(row.get(COL_REPERE)).strip() else None), (str(row.get(COL_REPERE)).strip() if row.get(COL_REPERE) and str(row.get(COL_REPERE)).strip() else None),
mes_val,
) )
) )
@@ -253,8 +355,8 @@ else:
col1, col2, col3 = st.columns([1,1,2]) col1, col2, col3 = st.columns([1,1,2])
with col1: with col1:
if st.button("Enregistrer les modifications", disabled=(len(to_update)==0 and len(removed_ids)==0)): if st.button("Enregistrer les modifications", disabled=(len(to_update)==0 and len(removed_ids)==0)):
for rid, addr, lieu, rbits, repere in to_update: for rid, addr, lieu, rbits, repere, mes in to_update:
update_tracker(rid, addr, lieu, rbits, repere) update_tracker(rid, addr, lieu, rbits, repere, mes)
for rid in removed_ids: for rid in removed_ids:
delete_tracker(int(rid)) delete_tracker(int(rid))
st.success("Modifications enregistrées ✔️") st.success("Modifications enregistrées ✔️")
@@ -281,5 +383,5 @@ else:
st.divider() st.divider()
st.caption( st.caption(
"Astuce : vous pouvez coller directement une adresse ROM depuis vos logs au format {0x..,0x..,0x..,0x..,0x..,0x..,0x..,0x..}.\n" "Astuce : vous pouvez coller directement une adresse ROM depuis vos logs au format {0x..,0x..,0x..,0x..,0x..,0x..,0x..,0x..}.\n"
"Si vos noms de colonnes diffèrent (ex. 'res.bits'), ajustez les constantes en tête de fichier." "Si vos noms de colonnes diffèrent, ajustez les constantes en tête de fichier."
) )