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);
|
||||
}
|
||||
|
||||
/* ── Utility classes ─────────────────────────────────────────────────────── */
|
||||
|
||||
/* Scroll discret : pas de scrollbar visible mais la zone scroll quand même
|
||||
(utilisé par le chat de feedback). */
|
||||
.no-scrollbar { scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Badge avec animation pulse — utilisé pour indiquer les messages non lus. */
|
||||
@keyframes feedback-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.15); opacity: 0.85; }
|
||||
}
|
||||
.pulse-badge {
|
||||
animation: feedback-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
/* ── Brand tokens (thèmes utilisateur) ───────────────────────────────────────
|
||||
Tokens utilisés par l'app pour les couleurs de marque. Chaque thème surcharge
|
||||
ces variables via [data-theme="..."] sur <html>.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
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_password: str = ""
|
||||
smtp_sender: str = ""
|
||||
feedback_admin_email: str = "" # destinataire notifs feedback in-app
|
||||
save_ok_smtp: bool = False
|
||||
|
||||
# ── Escada ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -91,6 +92,7 @@ class ParamsState(AuthState):
|
|||
def set_smtp_login(self, v: str): self.smtp_login = v
|
||||
def set_smtp_password(self, v: str): self.smtp_password = v
|
||||
def set_smtp_sender(self, v: str): self.smtp_sender = v
|
||||
def set_feedback_admin_email(self, v: str): self.feedback_admin_email = v
|
||||
def set_escada_username(self, v: str): self.escada_username = v
|
||||
def set_escada_password(self, v: str): self.escada_password = v
|
||||
def set_totp_secret(self, v: str): self.totp_secret = v
|
||||
|
|
@ -111,6 +113,7 @@ class ParamsState(AuthState):
|
|||
self.smtp_login = s.get("smtp_login", s.get("smtp_email", ""))
|
||||
self.smtp_password = s.get("smtp_password", "")
|
||||
self.smtp_sender = s.get("smtp_sender", "EPTM Automation <noreply@eptm-automation.ch>")
|
||||
self.feedback_admin_email = s.get("feedback_admin_email", "")
|
||||
self.escada_username = s.get("escada_username", "")
|
||||
self.escada_password = s.get("escada_password", "")
|
||||
self.totp_secret = s.get("totp_secret", "")
|
||||
|
|
@ -154,6 +157,7 @@ class ParamsState(AuthState):
|
|||
s["smtp_login"] = self.smtp_login.strip()
|
||||
s["smtp_password"] = self.smtp_password.strip()
|
||||
s["smtp_sender"] = self.smtp_sender.strip()
|
||||
s["feedback_admin_email"] = self.feedback_admin_email.strip()
|
||||
s.pop("smtp_email", None)
|
||||
_write_settings(s)
|
||||
self.save_ok_smtp = True
|
||||
|
|
@ -380,6 +384,16 @@ def _section_smtp() -> rx.Component:
|
|||
width="100%",
|
||||
),
|
||||
),
|
||||
_field(
|
||||
"Email admin (feedback in-app)",
|
||||
rx.input(
|
||||
value=ParamsState.feedback_admin_email,
|
||||
on_change=ParamsState.set_feedback_admin_email,
|
||||
placeholder="admin@eptm-automation.ch",
|
||||
type="email",
|
||||
width="100%",
|
||||
),
|
||||
),
|
||||
rx.hstack(
|
||||
rx.button(
|
||||
rx.icon("save", size=16),
|
||||
|
|
|
|||
|
|
@ -32,13 +32,34 @@ _ADMIN_PAGES = [
|
|||
("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",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ class AuthState(rx.State):
|
|||
mobile_menu_open: bool = False
|
||||
admin_expanded: bool = True
|
||||
doc_expanded: bool = False
|
||||
# Compteur de messages feedback "new" (admin uniquement)
|
||||
feedback_new_count: int = 0
|
||||
|
||||
@rx.var
|
||||
def authenticated(self) -> bool:
|
||||
|
|
@ -126,8 +128,28 @@ class AuthState(rx.State):
|
|||
stored_theme = users[self.username].get("theme") or "eptm"
|
||||
if stored_theme != self.theme:
|
||||
self.theme = stored_theme
|
||||
# Compteur feedback (admin uniquement) — pour le badge sidebar
|
||||
self._refresh_feedback_count()
|
||||
return self._apply_theme_script(self.theme)
|
||||
|
||||
def _refresh_feedback_count(self):
|
||||
if self.role != "admin":
|
||||
self.feedback_new_count = 0
|
||||
return
|
||||
try:
|
||||
from src.db import get_session, FeedbackMessage
|
||||
from sqlalchemy import select, func
|
||||
sess = get_session()
|
||||
try:
|
||||
self.feedback_new_count = sess.execute(
|
||||
select(func.count(FeedbackMessage.id))
|
||||
.where(FeedbackMessage.status == "new")
|
||||
).scalar() or 0
|
||||
finally:
|
||||
sess.close()
|
||||
except Exception:
|
||||
self.feedback_new_count = 0
|
||||
|
||||
@staticmethod
|
||||
def _apply_theme_script(theme: str):
|
||||
"""Script JS qui set data-theme sur <html> immédiatement (sans attendre re-render)."""
|
||||
|
|
|
|||
17
src/db.py
17
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue