ajout du chat
This commit is contained in:
parent
9188e6ba1e
commit
eb98ec273c
9 changed files with 1039 additions and 21 deletions
|
|
@ -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>.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
911
eptm_dashboard/pages/feedback.py
Normal file
911
eptm_dashboard/pages/feedback.py
Normal 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%",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)."""
|
||||||
|
|
|
||||||
17
src/db.py
17
src/db.py
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue