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); --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) /* Brand tokens (thèmes utilisateur)
Tokens utilisés par l'app pour les couleurs de marque. Chaque thème surcharge Tokens utilisés par l'app pour les couleurs de marque. Chaque thème surcharge
ces variables via [data-theme="..."] sur <html>. 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 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", "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", "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 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=eff788b8-9b09-4166-baba-91cdb8f4cc8f", "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=b11daad1-028b-4fb5-bc98-303c5f59c9a8", "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=fbd5b085-8016-43bc-88f7-ab9542829a35", "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=194ac578-f4b3-4d17-adf7-3294d8042ce0", "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=24b579b1-d943-4933-91e7-65bd42a4050a", "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=a5203dae-ffe4-49f0-a0e7-543e457c3494", "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=b1f42f17-426f-44e2-ae21-480486849505", "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=71823b1e-2ec3-4209-aaaa-bff6dcfc16a6", "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=5b147370-9ed0-42ec-ba48-1e10b3d81455", "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=7abe005d-291d-4ed6-91a4-a4b3d0f37c7d" "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_username": "julien.balet",
"escada_password": "Lauryne2023!", "escada_password": "Lauryne2023!",
"totp_secret": "KQZVCQLXGNAU22KRKNCHCYSUIRAXAUSR", "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.doc import doc_page, DocState
from .pages.profile import profile_page, ProfileState from .pages.profile import profile_page, ProfileState
from .pages.password_set import password_set_page, PasswordSetState 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 # RetenueState et SanctionState sont utilisés via modal dans /fiche
from .pages.retenue import RetenueState from .pages.retenue import RetenueState
from .pages.sanction import SanctionState 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(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(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(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) # 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) 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_login: str = ""
smtp_password: str = "" smtp_password: str = ""
smtp_sender: str = "" smtp_sender: str = ""
feedback_admin_email: str = "" # destinataire notifs feedback in-app
save_ok_smtp: bool = False save_ok_smtp: bool = False
# ── Escada ──────────────────────────────────────────────────────────────── # ── Escada ────────────────────────────────────────────────────────────────
@ -91,6 +92,7 @@ class ParamsState(AuthState):
def set_smtp_login(self, v: str): self.smtp_login = v 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_password(self, v: str): self.smtp_password = v
def set_smtp_sender(self, v: str): self.smtp_sender = 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_username(self, v: str): self.escada_username = v
def set_escada_password(self, v: str): self.escada_password = v def set_escada_password(self, v: str): self.escada_password = v
def set_totp_secret(self, v: str): self.totp_secret = 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_login = s.get("smtp_login", s.get("smtp_email", ""))
self.smtp_password = s.get("smtp_password", "") self.smtp_password = s.get("smtp_password", "")
self.smtp_sender = s.get("smtp_sender", "EPTM Automation <noreply@eptm-automation.ch>") 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_username = s.get("escada_username", "")
self.escada_password = s.get("escada_password", "") self.escada_password = s.get("escada_password", "")
self.totp_secret = s.get("totp_secret", "") self.totp_secret = s.get("totp_secret", "")
@ -154,6 +157,7 @@ class ParamsState(AuthState):
s["smtp_login"] = self.smtp_login.strip() s["smtp_login"] = self.smtp_login.strip()
s["smtp_password"] = self.smtp_password.strip() s["smtp_password"] = self.smtp_password.strip()
s["smtp_sender"] = self.smtp_sender.strip() s["smtp_sender"] = self.smtp_sender.strip()
s["feedback_admin_email"] = self.feedback_admin_email.strip()
s.pop("smtp_email", None) s.pop("smtp_email", None)
_write_settings(s) _write_settings(s)
self.save_ok_smtp = True self.save_ok_smtp = True
@ -380,6 +384,16 @@ def _section_smtp() -> rx.Component:
width="100%", 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.hstack(
rx.button( rx.button(
rx.icon("save", size=16), rx.icon("save", size=16),

View file

@ -27,18 +27,39 @@ _PAGES = [
] ]
_ADMIN_PAGES = [ _ADMIN_PAGES = [
("Escada", "/escada", "globe"), ("Escada", "/escada", "globe"),
("Cron", "/cron", "alarm-clock"), ("Cron", "/cron", "alarm-clock"),
("Logs", "/logs", "file-text"), ("Logs", "/logs", "file-text"),
("Utilisateurs", "/users", "user-cog"), ("Utilisateurs", "/users", "user-cog"),
("Paramètres", "/params", "settings"), ("Paramètres", "/params", "settings"),
("Purger classe","/purge", "trash-2"), ("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: def _nav_full(label: str, href: str, icon_name: str, close_menu: bool = False) -> rx.Component:
is_active = AuthState.router.page.path == href is_active = AuthState.router.page.path == href
click_handler = AuthState.close_mobile_menu if close_menu else None 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( return rx.link(
rx.hstack( rx.hstack(
rx.box( rx.box(
@ -53,7 +74,8 @@ def _nav_full(label: str, href: str, icon_name: str, close_menu: bool = False) -
), ),
rx.icon( rx.icon(
icon_name, size=17, icon_name, size=17,
color=rx.cond(is_active, _ACTIVE_CLR, _TEXT), color=icon_color,
class_name=icon_class,
flex_shrink="0", flex_shrink="0",
), ),
rx.text( 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: def _nav_rail(label: str, href: str, icon_name: str) -> rx.Component:
is_active = AuthState.router.page.path == href 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( return rx.tooltip(
rx.link( rx.link(
rx.box( rx.box(
rx.icon(icon_name, size=20, rx.icon(icon_name, size=20, color=icon_color, class_name=icon_class),
color=rx.cond(is_active, _ACTIVE_CLR, _TEXT)),
width="100%", width="100%",
display="flex", display="flex",
align_items="center", align_items="center",
@ -525,6 +556,8 @@ _KEYBOARD_SHORTCUTS_JS = """
def layout(content: rx.Component) -> rx.Component: 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( return rx.box(
sidebar(), sidebar(),
_mobile_topbar(), _mobile_topbar(),
@ -543,6 +576,7 @@ def layout(content: rx.Component) -> rx.Component:
transition="margin-left 0.22s ease, width 0.22s ease", transition="margin-left 0.22s ease, width 0.22s ease",
box_sizing="border-box", box_sizing="border-box",
), ),
feedback_widget(),
rx.script(_KEYBOARD_SHORTCUTS_JS), rx.script(_KEYBOARD_SHORTCUTS_JS),
width="100%", width="100%",
height="100vh", height="100vh",

View file

@ -65,6 +65,8 @@ class AuthState(rx.State):
mobile_menu_open: bool = False mobile_menu_open: bool = False
admin_expanded: bool = True admin_expanded: bool = True
doc_expanded: bool = False doc_expanded: bool = False
# Compteur de messages feedback "new" (admin uniquement)
feedback_new_count: int = 0
@rx.var @rx.var
def authenticated(self) -> bool: def authenticated(self) -> bool:
@ -126,8 +128,28 @@ class AuthState(rx.State):
stored_theme = users[self.username].get("theme") or "eptm" stored_theme = users[self.username].get("theme") or "eptm"
if stored_theme != self.theme: if stored_theme != self.theme:
self.theme = stored_theme self.theme = stored_theme
# Compteur feedback (admin uniquement) — pour le badge sidebar
self._refresh_feedback_count()
return self._apply_theme_script(self.theme) 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 @staticmethod
def _apply_theme_script(theme: str): def _apply_theme_script(theme: str):
"""Script JS qui set data-theme sur <html> immédiatement (sans attendre re-render).""" """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) 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): def get_engine(db_url: str | None = None):
url = db_url or f"sqlite:///{DB_PATH}" url = db_url or f"sqlite:///{DB_PATH}"
from sqlalchemy import event as _sa_event from sqlalchemy import event as _sa_event