ajout du chat

This commit is contained in:
Julien Balet 2026-05-12 09:09:07 +02:00
parent 9188e6ba1e
commit eb98ec273c
9 changed files with 1039 additions and 21 deletions

View file

@ -37,6 +37,23 @@
--quote-font-family: var(--default-font-family);
}
/* ── Utility classes ─────────────────────────────────────────────────────── */
/* Scroll discret : pas de scrollbar visible mais la zone scroll quand même
(utilisé par le chat de feedback). */
.no-scrollbar { scrollbar-width: none; -ms-overflow-style: none; }
.no-scrollbar::-webkit-scrollbar { display: none; }
/* Badge avec animation pulse — utilisé pour indiquer les messages non lus. */
@keyframes feedback-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.15); opacity: 0.85; }
}
.pulse-badge {
animation: feedback-pulse 1.5s ease-in-out infinite;
}
/* Brand tokens (thèmes utilisateur)
Tokens utilisés par l'app pour les couleurs de marque. Chaque thème surcharge
ces variables via [data-theme="..."] sur <html>.

View file

@ -32,15 +32,15 @@
"MP1-TASV 4D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3bc53c77-bda8-43a4-ab47-08c2eb84a917",
"MP1-TASV 4E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7673335b-a2aa-42de-9cab-6b95ffc90d7c",
"Z-IT Test 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f0707cc3-4511-403b-a2a6-d79f2da4fd99",
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=884d4b5a-5c84-4699-b317-a1c20519e8d1",
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=eff788b8-9b09-4166-baba-91cdb8f4cc8f",
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b11daad1-028b-4fb5-bc98-303c5f59c9a8",
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=fbd5b085-8016-43bc-88f7-ab9542829a35",
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=194ac578-f4b3-4d17-adf7-3294d8042ce0",
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=24b579b1-d943-4933-91e7-65bd42a4050a",
"EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a5203dae-ffe4-49f0-a0e7-543e457c3494",
"EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b1f42f17-426f-44e2-ae21-480486849505",
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=71823b1e-2ec3-4209-aaaa-bff6dcfc16a6",
"MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=5b147370-9ed0-42ec-ba48-1e10b3d81455",
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7abe005d-291d-4ed6-91a4-a4b3d0f37c7d"
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=326850f9-08ad-413c-ad15-1fa079f5058b",
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=8b5035a7-f0b6-41dd-b203-4c5d540b1e64",
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=93c187b8-7bd9-4361-82ef-cac3a5658c6c",
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=83a6684e-a2be-4148-8bd4-faaea872698d",
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=cf80e939-1c49-484b-b8ed-1357a1a51c2b",
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=12a70ca4-1410-4c7a-85b8-36eeabbe7cda",
"EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=eb8d612b-0929-47bc-8af5-ffebc7ed2432",
"EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=d0781d15-f260-40b5-9bac-e3c919533422",
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=69c49e37-d184-4ab4-a463-914ed635d237",
"MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=6d07e078-38b1-49da-a513-91b482dbf2a6",
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=6705457b-b544-4c74-8de1-eb61cf45511d"
}

View file

@ -7,5 +7,6 @@
"escada_username": "julien.balet",
"escada_password": "Lauryne2023!",
"totp_secret": "KQZVCQLXGNAU22KRKNCHCYSUIRAXAUSR",
"app_base_url": "https://dev.dashboard.eptm-automation.ch"
"app_base_url": "https://dev.dashboard.eptm-automation.ch",
"feedback_admin_email": "julien.balet@edu.vs.ch"
}

View file

@ -13,6 +13,7 @@ from .pages.purge import purge_page, PurgeState
from .pages.doc import doc_page, DocState
from .pages.profile import profile_page, ProfileState
from .pages.password_set import password_set_page, PasswordSetState
from .pages.feedback import feedback_page, FeedbackAdminState
# RetenueState et SanctionState sont utilisés via modal dans /fiche
from .pages.retenue import RetenueState
from .pages.sanction import SanctionState
@ -79,5 +80,6 @@ app.add_page(params_page, route="/params", on_load=[AuthState.check_auth,
app.add_page(purge_page, route="/purge", on_load=[AuthState.check_auth, PurgeState.load_data], title=TITLE)
app.add_page(doc_page, route="/doc", on_load=[AuthState.check_auth, DocState.load_data], title=TITLE)
app.add_page(profile_page, route="/profile", on_load=[AuthState.check_auth, ProfileState.load_data], title=TITLE)
app.add_page(feedback_page, route="/feedback",on_load=[AuthState.check_auth, FeedbackAdminState.load_data], title=TITLE)
# Page publique (pas de check_auth — accessible via lien email)
app.add_page(password_set_page, route="/password-set", on_load=PasswordSetState.load_data, title=TITLE)

View file

@ -0,0 +1,911 @@
"""Widget de feedback in-app + page admin /feedback.
Widget : bouton flottant en bas à droite (visible partout). Flow :
1. Bot : « Que voulez-vous faire ? » [Bug] [Idée]
2. User : saisit son message
3. Bot : merci message en DB + email envoyé à l'admin
Page admin : table des messages, modal de réponse SMTP.
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime
from pathlib import Path
import reflex as rx
from sqlalchemy import select, func
_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from src.db import get_session, FeedbackMessage # noqa: E402
from src.email_sender import send_email # noqa: E402
from src.logger import app_log # noqa: E402
from ..state import AuthState
from ..sidebar import layout
DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
_SETTINGS_FILE = DATA_DIR / "settings.json"
def _load_settings() -> dict:
if _SETTINGS_FILE.exists():
try:
return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
return {}
# ── State ─────────────────────────────────────────────────────────────────────
class FeedbackChatState(AuthState):
open: bool = False
step: str = "choice" # "choice" | "writing" | "done"
feedback_type: str = "" # "bug" | "feature"
# Historique du chat affiché. Chaque message: {"role": "bot"|"user", "text": str}
messages: list[dict] = []
composing: str = "" # texte en cours dans l'input
submit_error: str = ""
_BOT_GREETING = (
"Bonjour ! Je peux transmettre votre message à l'équipe. "
"Que voulez-vous faire ?"
)
_BOT_BUG = "D'accord. Décrivez le bug : ce qui s'est passé, sur quelle page, ce qui était attendu."
_BOT_FEATURE = "Avec plaisir. Décrivez l'idée : le besoin, le contexte."
_BOT_DONE = "Merci ! Votre message a été transmis. L'équipe vous répondra par email."
def set_open(self, v: bool):
self.open = v
if v and not self.messages:
# Première ouverture : démarrer la conversation
self.messages = [{"role": "bot", "text": self._BOT_GREETING}]
if not v:
# Reset à la fermeture
self.step = "choice"
self.feedback_type = ""
self.messages = []
self.composing = ""
self.submit_error = ""
_SCROLL_JS = (
"setTimeout(() => {"
" var el = document.getElementById('feedback-chat-scroll');"
" if (el) el.scrollTop = el.scrollHeight;"
"}, 50);"
)
def start_bug(self):
self.feedback_type = "bug"
self.messages = self.messages + [
{"role": "user", "text": "Signaler un bug"},
{"role": "bot", "text": self._BOT_BUG},
]
self.step = "writing"
return rx.call_script(self._SCROLL_JS)
def start_feature(self):
self.feedback_type = "feature"
self.messages = self.messages + [
{"role": "user", "text": "Proposer une fonctionnalité"},
{"role": "bot", "text": self._BOT_FEATURE},
]
self.step = "writing"
return rx.call_script(self._SCROLL_JS)
def set_composing(self, v: str):
self.composing = v
self.submit_error = ""
def submit(self):
msg = (self.composing or "").strip()
if not msg:
self.submit_error = "Le message ne peut pas être vide."
return
if not self.feedback_type:
self.submit_error = "Choisissez d'abord Bug ou Idée."
return
# Pousse le message utilisateur dans le chat AVANT la persistance
# (UX : l'utilisateur voit sa bulle immédiatement).
self.messages = self.messages + [{"role": "user", "text": msg}]
self.composing = ""
sess = get_session()
try:
fb = FeedbackMessage(
created_by=self.username or "anonyme",
user_email=self._lookup_user_email(),
type=self.feedback_type,
message=msg,
context_url=self.router.page.path or "",
)
sess.add(fb)
sess.commit()
app_log(
f"[feedback] {self.username or '?'} : nouveau {self.feedback_type}"
)
except Exception as e:
sess.rollback()
self.submit_error = f"Erreur en base : {e}"
return
finally:
sess.close()
# Notification email à l'admin (best-effort).
try:
self._notify_admin_with_msg(msg)
except Exception as _e:
app_log(f"[feedback] échec notif admin : {_e}")
self.messages = self.messages + [{"role": "bot", "text": self._BOT_DONE}]
self.step = "done"
return rx.call_script(self._SCROLL_JS)
def _lookup_user_email(self) -> str | None:
"""Récupère l'email de l'utilisateur depuis auth.yaml."""
try:
import yaml
auth_file = DATA_DIR / "auth.yaml"
if not auth_file.exists():
return None
cfg = yaml.safe_load(auth_file.read_text(encoding="utf-8")) or {}
user = cfg.get("credentials", {}).get("usernames", {}).get(self.username, {})
return user.get("email") or None
except Exception:
return None
def _notify_admin_with_msg(self, msg: str):
s = _load_settings()
admin_email = (s.get("feedback_admin_email") or "").strip()
if not admin_email:
return # pas d'admin configuré → skip
smtp_host = s.get("smtp_host")
smtp_port = int(s.get("smtp_port") or 587)
smtp_login = s.get("smtp_login")
smtp_password = s.get("smtp_password")
smtp_sender = s.get("smtp_sender")
if not (smtp_host and smtp_login and smtp_password and smtp_sender):
return
label = "Bug" if self.feedback_type == "bug" else "Proposition"
subject = f"[EPTM Dashboard] Nouveau {label.lower()}{self.username or '?'}"
body = (
f"Nouveau message de feedback EPTM Dashboard.\n\n"
f"Type : {label}\n"
f"Utilisateur : {self.name or self.username or '?'}\n"
f"Email : {self._lookup_user_email() or '(non renseigné)'}\n"
f"Page d'origine : {self.router.page.path or ''}\n\n"
f"Message :\n{msg}\n\n"
f"Pour répondre : https://dashboard.eptm-automation.ch/feedback\n"
)
send_email(
smtp_host=smtp_host, smtp_port=smtp_port,
smtp_login=smtp_login, smtp_password=smtp_password,
smtp_sender=smtp_sender,
to_email=admin_email, subject=subject, body=body,
)
# ── Widget UI (bouton flottant + chat) ────────────────────────────────────────
def _bubble(msg: rx.Var) -> rx.Component:
"""Une bulle de message. Style Crisp : bot à gauche (gris), user à droite (bleu)."""
is_user = msg["role"] == "user"
return rx.flex(
# Avatar bot à gauche (seulement pour les messages bot)
rx.cond(
is_user,
rx.fragment(),
rx.flex(
rx.icon("bot", size=14, color="white"),
background_color="var(--brand-accent)",
border_radius="50%",
width="28px", height="28px",
align="center", justify="center",
flex_shrink="0",
),
),
rx.box(
rx.text(
msg["text"], size="2",
color=rx.cond(is_user, "white", "var(--text-strong)"),
style={"white_space": "pre-wrap"},
),
padding="0.55rem 0.85rem",
border_radius="14px",
background_color=rx.cond(is_user, "var(--brand-accent)", "var(--gray-3)"),
max_width="80%",
),
# Spacer côté avatar opposé (pour pousser la bulle au bon côté)
rx.cond(
is_user,
rx.fragment(),
rx.fragment(),
),
justify=rx.cond(is_user, "end", "start"),
align="end",
gap="0.5rem",
width="100%",
margin_bottom="0.5rem",
)
def _chat_messages() -> rx.Component:
return rx.box(
rx.foreach(FeedbackChatState.messages, _bubble),
id="feedback-chat-scroll",
height="320px",
overflow_y="auto",
padding="0.75rem",
background_color="var(--surface-soft)",
border_radius="10px",
border="1px solid var(--border-soft)",
width="100%",
class_name="no-scrollbar",
)
def _quick_replies() -> rx.Component:
"""Boutons de choix initiaux (affichés tant que step=choice)."""
return rx.cond(
FeedbackChatState.step == "choice",
rx.flex(
rx.button(
rx.icon("bug", size=13), "Signaler un bug",
on_click=FeedbackChatState.start_bug,
color_scheme="red", variant="soft", size="2",
),
rx.button(
rx.icon("lightbulb", size=13), "Proposer une idée",
on_click=FeedbackChatState.start_feature,
color_scheme="blue", variant="soft", size="2",
),
gap="0.5rem", flex_wrap="wrap", width="100%",
),
)
def _composer() -> rx.Component:
"""Input + bouton send en bas du chat (style Crisp footer)."""
can_send = (
(FeedbackChatState.step == "writing")
& (FeedbackChatState.composing != "")
)
return rx.cond(
FeedbackChatState.step == "done",
rx.flex(
rx.dialog.close(
rx.button("Fermer", variant="soft", color_scheme="gray", size="2"),
),
justify="end", width="100%",
),
rx.flex(
rx.text_area(
value=FeedbackChatState.composing,
on_change=FeedbackChatState.set_composing,
placeholder=rx.cond(
FeedbackChatState.step == "writing",
"Tapez votre message…",
"Choisissez une option ci-dessus…",
),
disabled=FeedbackChatState.step != "writing",
rows="3",
resize="none",
width="100%",
style={"font_size": "0.9rem"},
),
rx.icon_button(
rx.icon("send", size=18),
on_click=FeedbackChatState.submit,
disabled=~can_send,
color_scheme="blue", variant="solid", size="3",
style={"align_self": "flex-end"},
),
gap="0.5rem", width="100%", align="end",
),
)
def feedback_widget() -> rx.Component:
"""Bouton flottant + chat. À placer dans le layout principal."""
return rx.cond(
AuthState.authenticated,
rx.dialog.root(
rx.dialog.trigger(
rx.icon_button(
rx.icon("message-square", size=20),
size="3",
color_scheme="blue",
variant="solid",
style={
"position": "fixed",
"bottom": "1.5rem",
"right": "1.5rem",
"z_index": "1000",
"border_radius": "9999px",
"box_shadow": "0 4px 12px rgba(0,0,0,0.18)",
"cursor": "pointer",
},
title="Signaler un bug ou proposer une idée",
),
),
rx.dialog.content(
# Header style chat (toute largeur, coins arrondis en haut)
rx.flex(
rx.icon("message-square", size=18, color="white"),
rx.text(
"Aide & feedback EPTM",
size="3", weight="bold", color="white",
),
rx.spacer(),
rx.dialog.close(
rx.icon_button(
rx.icon("x", size=14),
variant="ghost", size="1",
style={"color": "white"},
),
),
gap="0.5rem", align="center",
padding="0.75rem 1rem",
background_color="var(--brand-accent)",
),
rx.vstack(
_chat_messages(),
_quick_replies(),
rx.cond(
FeedbackChatState.submit_error != "",
rx.text(
FeedbackChatState.submit_error,
size="1", color="var(--red-10)",
),
),
_composer(),
spacing="2", width="100%",
padding="1rem",
),
max_width="480px",
# Retire le padding par défaut du Radix dialog pour que le
# header s'étende sur toute la largeur, et coins arrondis 4×.
padding="0",
overflow="hidden",
border_radius="12px",
),
open=FeedbackChatState.open,
on_open_change=FeedbackChatState.set_open,
),
)
# ── Page admin /feedback ──────────────────────────────────────────────────────
class FeedbackAdminState(AuthState):
items: list[dict] = []
new_count: int = 0
filter_status: str = "all" # "all" | "new" | "in_progress" | "resolved"
filter_type: str = "all" # "all" | "bug" | "feature"
# Modal détail / réponse
detail_open: bool = False
sel_id: int = 0
sel_created_at: str = ""
sel_created_by: str = ""
sel_user_email: str = ""
sel_type: str = ""
sel_message: str = ""
sel_context_url: str = ""
sel_status: str = "new"
sel_response: str = ""
sel_response_sent_at: str = ""
send_error: str = ""
def set_filter_status(self, v: str):
self.filter_status = v
self._reload()
def set_filter_type(self, v: str):
self.filter_type = v
self._reload()
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
if self.role != "admin":
return rx.redirect("/accueil")
self._reload()
@staticmethod
def _load_username_to_name() -> dict[str, str]:
"""Mapping username → nom complet depuis auth.yaml (vide si erreur)."""
out: dict[str, str] = {}
try:
import yaml
auth_file = DATA_DIR / "auth.yaml"
if not auth_file.exists():
return out
cfg = yaml.safe_load(auth_file.read_text(encoding="utf-8")) or {}
for uname, data in cfg.get("credentials", {}).get("usernames", {}).items():
out[uname] = (data or {}).get("name") or uname
except Exception:
pass
return out
def _reload(self):
username_to_name = self._load_username_to_name()
sess = get_session()
try:
q = select(FeedbackMessage).order_by(FeedbackMessage.created_at.desc())
if self.filter_status != "all":
q = q.where(FeedbackMessage.status == self.filter_status)
if self.filter_type != "all":
q = q.where(FeedbackMessage.type == self.filter_type)
rows = sess.execute(q).scalars().all()
self.items = [
{
"id": r.id,
"created_at": r.created_at.strftime("%d.%m.%Y %H:%M") if r.created_at else "",
"created_by": r.created_by or "",
"created_by_label": username_to_name.get(r.created_by, r.created_by or ""),
"type": r.type or "",
"type_label": "Bug" if r.type == "bug" else "Idée",
"status": r.status or "new",
"status_label": {"new":"Nouveau","in_progress":"En cours","resolved":"Résolu"}.get(r.status, r.status),
"preview": (r.message or "")[:80] + ("" if r.message and len(r.message) > 80 else ""),
}
for r in rows
]
self.new_count = sess.execute(
select(func.count(FeedbackMessage.id)).where(FeedbackMessage.status == "new")
).scalar() or 0
finally:
sess.close()
def open_detail(self, mid: int):
username_to_name = self._load_username_to_name()
sess = get_session()
try:
fb = sess.get(FeedbackMessage, mid)
if not fb:
return
self.sel_id = fb.id
self.sel_created_at = fb.created_at.strftime("%d.%m.%Y %H:%M") if fb.created_at else ""
self.sel_created_by = username_to_name.get(fb.created_by, fb.created_by or "")
self.sel_user_email = fb.user_email or ""
self.sel_type = fb.type or ""
self.sel_message = fb.message or ""
self.sel_context_url = fb.context_url or ""
self.sel_status = fb.status or "new"
self.sel_response = fb.admin_response or ""
self.sel_response_sent_at = (
fb.response_sent_at.strftime("%d.%m.%Y %H:%M") if fb.response_sent_at else ""
)
self.send_error = ""
self.detail_open = True
finally:
sess.close()
def set_detail_open(self, v: bool):
self.detail_open = v
if not v:
self.send_error = ""
def set_sel_response(self, v: str):
self.sel_response = v
self.send_error = ""
def mark_in_progress(self):
return self._update_status("in_progress")
def mark_resolved(self):
return self._update_status("resolved")
def _update_status(self, status: str):
sess = get_session()
try:
fb = sess.get(FeedbackMessage, self.sel_id)
if fb:
fb.status = status
sess.commit()
self.sel_status = status
finally:
sess.close()
# Notification email à l'utilisateur si email disponible (best-effort)
sent = False
try:
sent = self._send_status_email(status)
except Exception as e:
app_log(f"[feedback] échec notif statut : {e}")
app_log(
f"[feedback] {self.username or '?'} : msg #{self.sel_id}{status}"
)
self._reload()
self._refresh_feedback_count()
if sent:
return rx.toast.success(
f"Statut mis à jour — email envoyé à {self.sel_user_email}"
)
return rx.toast.info("Statut mis à jour")
def _send_status_email(self, status: str) -> bool:
"""Envoie un email à l'auteur du message quand son statut change.
Retourne True si email envoyé, False si skipped (config manquante ou
pas d'email user)."""
if not self.sel_user_email or "@" not in self.sel_user_email:
return False
s = _load_settings()
smtp_host = s.get("smtp_host")
smtp_port = int(s.get("smtp_port") or 587)
smtp_login = s.get("smtp_login")
smtp_password = s.get("smtp_password")
smtp_sender = s.get("smtp_sender")
if not (smtp_host and smtp_login and smtp_password and smtp_sender):
return False
if status == "in_progress":
subject = "[EPTM Dashboard] Votre signalement est en cours de traitement"
status_label = "en cours de traitement"
elif status == "resolved":
subject = "[EPTM Dashboard] Votre signalement a été résolu"
status_label = "résolu"
else:
return False
body = (
f"Bonjour,\n\n"
f"Le statut de votre signalement envoyé le {self.sel_created_at} "
f"est passé à : {status_label}.\n\n"
f"---\nMessage initial :\n{self.sel_message}\n\n"
f"Cordialement,\nEPTM Dashboard\n"
)
send_email(
smtp_host=smtp_host, smtp_port=smtp_port,
smtp_login=smtp_login, smtp_password=smtp_password,
smtp_sender=smtp_sender,
to_email=self.sel_user_email, subject=subject, body=body,
)
return True
def delete_current(self):
"""Supprime le message actuellement ouvert et ferme le modal."""
if not self.sel_id:
return
sess = get_session()
try:
fb = sess.get(FeedbackMessage, self.sel_id)
if fb:
sess.delete(fb)
sess.commit()
app_log(
f"[feedback] {self.username or '?'} : suppression du "
f"message #{self.sel_id}"
)
finally:
sess.close()
self.detail_open = False
self._reload()
self._refresh_feedback_count()
return rx.toast.success("Message supprimé")
def send_response_only(self):
"""Envoie le commentaire par email, sans changer le statut."""
return self._send_response(None)
def send_response_in_progress(self):
"""Envoie le commentaire + marque le message 'en cours'."""
return self._send_response("in_progress")
def send_response_resolved(self):
"""Envoie le commentaire + marque le message 'résolu'."""
return self._send_response("resolved")
def _send_response(self, new_status):
if not self.sel_response.strip():
self.send_error = "La réponse ne peut pas être vide."
return
if not self.sel_user_email or "@" not in self.sel_user_email:
self.send_error = "Pas d'email utilisateur enregistré pour ce message."
return
s = _load_settings()
smtp_host = s.get("smtp_host")
smtp_port = int(s.get("smtp_port") or 587)
smtp_login = s.get("smtp_login")
smtp_password = s.get("smtp_password")
smtp_sender = s.get("smtp_sender")
if not (smtp_host and smtp_login and smtp_password and smtp_sender):
self.send_error = "Configuration SMTP incomplète (Paramètres)."
return
# Sujet adapté au statut résultant
status_suffix = {
"in_progress": " (en cours)",
"resolved": " (résolu)",
}.get(new_status, "")
subject = f"[EPTM Dashboard] Réponse à votre signalement{status_suffix}"
body = (
f"Bonjour,\n\n"
f"Voici la réponse à votre message envoyé le {self.sel_created_at} :\n\n"
f"{self.sel_response.strip()}\n\n"
f"---\nMessage initial :\n{self.sel_message}\n\n"
f"Cordialement,\n{self.name or self.username or 'EPTM Dashboard'}\n"
)
try:
send_email(
smtp_host=smtp_host, smtp_port=smtp_port,
smtp_login=smtp_login, smtp_password=smtp_password,
smtp_sender=smtp_sender,
to_email=self.sel_user_email, subject=subject, body=body,
)
except Exception as e:
self.send_error = f"Échec d'envoi : {e}"
return
# Persister la réponse + (optionnellement) le statut
sess = get_session()
try:
fb = sess.get(FeedbackMessage, self.sel_id)
if fb:
fb.admin_response = self.sel_response.strip()
fb.response_sent_at = datetime.now()
if new_status:
fb.status = new_status
self.sel_status = new_status
sess.commit()
self.sel_response_sent_at = fb.response_sent_at.strftime("%d.%m.%Y %H:%M")
finally:
sess.close()
app_log(
f"[feedback] {self.username or '?'} : réponse envoyée à "
f"{self.sel_user_email} (msg #{self.sel_id}, "
f"statut={new_status or 'inchangé'})"
)
self._reload()
self._refresh_feedback_count()
return rx.toast.success(f"Réponse envoyée à {self.sel_user_email}")
def _row(item: rx.Var) -> rx.Component:
return rx.table.row(
rx.table.cell(item["created_at"], white_space="nowrap", color="var(--text-soft)"),
rx.table.cell(item["created_by_label"], color="var(--text-strong)"),
rx.table.cell(
rx.badge(
item["type_label"],
color_scheme=rx.cond(item["type"] == "bug", "red", "blue"),
variant="soft",
),
),
rx.table.cell(
rx.badge(
item["status_label"],
color_scheme=rx.match(
item["status"],
("new", "amber"),
("in_progress", "blue"),
("resolved", "green"),
"gray",
),
variant="soft",
),
),
rx.table.cell(item["preview"], color="var(--text-soft)"),
# Toute la ligne est cliquable → ouvre le détail
on_click=FeedbackAdminState.open_detail(item["id"]),
cursor="pointer",
_hover={"background_color": "var(--surface-hover)"},
)
def _filters() -> rx.Component:
return rx.flex(
rx.vstack(
rx.text("Statut", size="1", color="var(--text-muted)"),
rx.select.root(
rx.select.trigger(),
rx.select.content(
rx.select.item("Tous", value="all"),
rx.select.item("Nouveau", value="new"),
rx.select.item("En cours", value="in_progress"),
rx.select.item("Résolu", value="resolved"),
),
value=FeedbackAdminState.filter_status,
on_change=FeedbackAdminState.set_filter_status,
),
spacing="1", align="start",
),
rx.vstack(
rx.text("Type", size="1", color="var(--text-muted)"),
rx.select.root(
rx.select.trigger(),
rx.select.content(
rx.select.item("Tous", value="all"),
rx.select.item("Bug", value="bug"),
rx.select.item("Idée", value="feature"),
),
value=FeedbackAdminState.filter_type,
on_change=FeedbackAdminState.set_filter_type,
),
spacing="1", align="start",
),
gap="1rem", flex_wrap="wrap",
)
def _detail_modal() -> rx.Component:
return rx.dialog.root(
rx.dialog.content(
rx.dialog.title("Détail du message"),
rx.vstack(
rx.flex(
rx.badge(
rx.cond(FeedbackAdminState.sel_type == "bug", "Bug", "Idée"),
color_scheme=rx.cond(FeedbackAdminState.sel_type == "bug", "red", "blue"),
variant="soft",
),
rx.text("De : ", FeedbackAdminState.sel_created_by,
size="2", color="var(--text-soft)"),
rx.text("", FeedbackAdminState.sel_created_at,
size="2", color="var(--text-soft)"),
gap="0.5rem", align="center", flex_wrap="wrap",
),
rx.cond(
FeedbackAdminState.sel_user_email != "",
rx.text(
"Email : ", FeedbackAdminState.sel_user_email,
size="2", color="var(--text-soft)",
),
),
rx.cond(
FeedbackAdminState.sel_context_url != "",
rx.text(
"Page d'origine : ", FeedbackAdminState.sel_context_url,
size="1", color="var(--text-muted)",
),
),
rx.box(
rx.text(FeedbackAdminState.sel_message,
size="2", color="var(--text-strong)",
style={"white_space": "pre-wrap"}),
padding="0.75rem 1rem",
background_color="var(--surface-muted)",
border_radius="6px",
border="1px solid var(--border-soft)",
width="100%",
),
rx.divider(),
rx.cond(
FeedbackAdminState.sel_response_sent_at != "",
rx.callout.root(
rx.callout.icon(rx.icon("check", size=14)),
rx.callout.text(
"Réponse envoyée le ", FeedbackAdminState.sel_response_sent_at,
),
color_scheme="green", variant="soft", size="1",
),
),
rx.text("Réponse :", size="2", weight="medium", color="var(--text-strong)"),
rx.text_area(
value=FeedbackAdminState.sel_response,
on_change=FeedbackAdminState.set_sel_response,
placeholder="Tapez votre réponse à l'utilisateur…",
rows="6", width="100%",
),
rx.cond(
FeedbackAdminState.send_error != "",
rx.callout.root(
rx.callout.icon(rx.icon("triangle-alert", size=14)),
rx.callout.text(FeedbackAdminState.send_error),
color_scheme="red", variant="soft", size="1",
),
),
rx.flex(
rx.button(
rx.icon("send", size=14), "Envoyer seulement",
on_click=FeedbackAdminState.send_response_only,
color_scheme="gray", variant="soft", size="2",
),
rx.button(
rx.icon("send", size=14), "Envoyer + en cours",
on_click=FeedbackAdminState.send_response_in_progress,
color_scheme="blue", size="2",
),
rx.button(
rx.icon("send", size=14), "Envoyer + résolu",
on_click=FeedbackAdminState.send_response_resolved,
color_scheme="green", size="2",
),
rx.spacer(),
rx.alert_dialog.root(
rx.alert_dialog.trigger(
rx.button(
rx.icon("trash-2", size=14),
"Supprimer",
variant="outline", color_scheme="red", size="2",
),
),
rx.alert_dialog.content(
rx.alert_dialog.title("Supprimer ce message ?"),
rx.alert_dialog.description(
"Cette action est définitive. Le message et la "
"réponse associée seront perdus.",
size="2",
),
rx.flex(
rx.alert_dialog.cancel(
rx.button("Annuler", variant="soft", color_scheme="gray"),
),
rx.alert_dialog.action(
rx.button(
"Supprimer définitivement",
color_scheme="red",
on_click=FeedbackAdminState.delete_current,
),
),
gap="0.5rem", justify="end", margin_top="1rem",
),
max_width="420px",
),
),
rx.dialog.close(
rx.button("Fermer", variant="soft", color_scheme="gray", size="2"),
),
gap="0.5rem", flex_wrap="wrap", width="100%", align="center",
),
spacing="3", width="100%",
),
max_width="720px",
max_height="90vh",
overflow_y="auto",
),
open=FeedbackAdminState.detail_open,
on_open_change=FeedbackAdminState.set_detail_open,
)
def feedback_page() -> rx.Component:
return layout(
rx.vstack(
rx.flex(
rx.heading("Feedback utilisateurs", size="6"),
rx.spacer(),
rx.badge(
FeedbackAdminState.new_count, " nouveau(x)",
color_scheme="amber", variant="soft", size="2",
),
align="center", width="100%", flex_wrap="wrap",
),
_filters(),
rx.cond(
FeedbackAdminState.items.length() == 0,
rx.callout.root(
rx.callout.icon(rx.icon("inbox", size=16)),
rx.callout.text("Aucun message pour ces filtres."),
color_scheme="gray", variant="soft", size="1",
),
rx.box(
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Date"),
rx.table.column_header_cell("Utilisateur"),
rx.table.column_header_cell("Type"),
rx.table.column_header_cell("Statut"),
rx.table.column_header_cell("Message"),
),
),
rx.table.body(
rx.foreach(FeedbackAdminState.items, _row),
),
width="100%", size="2",
),
overflow_x="auto", width="100%",
),
),
_detail_modal(),
spacing="4", width="100%",
)
)

View file

@ -58,6 +58,7 @@ class ParamsState(AuthState):
smtp_login: str = ""
smtp_password: str = ""
smtp_sender: str = ""
feedback_admin_email: str = "" # destinataire notifs feedback in-app
save_ok_smtp: bool = False
# ── Escada ────────────────────────────────────────────────────────────────
@ -91,6 +92,7 @@ class ParamsState(AuthState):
def set_smtp_login(self, v: str): self.smtp_login = v
def set_smtp_password(self, v: str): self.smtp_password = v
def set_smtp_sender(self, v: str): self.smtp_sender = v
def set_feedback_admin_email(self, v: str): self.feedback_admin_email = v
def set_escada_username(self, v: str): self.escada_username = v
def set_escada_password(self, v: str): self.escada_password = v
def set_totp_secret(self, v: str): self.totp_secret = v
@ -111,6 +113,7 @@ class ParamsState(AuthState):
self.smtp_login = s.get("smtp_login", s.get("smtp_email", ""))
self.smtp_password = s.get("smtp_password", "")
self.smtp_sender = s.get("smtp_sender", "EPTM Automation <noreply@eptm-automation.ch>")
self.feedback_admin_email = s.get("feedback_admin_email", "")
self.escada_username = s.get("escada_username", "")
self.escada_password = s.get("escada_password", "")
self.totp_secret = s.get("totp_secret", "")
@ -154,6 +157,7 @@ class ParamsState(AuthState):
s["smtp_login"] = self.smtp_login.strip()
s["smtp_password"] = self.smtp_password.strip()
s["smtp_sender"] = self.smtp_sender.strip()
s["feedback_admin_email"] = self.feedback_admin_email.strip()
s.pop("smtp_email", None)
_write_settings(s)
self.save_ok_smtp = True
@ -380,6 +384,16 @@ def _section_smtp() -> rx.Component:
width="100%",
),
),
_field(
"Email admin (feedback in-app)",
rx.input(
value=ParamsState.feedback_admin_email,
on_change=ParamsState.set_feedback_admin_email,
placeholder="admin@eptm-automation.ch",
type="email",
width="100%",
),
),
rx.hstack(
rx.button(
rx.icon("save", size=16),

View file

@ -27,18 +27,39 @@ _PAGES = [
]
_ADMIN_PAGES = [
("Escada", "/escada", "globe"),
("Cron", "/cron", "alarm-clock"),
("Logs", "/logs", "file-text"),
("Utilisateurs", "/users", "user-cog"),
("Paramètres", "/params", "settings"),
("Purger classe","/purge", "trash-2"),
("Escada", "/escada", "globe"),
("Cron", "/cron", "alarm-clock"),
("Logs", "/logs", "file-text"),
("Utilisateurs", "/users", "user-cog"),
("Paramètres", "/params", "settings"),
("Feedback", "/feedback", "message-square"),
("Purger classe","/purge", "trash-2"),
]
def _href_badge_count(href: str):
"""Retourne le compteur de badge pour un href donné (None si pas de badge).
Sert à colorer l'icône de nav en rouge quand il y a des messages non lus."""
if href == "/feedback":
return AuthState.feedback_new_count
return None
def _nav_full(label: str, href: str, icon_name: str, close_menu: bool = False) -> rx.Component:
is_active = AuthState.router.page.path == href
click_handler = AuthState.close_mobile_menu if close_menu else None
badge_cnt = _href_badge_count(href)
# Si on a un badge actif, l'icône passe en rouge + pulse au lieu de la
# couleur normale (inactive ou active).
if badge_cnt is not None:
icon_color = rx.cond(
badge_cnt > 0, "#dc2626",
rx.cond(is_active, _ACTIVE_CLR, _TEXT),
)
icon_class = rx.cond(badge_cnt > 0, "pulse-badge", "")
else:
icon_color = rx.cond(is_active, _ACTIVE_CLR, _TEXT)
icon_class = ""
return rx.link(
rx.hstack(
rx.box(
@ -53,7 +74,8 @@ def _nav_full(label: str, href: str, icon_name: str, close_menu: bool = False) -
),
rx.icon(
icon_name, size=17,
color=rx.cond(is_active, _ACTIVE_CLR, _TEXT),
color=icon_color,
class_name=icon_class,
flex_shrink="0",
),
rx.text(
@ -83,11 +105,20 @@ def _nav_full(label: str, href: str, icon_name: str, close_menu: bool = False) -
def _nav_rail(label: str, href: str, icon_name: str) -> rx.Component:
is_active = AuthState.router.page.path == href
badge_cnt = _href_badge_count(href)
if badge_cnt is not None:
icon_color = rx.cond(
badge_cnt > 0, "#dc2626",
rx.cond(is_active, _ACTIVE_CLR, _TEXT),
)
icon_class = rx.cond(badge_cnt > 0, "pulse-badge", "")
else:
icon_color = rx.cond(is_active, _ACTIVE_CLR, _TEXT)
icon_class = ""
return rx.tooltip(
rx.link(
rx.box(
rx.icon(icon_name, size=20,
color=rx.cond(is_active, _ACTIVE_CLR, _TEXT)),
rx.icon(icon_name, size=20, color=icon_color, class_name=icon_class),
width="100%",
display="flex",
align_items="center",
@ -525,6 +556,8 @@ _KEYBOARD_SHORTCUTS_JS = """
def layout(content: rx.Component) -> rx.Component:
# Import local pour éviter le cycle sidebar ↔ pages.feedback
from .pages.feedback import feedback_widget
return rx.box(
sidebar(),
_mobile_topbar(),
@ -543,6 +576,7 @@ def layout(content: rx.Component) -> rx.Component:
transition="margin-left 0.22s ease, width 0.22s ease",
box_sizing="border-box",
),
feedback_widget(),
rx.script(_KEYBOARD_SHORTCUTS_JS),
width="100%",
height="100vh",

View file

@ -65,6 +65,8 @@ class AuthState(rx.State):
mobile_menu_open: bool = False
admin_expanded: bool = True
doc_expanded: bool = False
# Compteur de messages feedback "new" (admin uniquement)
feedback_new_count: int = 0
@rx.var
def authenticated(self) -> bool:
@ -126,8 +128,28 @@ class AuthState(rx.State):
stored_theme = users[self.username].get("theme") or "eptm"
if stored_theme != self.theme:
self.theme = stored_theme
# Compteur feedback (admin uniquement) — pour le badge sidebar
self._refresh_feedback_count()
return self._apply_theme_script(self.theme)
def _refresh_feedback_count(self):
if self.role != "admin":
self.feedback_new_count = 0
return
try:
from src.db import get_session, FeedbackMessage
from sqlalchemy import select, func
sess = get_session()
try:
self.feedback_new_count = sess.execute(
select(func.count(FeedbackMessage.id))
.where(FeedbackMessage.status == "new")
).scalar() or 0
finally:
sess.close()
except Exception:
self.feedback_new_count = 0
@staticmethod
def _apply_theme_script(theme: str):
"""Script JS qui set data-theme sur <html> immédiatement (sans attendre re-render)."""

View file

@ -330,6 +330,23 @@ class CronJob(Base):
updated_at: Mapped[datetime] = mapped_column(default=datetime.now)
class FeedbackMessage(Base):
"""Message de feedback utilisateur (bug / proposition) collecté via le
widget chat in-app. Géré depuis la page admin /feedback."""
__tablename__ = "feedback_messages"
id: Mapped[int] = mapped_column(primary_key=True)
created_at: Mapped[datetime] = mapped_column(default=datetime.now)
created_by: Mapped[str] # username
user_email: Mapped[Optional[str]] = mapped_column(String, nullable=True)
type: Mapped[str] # "bug" | "feature"
message: Mapped[str] = mapped_column(Text)
context_url: Mapped[Optional[str]] = mapped_column(String, nullable=True) # page d'origine
status: Mapped[str] = mapped_column(default="new") # "new" | "in_progress" | "resolved"
admin_response: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
response_sent_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
def get_engine(db_url: str | None = None):
url = db_url or f"sqlite:///{DB_PATH}"
from sqlalchemy import event as _sa_event