diff --git a/assets/responsive.css b/assets/responsive.css index 719d59e..863f63e 100644 --- a/assets/responsive.css +++ b/assets/responsive.css @@ -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 . diff --git a/data/class_href_cache.json b/data/class_href_cache.json index bc1c547..6b0065a 100644 --- a/data/class_href_cache.json +++ b/data/class_href_cache.json @@ -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" } \ No newline at end of file diff --git a/data/settings.json b/data/settings.json index 4df381e..8510c13 100644 --- a/data/settings.json +++ b/data/settings.json @@ -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" } \ No newline at end of file diff --git a/eptm_dashboard/eptm_dashboard.py b/eptm_dashboard/eptm_dashboard.py index 3e893af..4502626 100644 --- a/eptm_dashboard/eptm_dashboard.py +++ b/eptm_dashboard/eptm_dashboard.py @@ -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) diff --git a/eptm_dashboard/pages/feedback.py b/eptm_dashboard/pages/feedback.py new file mode 100644 index 0000000..ced6ff0 --- /dev/null +++ b/eptm_dashboard/pages/feedback.py @@ -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%", + ) + ) + diff --git a/eptm_dashboard/pages/params.py b/eptm_dashboard/pages/params.py index bec36f7..9b57639 100644 --- a/eptm_dashboard/pages/params.py +++ b/eptm_dashboard/pages/params.py @@ -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 ") + 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), diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py index 915b8be..78258af 100644 --- a/eptm_dashboard/sidebar.py +++ b/eptm_dashboard/sidebar.py @@ -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", diff --git a/eptm_dashboard/state.py b/eptm_dashboard/state.py index a36f546..babdf7b 100644 --- a/eptm_dashboard/state.py +++ b/eptm_dashboard/state.py @@ -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 immédiatement (sans attendre re-render).""" diff --git a/src/db.py b/src/db.py index cd913c0..2889997 100644 --- a/src/db.py +++ b/src/db.py @@ -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