eptm_dashboard/eptm_dashboard/pages/params.py
Julien Balet 6d1b7c8044 retenue: avis PDF + notices Escada + mapping profession
- nouvelle page /retenue : sélection apprenti, date retenue, date du
  problème, motif (3 cases mutex), branche (autocomplete + saisie libre
  depuis NotesExamen), remarque. Génération PDF basée sur le template
  AcroForm officiel, séparation des 3 widgets Date partagés en 3 champs
  distincts pour ne remplir que celui de la case cochée. Téléchargement
  ou envoi par email (3 destinataires).
- profession : nouveau champ ApprentiFiche.profession, dérivé du préfixe
  de classe via mapping configurable dans Paramètres
  ("AUTOMAT" → "Automaticien CFC" par défaut). Section dédiée avec
  classes orphelines détectées automatiquement.
- notices Escada : nouvelle table Notice (apprenti, titre, remarque,
  date, status). Checkbox "Ajouter automatiquement une notice sur
  Escada" sur /retenue qui crée une entrée pending. Bloc dédié sur
  /escada listant les pending, bouton "Pousser les notices" qui lance
  scripts/push_notices.py (Playwright : navigation Classes → Élèves →
  Notices → Ajouter, fill date / titre / remarque, vérification post-save,
  suppression DB si OK, marquage failed sinon). Nouveau task_kind "push_notices"
  dans le cron pour exécution planifiée.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:24:15 +02:00

684 lines
24 KiB
Python

import json
import os
import sys
from pathlib import Path
import reflex as rx
_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from src.profession import load_mapping, save_mapping, find_unmapped_classes, refresh_all_professions # noqa: E402
from src.db import get_session # noqa: E402
from ..sidebar import layout
from ..state import AuthState
_ROOT = Path(__file__).resolve().parent.parent.parent
DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
_SETTINGS_FILE = DATA_DIR / "settings.json"
_DEFAULT_SANCTION = (
"Selon le règlement de l'EM, l'apprenti a dépassé le nombre d'absences limite."
)
_DEFAULT_TEMPLATE_SUBJ = "Document EPTM — {nom_complet} ({classe})"
_DEFAULT_TEMPLATE_BODY = (
"Bonjour {prenom},\n\n"
"Veuillez trouver ci-joint votre document pour la classe {classe}.\n\n"
"Cordialement,\nL'équipe EPTM"
)
def _read_settings() -> dict:
if _SETTINGS_FILE.exists():
try:
return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
return {}
def _write_settings(data: dict) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
_SETTINGS_FILE.write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
class ParamsState(AuthState):
# ── Sanction ──────────────────────────────────────────────────────────────
texte_sanction: str = ""
chef_section: str = ""
save_ok_sanction: bool = False
# ── SMTP ──────────────────────────────────────────────────────────────────
smtp_host: str = ""
smtp_port: str = "587"
smtp_login: str = ""
smtp_password: str = ""
smtp_sender: str = ""
save_ok_smtp: bool = False
# ── Escada ────────────────────────────────────────────────────────────────
escada_username: str = ""
escada_password: str = ""
totp_secret: str = ""
save_ok_escada: bool = False
# ── Template email ────────────────────────────────────────────────────────
email_subject: str = ""
email_body: str = ""
save_ok_template: bool = False
# ── App ───────────────────────────────────────────────────────────────────
app_base_url: str = ""
save_ok_app: bool = False
# ── Profession mapping ────────────────────────────────────────────────────
prof_mapping: list[dict] = []
prof_unmapped: list[str] = []
new_prefix: str = ""
new_profession: str = ""
save_ok_prof: bool = False
refresh_msg: str = ""
# ── Setters ───────────────────────────────────────────────────────────────
def set_texte_sanction(self, v: str): self.texte_sanction = v
def set_chef_section(self, v: str): self.chef_section = v
def set_smtp_host(self, v: str): self.smtp_host = v
def set_smtp_port(self, v: str): self.smtp_port = 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_sender(self, v: str): self.smtp_sender = 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
def set_email_subject(self, v: str): self.email_subject = v
def set_email_body(self, v: str): self.email_body = v
def set_app_base_url(self, v: str): self.app_base_url = v
def set_new_prefix(self, v: str): self.new_prefix = v
def set_new_profession(self, v: str): self.new_profession = v
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
s = _read_settings()
self.texte_sanction = s.get("texte_sanction", _DEFAULT_SANCTION)
self.chef_section = s.get("chef_section", "Patrick Rausis")
self.smtp_host = s.get("smtp_host", "smtp-relay.brevo.com")
self.smtp_port = str(s.get("smtp_port", 587))
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.escada_username = s.get("escada_username", "")
self.escada_password = s.get("escada_password", "")
self.totp_secret = s.get("totp_secret", "")
self.email_subject = s.get("email_subject", _DEFAULT_TEMPLATE_SUBJ)
self.email_body = s.get("email_body", _DEFAULT_TEMPLATE_BODY)
self.app_base_url = s.get("app_base_url", "https://dev.dashboard.eptm-automation.ch")
self.save_ok_sanction = False
self.save_ok_smtp = False
self.save_ok_escada = False
self.save_ok_template = False
self.save_ok_app = False
self._reload_prof_mapping()
def _reload_prof_mapping(self):
self.prof_mapping = load_mapping()
sess = get_session()
try:
self.prof_unmapped = find_unmapped_classes(sess)
finally:
sess.close()
self.save_ok_prof = False
self.refresh_msg = ""
def save_sanctions(self):
s = _read_settings()
s["texte_sanction"] = self.texte_sanction.strip()
s["chef_section"] = self.chef_section.strip()
_write_settings(s)
self.save_ok_sanction = True
self.save_ok_smtp = False
self.save_ok_escada = False
self.save_ok_template = False
def save_smtp(self):
s = _read_settings()
s["smtp_host"] = self.smtp_host.strip()
try:
s["smtp_port"] = int(self.smtp_port)
except Exception:
s["smtp_port"] = 587
s["smtp_login"] = self.smtp_login.strip()
s["smtp_password"] = self.smtp_password.strip()
s["smtp_sender"] = self.smtp_sender.strip()
s.pop("smtp_email", None)
_write_settings(s)
self.save_ok_smtp = True
self.save_ok_sanction = False
self.save_ok_escada = False
self.save_ok_template = False
def save_escada(self):
s = _read_settings()
s["escada_username"] = self.escada_username.strip()
s["escada_password"] = self.escada_password.strip()
s["totp_secret"] = self.totp_secret.strip()
_write_settings(s)
self.save_ok_escada = True
self.save_ok_sanction = False
self.save_ok_smtp = False
self.save_ok_template = False
def save_template(self):
s = _read_settings()
s["email_subject"] = self.email_subject
s["email_body"] = self.email_body
_write_settings(s)
self.save_ok_template = True
self.save_ok_sanction = False
self.save_ok_smtp = False
self.save_ok_escada = False
self.save_ok_app = False
def save_app(self):
s = _read_settings()
s["app_base_url"] = self.app_base_url.strip().rstrip("/")
_write_settings(s)
self.save_ok_app = True
self.save_ok_sanction = False
self.save_ok_smtp = False
self.save_ok_escada = False
self.save_ok_template = False
# ── Profession mapping ───────────────────────────────────────────────────
def add_mapping(self):
prefix = self.new_prefix.strip()
prof = self.new_profession.strip()
if not prefix or not prof:
return
cur = list(self.prof_mapping)
# Si le préfixe existe déjà, on met juste à jour la profession
for m in cur:
if m.get("prefix") == prefix:
m["profession"] = prof
break
else:
cur.append({"prefix": prefix, "profession": prof})
save_mapping(cur)
self.new_prefix = ""
self.new_profession = ""
self._reload_prof_mapping()
self.save_ok_prof = True
def remove_mapping(self, prefix: str):
cur = [m for m in self.prof_mapping if m.get("prefix") != prefix]
save_mapping(cur)
self._reload_prof_mapping()
self.save_ok_prof = True
def quick_add_prefix(self, prefix: str):
"""Pré-remplit le formulaire avec une classe orpheline."""
self.new_prefix = prefix
self.new_profession = ""
def apply_mapping_to_db(self):
"""Recalcule la profession pour tous les apprentis avec le mapping actuel."""
sess = get_session()
try:
n = refresh_all_professions(sess)
finally:
sess.close()
self.refresh_msg = f"{n} fiche(s) mise(s) à jour."
# ── UI helpers ────────────────────────────────────────────────────────────────
def _label(text: str) -> rx.Component:
return rx.text(text, size="2", weight="medium", color="var(--gray-11)")
def _section(title: str, *children) -> rx.Component:
return rx.box(
rx.vstack(
rx.text(title, size="4", weight="bold"),
rx.divider(),
*children,
spacing="3",
width="100%",
),
padding="1.25rem",
background_color="white",
border_radius="8px",
border="1px solid #e0e0e0",
width="100%",
)
def _save_ok_callout(show: bool) -> rx.Component:
return rx.cond(
show,
rx.callout.root(
rx.callout.icon(rx.icon("check", size=16)),
rx.callout.text("Paramètres enregistrés."),
color_scheme="green",
variant="soft",
size="1",
),
rx.fragment(),
)
def _input_row(*items) -> rx.Component:
return rx.hstack(*items, spacing="4", width="100%", flex_wrap="wrap")
def _field(label: str, input_component: rx.Component) -> rx.Component:
return rx.vstack(
_label(label),
input_component,
spacing="1",
flex="1",
min_width="200px",
width="100%",
)
# ── Sections ──────────────────────────────────────────────────────────────────
def _section_sanction() -> rx.Component:
return _section(
"Avis de sanction",
_field(
"Texte de description par défaut (champ TexteDescription)",
rx.text_area(
value=ParamsState.texte_sanction,
on_change=ParamsState.set_texte_sanction,
rows="4",
width="100%",
resize="vertical",
),
),
_field(
"Chef de section (CS)",
rx.input(
value=ParamsState.chef_section,
on_change=ParamsState.set_chef_section,
width="100%",
),
),
rx.hstack(
rx.button(
rx.icon("save", size=16),
"Enregistrer sanctions",
on_click=ParamsState.save_sanctions,
color_scheme="blue",
variant="solid",
size="2",
),
_save_ok_callout(ParamsState.save_ok_sanction),
spacing="3",
align="center",
flex_wrap="wrap",
),
)
def _section_smtp() -> rx.Component:
return _section(
"Configuration email",
_input_row(
_field(
"Serveur SMTP",
rx.input(
value=ParamsState.smtp_host,
on_change=ParamsState.set_smtp_host,
placeholder="smtp-relay.brevo.com",
width="100%",
),
),
_field(
"Port",
rx.input(
value=ParamsState.smtp_port,
on_change=ParamsState.set_smtp_port,
type="number",
placeholder="587",
width="100%",
),
),
),
_input_row(
_field(
"Login SMTP (authentification)",
rx.input(
value=ParamsState.smtp_login,
on_change=ParamsState.set_smtp_login,
placeholder="login@domaine.com",
width="100%",
),
),
_field(
"Mot de passe SMTP",
rx.input(
value=ParamsState.smtp_password,
on_change=ParamsState.set_smtp_password,
type="password",
placeholder="••••••••",
width="100%",
),
),
),
_field(
"Expéditeur",
rx.input(
value=ParamsState.smtp_sender,
on_change=ParamsState.set_smtp_sender,
placeholder="EPTM Automation <noreply@eptm-automation.ch>",
width="100%",
),
),
rx.hstack(
rx.button(
rx.icon("save", size=16),
"Enregistrer configuration SMTP",
on_click=ParamsState.save_smtp,
color_scheme="blue",
variant="solid",
size="2",
),
_save_ok_callout(ParamsState.save_ok_smtp),
spacing="3",
align="center",
flex_wrap="wrap",
),
)
def _section_escada() -> rx.Component:
return _section(
"Connexion Escada (synchronisation automatique)",
rx.text(
"Si renseignés, identifiant, mot de passe et code 2FA sont saisis automatiquement lors de la synchronisation.",
size="2",
color="var(--gray-10)",
),
_input_row(
_field(
"Identifiant Escada",
rx.input(
value=ParamsState.escada_username,
on_change=ParamsState.set_escada_username,
placeholder="prenom.nom",
width="100%",
),
),
_field(
"Mot de passe Escada",
rx.input(
value=ParamsState.escada_password,
on_change=ParamsState.set_escada_password,
type="password",
placeholder="••••••••",
width="100%",
),
),
),
_field(
"Clé secrète 2FA (TOTP) — Format Base32",
rx.input(
value=ParamsState.totp_secret,
on_change=ParamsState.set_totp_secret,
type="password",
placeholder="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
width="100%",
),
),
rx.hstack(
rx.button(
rx.icon("save", size=16),
"Enregistrer connexion Escada",
on_click=ParamsState.save_escada,
color_scheme="blue",
variant="solid",
size="2",
),
_save_ok_callout(ParamsState.save_ok_escada),
spacing="3",
align="center",
flex_wrap="wrap",
),
)
def _section_template() -> rx.Component:
return _section(
"Template email",
rx.box(
rx.text(
"Variables disponibles : ",
rx.code("{prenom}"), ", ",
rx.code("{nom}"), ", ",
rx.code("{nom_complet}"), ", ",
rx.code("{classe}"), ", ",
rx.code("{nb_absences}"), ", ",
rx.code("{nb_excusees}"), ", ",
rx.code("{nb_non_excusees}"), ", ",
rx.code("{nb_a_traiter}"), ", ",
rx.code("{semestre}"), ", ",
rx.code("{date_du_jour}"),
size="2",
color="var(--gray-10)",
),
padding="0.5rem 0.75rem",
background_color="var(--gray-2)",
border_radius="6px",
border="1px solid var(--gray-4)",
),
_field(
"Objet",
rx.input(
value=ParamsState.email_subject,
on_change=ParamsState.set_email_subject,
width="100%",
),
),
_field(
"Corps du message",
rx.text_area(
value=ParamsState.email_body,
on_change=ParamsState.set_email_body,
rows="8",
width="100%",
resize="vertical",
font_family="monospace",
font_size="0.85rem",
),
),
rx.hstack(
rx.button(
rx.icon("save", size=16),
"Enregistrer template",
on_click=ParamsState.save_template,
color_scheme="blue",
variant="solid",
size="2",
),
_save_ok_callout(ParamsState.save_ok_template),
spacing="3",
align="center",
flex_wrap="wrap",
),
)
def _mapping_row(m: rx.Var) -> rx.Component:
return rx.flex(
rx.box(
rx.text("Préfixe", size="1", color="var(--gray-10)"),
rx.text(m["prefix"], size="2", weight="medium"),
flex="1", min_width="120px",
),
rx.box(
rx.text("Profession", size="1", color="var(--gray-10)"),
rx.text(m["profession"], size="2"),
flex="2", min_width="200px",
),
rx.button(
rx.icon("trash-2", size=14),
on_click=ParamsState.remove_mapping(m["prefix"]),
color_scheme="red", variant="ghost", size="1",
),
gap="0.75rem", align="center", flex_wrap="wrap",
padding="0.4rem 0.6rem",
border="1px solid var(--gray-5)",
border_radius="6px",
background_color="white",
width="100%",
)
def _unmapped_chip(classe: rx.Var) -> rx.Component:
return rx.button(
rx.icon("plus", size=12),
classe,
on_click=ParamsState.quick_add_prefix(classe),
color_scheme="amber", variant="soft", size="1",
)
def _section_profession() -> rx.Component:
return _section(
"Correspondances classe → profession",
rx.text(
"Lors de l'import des données apprentis, la profession est dérivée "
"du préfixe de la classe (ex. classe « AUTOMAT 1 » → profession "
"« Automaticien CFC »). Utilisée notamment dans les avis de retenue.",
size="1", color="var(--gray-11)",
),
# Tableau des correspondances
rx.cond(
ParamsState.prof_mapping.length() > 0,
rx.vstack(
rx.foreach(ParamsState.prof_mapping, _mapping_row),
spacing="2", width="100%",
),
rx.text("Aucune correspondance configurée.", size="2", color="var(--gray-10)"),
),
# Classes orphelines
rx.cond(
ParamsState.prof_unmapped.length() > 0,
rx.box(
rx.text(
"Classes sans correspondance (clique pour ajouter) :",
size="2", weight="medium", color="#92400e", margin_bottom="0.4rem",
),
rx.flex(
rx.foreach(ParamsState.prof_unmapped, _unmapped_chip),
gap="0.35rem", flex_wrap="wrap",
),
padding="0.75rem",
background_color="#fef3c7",
border="1px solid #fcd34d",
border_radius="6px",
width="100%",
),
rx.fragment(),
),
# Ajout d'une nouvelle correspondance
rx.divider(),
rx.text("Ajouter / modifier une correspondance", size="2", weight="medium"),
rx.flex(
_field(
"Préfixe de classe",
rx.input(
value=ParamsState.new_prefix,
on_change=ParamsState.set_new_prefix,
placeholder="ex. AUTOMAT",
width="100%",
),
),
_field(
"Profession",
rx.input(
value=ParamsState.new_profession,
on_change=ParamsState.set_new_profession,
placeholder="ex. Automaticien CFC",
width="100%",
),
),
gap="0.75rem", flex_wrap="wrap", width="100%",
),
rx.flex(
rx.button(
rx.icon("plus", size=16),
"Ajouter / mettre à jour",
on_click=ParamsState.add_mapping,
color_scheme="blue", size="2",
),
rx.button(
rx.icon("refresh-cw", size=14),
"Appliquer aux fiches existantes",
on_click=ParamsState.apply_mapping_to_db,
color_scheme="gray", variant="soft", size="2",
),
_save_ok_callout(ParamsState.save_ok_prof),
rx.cond(
ParamsState.refresh_msg != "",
rx.text(ParamsState.refresh_msg, size="1", color="#15803d"),
rx.fragment(),
),
gap="0.5rem", align="center", flex_wrap="wrap",
),
)
def _section_app() -> rx.Component:
return _section(
"Application",
rx.text(
"URL de base de l'application — utilisée pour générer les liens "
"envoyés par email (création de compte, réinitialisation de mot de passe).",
size="1", color="var(--gray-11)",
),
_field(
"URL de base (sans /)",
rx.input(
value=ParamsState.app_base_url,
on_change=ParamsState.set_app_base_url,
placeholder="https://dashboard.eptm-automation.ch",
width="100%",
),
),
rx.hstack(
rx.button(
rx.icon("save", size=16),
"Enregistrer",
on_click=ParamsState.save_app,
color_scheme="blue", variant="solid", size="2",
),
_save_ok_callout(ParamsState.save_ok_app),
spacing="3", align="center", flex_wrap="wrap",
),
)
# ── Page ──────────────────────────────────────────────────────────────────────
def params_page() -> rx.Component:
return layout(
rx.vstack(
rx.heading("Paramètres", size="7"),
_section_app(),
_section_profession(),
_section_sanction(),
_section_smtp(),
_section_escada(),
_section_template(),
spacing="5",
width="100%",
max_width="860px",
)
)