avis de sanction dans fiche apprenti

This commit is contained in:
Julien Balet 2026-05-11 15:22:55 +02:00
parent ef6072112b
commit 610e37d2a1
4 changed files with 329 additions and 76 deletions

View file

@ -418,9 +418,6 @@ class FicheState(AuthState):
cal_next_name: str = ""
cal_days: list[dict] = []
# ── Pending dates (quick excuse) ─────────────────────────────────────────
pending_dates: list[dict] = []
# ── Calendar day edit ─────────────────────────────────────────────────────
edit_date: str = ""
edit_date_label: str = ""
@ -435,6 +432,19 @@ class FicheState(AuthState):
edit_p9: str = "present"
edit_p10: str = "present"
# Snapshot des choix au chargement (pour détecter les modifications non
# enregistrées). Mis à jour par _load_day_choices().
initial_p1: str = "present"
initial_p2: str = "present"
initial_p3: str = "present"
initial_p4: str = "present"
initial_p5: str = "present"
initial_p6: str = "present"
initial_p7: str = "present"
initial_p8: str = "present"
initial_p9: str = "present"
initial_p10: str = "present"
# ── Escada fiche ─────────────────────────────────────────────────────────
fiche_available: bool = False
fiche_adresse: str = ""
@ -531,6 +541,7 @@ class FicheState(AuthState):
self.selected_id = self.apprenti_ids[0]
self.selected_label = self.apprenti_labels[0]
self._reload(reset_email=True)
self._select_today()
def handle_select(self, label: str):
self.selected_label = label
@ -543,6 +554,13 @@ class FicheState(AuthState):
self.apprenti_select_open = False
self.apprenti_search = ""
self._reload(reset_email=True)
self._select_today()
def _select_today(self):
"""Pré-sélectionne la date du jour dans le panneau d'édition."""
today_iso = date.today().isoformat()
self._load_day_choices(today_iso)
self.edit_date = today_iso
def set_apprenti_search(self, v: str):
self.apprenti_search = v
@ -600,12 +618,8 @@ class FicheState(AuthState):
self._rebuild_calendar()
# ── Calendar day edit ─────────────────────────────────────────────────────
def select_day(self, date_str: str):
if not date_str:
return
if self.edit_date == date_str:
self.edit_date = ""
return
def _load_day_choices(self, date_str: str):
"""Met à jour edit_p1..p10 + edit_date_label pour la date donnée."""
sess = get_session()
d = date.fromisoformat(date_str)
absences = sess.execute(
@ -632,9 +646,74 @@ class FicheState(AuthState):
self.edit_p8 = _choice(8)
self.edit_p9 = _choice(9)
self.edit_p10 = _choice(10)
# Snapshot des choix initiaux (pour détecter les modifs)
self.initial_p1 = self.edit_p1
self.initial_p2 = self.edit_p2
self.initial_p3 = self.edit_p3
self.initial_p4 = self.edit_p4
self.initial_p5 = self.edit_p5
self.initial_p6 = self.edit_p6
self.initial_p7 = self.edit_p7
self.initial_p8 = self.edit_p8
self.initial_p9 = self.edit_p9
self.initial_p10 = self.edit_p10
self.edit_date_label = d.strftime("%d.%m.%Y")
def select_day(self, date_str: str):
if not date_str:
return
if self.edit_date == date_str:
self.edit_date = ""
return
self._load_day_choices(date_str)
self.edit_date = date_str
@rx.var
def edit_has_changes(self) -> bool:
"""True si au moins une période diffère de l'état chargé en DB."""
return (
self.edit_p1 != self.initial_p1 or
self.edit_p2 != self.initial_p2 or
self.edit_p3 != self.initial_p3 or
self.edit_p4 != self.initial_p4 or
self.edit_p5 != self.initial_p5 or
self.edit_p6 != self.initial_p6 or
self.edit_p7 != self.initial_p7 or
self.edit_p8 != self.initial_p8 or
self.edit_p9 != self.initial_p9 or
self.edit_p10 != self.initial_p10
)
@rx.var
def edit_has_non_excusee(self) -> bool:
"""True si au moins une période est en N (non excusée)."""
return (
self.edit_p1 == "non_excusee" or
self.edit_p2 == "non_excusee" or
self.edit_p3 == "non_excusee" or
self.edit_p4 == "non_excusee" or
self.edit_p5 == "non_excusee" or
self.edit_p6 == "non_excusee" or
self.edit_p7 == "non_excusee" or
self.edit_p8 == "non_excusee" or
self.edit_p9 == "non_excusee" or
self.edit_p10 == "non_excusee"
)
def excuse_all_visual(self):
"""Bascule toutes les N → E dans le panneau (sans toucher la DB).
L'enregistrement passe par le bouton « Enregistrer »."""
if self.edit_p1 == "non_excusee": self.edit_p1 = "excusee"
if self.edit_p2 == "non_excusee": self.edit_p2 = "excusee"
if self.edit_p3 == "non_excusee": self.edit_p3 = "excusee"
if self.edit_p4 == "non_excusee": self.edit_p4 = "excusee"
if self.edit_p5 == "non_excusee": self.edit_p5 = "excusee"
if self.edit_p6 == "non_excusee": self.edit_p6 = "excusee"
if self.edit_p7 == "non_excusee": self.edit_p7 = "excusee"
if self.edit_p8 == "non_excusee": self.edit_p8 = "excusee"
if self.edit_p9 == "non_excusee": self.edit_p9 = "excusee"
if self.edit_p10 == "non_excusee": self.edit_p10 = "excusee"
def cancel_edit(self):
self.edit_date = ""
@ -705,6 +784,10 @@ class FicheState(AuthState):
)
sess.commit()
self._reload(reset_email=False)
# Resync du snapshot pour que edit_has_changes reparte à False
# tant qu'aucune nouvelle modif n'est faite.
if self.edit_date:
self._load_day_choices(self.edit_date)
if nb_changes == 0:
return rx.toast.info("Aucune modification")
msg = (
@ -745,9 +828,10 @@ class FicheState(AuthState):
f"{old_type} → E (excuse rapide)"
)
sess.commit()
if self.edit_date == date_str:
self.edit_date = ""
self._reload(reset_email=False)
# Rester sur la date sélectionnée et rafraîchir les choix du panneau.
if self.edit_date == date_str:
self._load_day_choices(date_str)
if nb == 0:
return rx.toast.info("Aucune absence à excuser")
msg = (
@ -890,19 +974,6 @@ class FicheState(AuthState):
self.kpi_blocs = nb_blocs_absences(sess, self.selected_id)
self.quota_atteint = self.kpi_blocs >= QUOTA
# Pending dates
by_date: dict = {}
for ab in absences:
by_date.setdefault(ab.date, []).append(ab)
self.pending_dates = [
{
"date_str": d.isoformat(),
"label": f"{d.strftime('%d.%m')} ({sum(1 for a in al if a.statut == 'a_traiter')})",
}
for d, al in sorted(by_date.items())
if any(a.statut == "a_traiter" for a in al)
]
# Fiche
fiche = sess.execute(
select(ApprentiFiche).where(ApprentiFiche.apprenti_id == self.selected_id)
@ -1340,18 +1411,33 @@ def _edit_panel() -> rx.Component:
flex_wrap="wrap",
width="100%",
),
rx.hstack(
# Action rapide : excuser visuellement toutes les N → E.
# N'enregistre pas en DB — il faut cliquer sur « Enregistrer ».
rx.flex(
rx.button(
rx.icon("check-check", size=14),
"Excuser toutes les périodes",
on_click=FicheState.excuse_all_visual,
disabled=~FicheState.edit_has_non_excusee,
variant="soft", color_scheme="green", size="2",
),
width="100%",
),
rx.divider(),
rx.flex(
rx.button(
rx.icon("save", size=14), "Enregistrer",
on_click=FicheState.save_day_edit,
disabled=~FicheState.edit_has_changes,
color_scheme="blue", size="2",
),
rx.button(
"Annuler",
on_click=FicheState.cancel_edit,
disabled=~FicheState.edit_has_changes,
variant="outline", color_scheme="gray", size="2",
),
spacing="3",
gap="0.75rem", flex_wrap="wrap",
),
spacing="3", width="100%",
),
@ -1441,17 +1527,6 @@ def _notice_row(item) -> rx.Component:
)
def _pending_btn(item: dict) -> rx.Component:
return rx.button(
rx.icon("check", size=13),
item["label"],
on_click=FicheState.excuse_day(item["date_str"]),
color_scheme="green",
variant="soft",
size="1",
)
def _email_section() -> rx.Component:
return rx.box(
rx.vstack(
@ -1849,33 +1924,6 @@ def fiche_page() -> rx.Component:
rx.text("Aucune absence enregistrée.", size="2", color="#666"),
),
# ── Actions rapides ───────────────────────────────────────
rx.cond(
FicheState.pending_dates.length() > 0,
rx.box(
rx.vstack(
rx.hstack(
rx.icon("clock", size=15, color="#b45309"),
rx.text(
"Valider toutes les absences d'une journée",
size="2", weight="bold", color="#92400e",
),
spacing="2", align="center",
),
rx.flex(
rx.foreach(FicheState.pending_dates, _pending_btn),
flex_wrap="wrap", gap="0.5rem",
),
spacing="2", width="100%",
),
padding="0.75rem 1rem",
background_color="#fffbeb",
border_radius="8px",
border="1px solid #fcd34d",
width="100%",
),
),
# ── Panneau d'édition ─────────────────────────────────────
rx.cond(
FicheState.edit_date != "",

View file

@ -156,7 +156,9 @@ class RetenueState(AuthState):
self.modal_open = True
def _detect_existing_notice(self, sess, apprenti_id: int):
"""Détecte si une Notice pending existe déjà aujourd'hui pour cet apprenti."""
"""Détecte si une Notice de retenue pending existe déjà aujourd'hui
pour cet apprenti. Filtre par source pour ne pas confondre avec une
notice de sanction."""
today = _date.today()
existing = sess.execute(
select(Notice)
@ -164,6 +166,7 @@ class RetenueState(AuthState):
Notice.apprenti_id == apprenti_id,
Notice.date_event == today,
Notice.status == "pending",
Notice.source == "retenue",
)
.order_by(Notice.created_at.desc())
).scalars().first()

View file

@ -10,16 +10,20 @@ from __future__ import annotations
import json
import os
import sys
from datetime import date as _date
from pathlib import Path
import reflex as rx
from sqlalchemy import select
_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, Apprenti, ApprentiFiche # noqa: E402
from src.sanction_pdf import generate_avis_pdf # noqa: E402
from src.db import get_session, Apprenti, ApprentiFiche, Notice # noqa: E402
from src.sanction_pdf import ( # noqa: E402
generate_avis_pdf, _DEFAULT_TEXTE_SANCTION, _DEFAULT_CHEF_SECTION,
)
from src.email_sender import send_email # noqa: E402
from src.user_access import is_class_allowed # noqa: E402
from src.logger import app_log # noqa: E402
@ -52,10 +56,20 @@ class SanctionState(AuthState):
sel_fiche_email_appr: str = ""
sel_fiche_email_form: str = ""
# Texte de description et chef de section, pré-remplis depuis Paramètres
# (ou valeurs par défaut) à chaque preload — modifiables librement.
texte_description: str = ""
chef_section: str = ""
# Email
email_dest: str = "apprenti"
email_custom: str = ""
# Détection notice existante (pending) pour cet apprenti à la date du jour
has_existing_notice: bool = False
existing_notice_label: str = ""
create_anyway: bool = False
form_error: str = ""
def set_modal_open(self, v: bool):
@ -63,8 +77,11 @@ class SanctionState(AuthState):
if not v:
self.form_error = ""
def set_email_dest(self, v: str): self.email_dest = v
def set_email_custom(self, v: str): self.email_custom = v
def set_email_dest(self, v: str): self.email_dest = v
def set_email_custom(self, v: str): self.email_custom = v
def set_texte_description(self, v: str): self.texte_description = v
def set_chef_section(self, v: str): self.chef_section = v
def set_create_anyway(self, v: bool): self.create_anyway = v
def preload_apprenti(self, apprenti_id: int, label: str):
self.selected_id = apprenti_id
@ -72,6 +89,15 @@ class SanctionState(AuthState):
self.form_error = ""
self.email_dest = "apprenti"
self.email_custom = ""
# Pré-remplit texte + chef de section avec Paramètres ou valeurs par défaut.
settings = _load_settings()
self.texte_description = (
(settings.get("texte_sanction") or "").strip() or _DEFAULT_TEXTE_SANCTION
)
self.chef_section = (
(settings.get("chef_section") or "").strip() or _DEFAULT_CHEF_SECTION
)
self.create_anyway = False
sess = get_session()
try:
ap = sess.get(Apprenti, apprenti_id)
@ -84,10 +110,75 @@ class SanctionState(AuthState):
else:
self.sel_fiche_email_appr = ""
self.sel_fiche_email_form = ""
self._detect_existing_notice(sess, apprenti_id)
finally:
sess.close()
self.modal_open = True
def _detect_existing_notice(self, sess, apprenti_id: int):
"""Détecte si une Notice de sanction pending existe déjà aujourd'hui
pour cet apprenti. Filtre par source pour ne pas confondre avec une
notice de retenue."""
today = _date.today()
existing = sess.execute(
select(Notice)
.where(
Notice.apprenti_id == apprenti_id,
Notice.date_event == today,
Notice.status == "pending",
Notice.source == "sanction",
)
.order_by(Notice.created_at.desc())
).scalars().first()
if existing:
self.has_existing_notice = True
self.existing_notice_label = (
f"{existing.titre or '(sans titre)'}"
f"créée le {existing.created_at.strftime('%d.%m.%Y %H:%M')}"
)
else:
self.has_existing_notice = False
self.existing_notice_label = ""
def _create_notice(self):
"""Crée une Notice en DB (push queue Escada).
Si une notice pending existe déjà pour cet apprenti aujourd'hui et que
l'utilisateur n'a pas coché « Créer quand même », on saute la création.
"""
if not self.selected_id:
return
if self.has_existing_notice and not self.create_anyway:
app_log(
f"[notice] {self.username or '?'} : notice doublon évitée pour "
f"{self.selected_label} (existante : {self.existing_notice_label})"
)
return
remarque = (self.texte_description or "").strip() or None
sess = get_session()
try:
sess.add(Notice(
apprenti_id=self.selected_id,
date_event=_date.today(),
titre="Avis de sanction",
remarque=remarque,
type_notice=None,
matiere=None,
source="sanction",
status="pending",
created_by=self.username or None,
))
sess.commit()
app_log(
f"[notice] {self.username or '?'} : création (sanction) pour "
f"{self.selected_label}"
)
except Exception as e:
sess.rollback()
app_log(f"[notice] échec création : {e}")
finally:
sess.close()
def _build_pdf(self) -> bytes | None:
if not self.selected_id:
self.form_error = "Aucun apprenti sélectionné."
@ -95,12 +186,18 @@ class SanctionState(AuthState):
if not is_class_allowed(self.username, self.sel_classe):
self.form_error = "Accès refusé pour cette classe."
return None
txt = (self.texte_description or "").strip()
if not txt:
self.form_error = "Le texte de description ne peut pas être vide."
return None
self.form_error = ""
sess = get_session()
try:
return generate_avis_pdf(
sess, self.selected_id,
prof_name=self.name or self.username,
texte_override=txt,
chef_override=(self.chef_section or "").strip() or None,
)
finally:
sess.close()
@ -125,10 +222,11 @@ class SanctionState(AuthState):
f"[sanction] {self.username or '?'} : avis téléchargé pour "
f"{self.selected_label}"
)
self._create_notice()
self.modal_open = False
return [
rx.download(data=data, filename=self._filename()),
rx.toast.success("Avis de sanction téléchargé"),
rx.toast.success("Avis de sanction téléchargé — notice ajoutée à la file Escada"),
]
def send_email_action(self):
@ -174,12 +272,101 @@ class SanctionState(AuthState):
f"[sanction] {self.username or '?'} : avis envoyé à {to} pour "
f"{self.selected_label}"
)
self._create_notice()
self.modal_open = False
return rx.toast.success(f"Avis de sanction envoyé à {to}")
return rx.toast.success(
f"Avis de sanction envoyé à {to} — notice ajoutée à la file Escada"
)
# ── UI ────────────────────────────────────────────────────────────────────────
def _texte_section() -> rx.Component:
"""Texte de description + chef de section, pré-remplis depuis Paramètres
(ou fallback). L'utilisateur les modifie librement avant génération."""
return rx.box(
rx.vstack(
rx.flex(
rx.icon("file-text", size=16, color="#37474f"),
rx.text("Contenu de l'avis", size="3", weight="bold", color="#37474f"),
gap="0.5rem", align="center",
),
rx.divider(),
rx.text(
"Pré-remplis depuis les Paramètres. Modifiables avant génération.",
size="1", color="var(--gray-11)",
),
rx.vstack(
rx.text("Texte de description", size="2", weight="medium", color="var(--gray-11)"),
rx.text_area(
value=SanctionState.texte_description,
on_change=SanctionState.set_texte_description,
rows="8",
width="100%",
),
spacing="1", width="100%",
),
rx.vstack(
rx.text("Chef de section", size="2", weight="medium", color="var(--gray-11)"),
rx.input(
value=SanctionState.chef_section,
on_change=SanctionState.set_chef_section,
width="100%",
),
spacing="1", width="100%",
),
spacing="3", width="100%",
),
padding="1.25rem",
background_color="white",
border_radius="8px",
border="1px solid #e0e0e0",
width="100%",
)
def _duplicate_notice_banner() -> rx.Component:
"""Bannière jaune si une notice pending existe déjà aujourd'hui (avec checkbox override)."""
return rx.cond(
SanctionState.has_existing_notice,
rx.box(
rx.vstack(
rx.flex(
rx.icon("triangle-alert", size=16, color="#92400e"),
rx.text(
"Une notice est déjà en attente aujourd'hui pour cet apprenti :",
size="2", weight="medium", color="#92400e",
),
gap="0.5rem", align="center",
),
rx.text(
SanctionState.existing_notice_label,
size="1", color="#78350f",
),
rx.flex(
rx.checkbox(
checked=SanctionState.create_anyway,
on_change=SanctionState.set_create_anyway,
size="2",
),
rx.text(
"Créer quand même une nouvelle notice",
size="2", color="#78350f",
),
gap="0.5rem", align="center",
),
spacing="2", width="100%",
),
padding="0.75rem 1rem",
background_color="#fef3c7",
border="1px solid #fcd34d",
border_radius="6px",
width="100%",
),
rx.fragment(),
)
def _email_section() -> rx.Component:
return rx.box(
rx.vstack(
@ -278,13 +465,15 @@ def sanction_modal() -> rx.Component:
rx.callout.root(
rx.callout.icon(rx.icon("info", size=16)),
rx.callout.text(
"L'avis utilise le texte par défaut configuré dans ",
"L'adresse et le nom de l'entreprise proviennent de la fiche "
"apprenti Escada. Les valeurs par défaut sont configurables dans ",
rx.link("Paramètres", href="/params", color="var(--brand-accent)"),
" (motif et chef de section). L'adresse et le nom de l'entreprise "
"proviennent de la fiche apprenti Escada.",
".",
),
color_scheme="blue", variant="soft", size="1",
),
_texte_section(),
_duplicate_notice_banner(),
rx.cond(
SanctionState.form_error != "",
rx.callout.root(

View file

@ -44,6 +44,8 @@ def generate_avis_pdf(
sess: Session,
apprenti_id: int,
prof_name: str = "",
texte_override: Optional[str] = None,
chef_override: Optional[str] = None,
) -> Optional[bytes]:
"""Renvoie les bytes d'un PDF d'avis de sanction pré-rempli pour l'apprenti.
@ -51,6 +53,9 @@ def generate_avis_pdf(
NomParents = nom entreprise) puisque les parents ne sont pas stockés.
Texte de description et chef de section depuis data/settings.json.
Si `texte_override` ou `chef_override` est fourni (non vide), il remplace
la valeur issue des paramètres.
Renvoie None si le template est introuvable ou l'apprenti n'existe pas.
"""
if not _TEMPLATE_PATH.exists():
@ -77,9 +82,17 @@ def generate_avis_pdf(
"Adresse": (fiche.entreprise_adresse if fiche else "") or "",
"NPA-Ville": npa_ville,
"Date": date.today().strftime("%d.%m.%Y"),
"TexteDescription": settings.get("texte_sanction") or _DEFAULT_TEXTE_SANCTION,
"TexteDescription": (
(texte_override or "").strip()
or settings.get("texte_sanction")
or _DEFAULT_TEXTE_SANCTION
),
"Prof": prof_name or "",
"CS": settings.get("chef_section") or _DEFAULT_CHEF_SECTION,
"CS": (
(chef_override or "").strip()
or settings.get("chef_section")
or _DEFAULT_CHEF_SECTION
),
}
# Lecture du template + clone vers writer (préserve la structure AcroForm)