From 23e0b2bf60d8ac9965eafbdbbd228564a25de7a1 Mon Sep 17 00:00:00 2001 From: Julien Balet Date: Sun, 10 May 2026 10:57:28 +0200 Subject: [PATCH] page apprentis ok --- data/auth.yaml | 2 +- eptm_dashboard/pages/fiche.py | 820 +++++++++++++++++++++++++++++----- eptm_dashboard/pages/login.py | 213 +++++++-- eptm_dashboard/state.py | 161 ++++++- 4 files changed, 1028 insertions(+), 168 deletions(-) diff --git a/data/auth.yaml b/data/auth.yaml index f5637d2..59a2b05 100644 --- a/data/auth.yaml +++ b/data/auth.yaml @@ -11,7 +11,7 @@ credentials: password: $2b$12$kigcAqfs9VIySuVHxenU6uTyk/8ef7DrzybCFCzw.iZOZTpzxVsOi role: admin smtp_password: 17acdfd671d8ab - totp_secret: null + totp_secret: H6QDWOPHK4GBT447VCKI6VDKEEUVFQZY test: email: julien@balet-vs.ch name: test diff --git a/eptm_dashboard/pages/fiche.py b/eptm_dashboard/pages/fiche.py index 6c352e8..fd9423d 100644 --- a/eptm_dashboard/pages/fiche.py +++ b/eptm_dashboard/pages/fiche.py @@ -4,20 +4,23 @@ import io import json import os import reflex as rx -from datetime import date, timedelta +from datetime import date, datetime, timedelta from pathlib import Path from sqlalchemy import select DATA_DIR = Path(os.getenv("DATA_DIR", "data")) +_SETTINGS_FILE = DATA_DIR / "settings.json" from ..state import AuthState from ..sidebar import layout from src.db import ( get_session, Apprenti, Absence, ApprentiFiche, NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu, + upsert_escada_pending, ) from src.stats import nb_blocs_absences from src.parser_bn import sem_short_label +from src.email_sender import build_template_vars, render_template MOIS_FR = [ "janvier", "fevrier", "mars", "avril", "mai", "juin", @@ -33,7 +36,16 @@ _GROUP_LABELS = { _GROUP_ORDER = {"DUAL": ["CG", "BP"], "EM": ["BP", "TP"]} -# ── HTML generators (pure Python, no Streamlit dependency) ─────────────────── +def _read_settings() -> dict: + if _SETTINGS_FILE.exists(): + try: + return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8")) + except Exception: + return {} + return {} + + +# ── HTML generators ─────────────────────────────────────────────────────────── def _bn_fmt(v) -> str: if v is None: @@ -172,7 +184,16 @@ def _render_notes_html(notes_data: list) -> str: f'
' f'{_br_name}' f'Moyenne : {_moy_html}
' - "" + '
DateExamenEnseignant
' + '' + '' + '' + '' + '' + '' + '' + '' + "" "" ) for _ex in _br.get("examens", []): @@ -311,8 +332,7 @@ def _absence_pdf_apprenti(sess, apprenti) -> bytes: buf = io.BytesIO() doc = SimpleDocTemplate( - buf, - pagesize=landscape(A4), + buf, pagesize=landscape(A4), leftMargin=1.5 * cm, rightMargin=1.5 * cm, topMargin=1.5 * cm, bottomMargin=1.5 * cm, ) @@ -355,18 +375,21 @@ def _extract_bn_pages(pdf_path, nom: str, prenom: str) -> bytes | None: # ── State ───────────────────────────────────────────────────────────────────── class FicheState(AuthState): + # ── Apprenti selector ──────────────────────────────────────────────────── apprenti_labels: list[str] = [] apprenti_ids: list[int] = [] selected_label: str = "" selected_id: int = 0 has_apprentis: bool = False + # ── KPIs ───────────────────────────────────────────────────────────────── kpi_total: int = 0 kpi_excusees: int = 0 kpi_non_excusees: int = 0 kpi_blocs: int = 0 quota_atteint: bool = False + # ── Calendar ───────────────────────────────────────────────────────────── cal_year: int = 0 cal_month: int = 0 cal_month_name: str = "" @@ -374,6 +397,24 @@ 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 = "" + edit_p1: str = "present" + edit_p2: str = "present" + edit_p3: str = "present" + edit_p4: str = "present" + edit_p5: str = "present" + edit_p6: str = "present" + edit_p7: str = "present" + edit_p8: str = "present" + edit_p9: str = "present" + edit_p10: str = "present" + + # ── Escada fiche ───────────────────────────────────────────────────────── fiche_available: bool = False fiche_adresse: str = "" fiche_cp_localite: str = "" @@ -390,6 +431,7 @@ class FicheState(AuthState): fiche_formateur_email: str = "" fiche_updated_at: str = "" + # ── Bulletin de notes ───────────────────────────────────────────────────── has_bn: bool = False bn_html: str = "" bn_caption: str = "" @@ -399,6 +441,41 @@ class FicheState(AuthState): has_pdf_bn: bool = False has_pdf_notes: bool = False + # ── Email ───────────────────────────────────────────────────────────────── + smtp_ok: bool = False + email_dest: str = "apprenti" + email_custom: str = "" + email_subject: str = "" + email_body: str = "" + email_attach_abs: bool = True + email_attach_bn: bool = False + email_attach_notes: bool = False + email_sending: bool = False + email_sent: bool = False + email_error: str = "" + + # ── Setters (edit periods) ──────────────────────────────────────────────── + def set_edit_p1(self, v: str): self.edit_p1 = v + def set_edit_p2(self, v: str): self.edit_p2 = v + def set_edit_p3(self, v: str): self.edit_p3 = v + def set_edit_p4(self, v: str): self.edit_p4 = v + def set_edit_p5(self, v: str): self.edit_p5 = v + def set_edit_p6(self, v: str): self.edit_p6 = v + def set_edit_p7(self, v: str): self.edit_p7 = v + def set_edit_p8(self, v: str): self.edit_p8 = v + def set_edit_p9(self, v: str): self.edit_p9 = v + def set_edit_p10(self, v: str): self.edit_p10 = v + + # ── Setters (email) ─────────────────────────────────────────────────────── + 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_subject(self, v: str): self.email_subject = v + def set_email_body(self, v: str): self.email_body = v + def toggle_attach_abs(self, v: bool): self.email_attach_abs = v + def toggle_attach_bn(self, v: bool): self.email_attach_bn = v + def toggle_attach_notes(self, v: bool): self.email_attach_notes = v + + # ── Page load ───────────────────────────────────────────────────────────── def load_data(self): if not self.authenticated: return rx.redirect("/login") @@ -419,7 +496,7 @@ class FicheState(AuthState): if self.selected_id == 0 or self.selected_id not in self.apprenti_ids: self.selected_id = self.apprenti_ids[0] self.selected_label = self.apprenti_labels[0] - self._reload() + self._reload(reset_email=True) def handle_select(self, label: str): self.selected_label = label @@ -428,15 +505,18 @@ class FicheState(AuthState): self.selected_id = self.apprenti_ids[idx] except ValueError: pass - self._reload() + self.edit_date = "" + self._reload(reset_email=True) def navigate_to(self, apprenti_id: int): if apprenti_id in self.apprenti_ids: idx = self.apprenti_ids.index(apprenti_id) self.selected_id = apprenti_id self.selected_label = self.apprenti_labels[idx] - self._reload() + self.edit_date = "" + self._reload(reset_email=True) + # ── Calendar navigation ─────────────────────────────────────────────────── def prev_month(self): if self.cal_month == 1: self.cal_month = 12 @@ -453,6 +533,112 @@ class FicheState(AuthState): self.cal_month += 1 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 + sess = get_session() + d = date.fromisoformat(date_str) + absences = sess.execute( + select(Absence).where( + Absence.apprenti_id == self.selected_id, + Absence.date == d, + ) + ).scalars().all() + pm = {ab.periode: ab.statut for ab in absences} + + def _choice(p: int) -> str: + s = pm.get(p) + if s == "excusee": return "excusee" + if s == "a_traiter": return "non_excusee" + return "present" + + self.edit_p1 = _choice(1) + self.edit_p2 = _choice(2) + self.edit_p3 = _choice(3) + self.edit_p4 = _choice(4) + self.edit_p5 = _choice(5) + self.edit_p6 = _choice(6) + self.edit_p7 = _choice(7) + self.edit_p8 = _choice(8) + self.edit_p9 = _choice(9) + self.edit_p10 = _choice(10) + self.edit_date_label = d.strftime("%d.%m.%Y") + self.edit_date = date_str + + def cancel_edit(self): + self.edit_date = "" + + def save_day_edit(self): + if not self.edit_date: + return + sess = get_session() + d = date.fromisoformat(self.edit_date) + existing = sess.execute( + select(Absence).where( + Absence.apprenti_id == self.selected_id, + Absence.date == d, + ) + ).scalars().all() + pm = {ab.periode: ab for ab in existing} + choices = { + 1: self.edit_p1, 2: self.edit_p2, 3: self.edit_p3, + 4: self.edit_p4, 5: self.edit_p5, 6: self.edit_p6, + 7: self.edit_p7, 8: self.edit_p8, 9: self.edit_p9, + 10: self.edit_p10, + } + for p, choice in choices.items(): + ab = pm.get(p) + if choice == "present": + if ab: + upsert_escada_pending(sess, self.selected_id, d, p, "clear") + sess.delete(ab) + else: + type_o = "E" if choice == "excusee" else "N" + statut = "excusee" if choice == "excusee" else "a_traiter" + if ab: + if ab.statut != statut: + ab.type_origine = type_o + ab.statut = statut + ab.updated_by = self.username + upsert_escada_pending(sess, self.selected_id, d, p, type_o) + else: + sess.add(Absence( + apprenti_id=self.selected_id, + date=d, periode=p, + type_origine=type_o, statut=statut, + updated_by=self.username, import_id=None, + )) + upsert_escada_pending(sess, self.selected_id, d, p, type_o) + sess.commit() + self.edit_date = "" + self._reload(reset_email=False) + + # ── Quick excuse ────────────────────────────────────────────────────────── + def excuse_day(self, date_str: str): + sess = get_session() + d = date.fromisoformat(date_str) + absences = sess.execute( + select(Absence).where( + Absence.apprenti_id == self.selected_id, + Absence.date == d, + Absence.statut == "a_traiter", + ) + ).scalars().all() + for ab in absences: + ab.statut = "excusee" + ab.type_origine = "E" + ab.updated_by = self.username + upsert_escada_pending(sess, self.selected_id, d, ab.periode, "E") + sess.commit() + if self.edit_date == date_str: + self.edit_date = "" + self._reload(reset_email=False) + + # ── Downloads ───────────────────────────────────────────────────────────── def download_abs_pdf(self): sess = get_session() apprenti = sess.get(Apprenti, self.selected_id) @@ -489,7 +675,89 @@ class FicheState(AuthState): filename = f"Notes_{apprenti.nom}_{apprenti.prenom}.pdf" return rx.download(data=pdf_bytes, filename=filename) - def _reload(self): + # ── Email send (background task) ────────────────────────────────────────── + async def send_email_action(self): + from src.email_sender import send_email as _send_email + async with self: + self.email_sending = True + self.email_error = "" + self.email_sent = False + try: + s = _read_settings() + smtp_host = s.get("smtp_host", "smtp-relay.brevo.com") + smtp_port = int(s.get("smtp_port", 587)) + smtp_login = s.get("smtp_login", "") + smtp_password = s.get("smtp_password", "") + smtp_sender = s.get("smtp_sender", "") + + if self.email_dest == "apprenti": + recipients = [self.fiche_email_val] if self.fiche_email_val else [] + elif self.email_dest == "formateur": + recipients = [self.fiche_formateur_email] if self.fiche_formateur_email else [] + else: + recipients = [e.strip() for e in self.email_custom.split(",") if e.strip()] + + if not recipients: + async with self: + self.email_error = "Aucune adresse email valide." + self.email_sending = False + return + + sess = get_session() + apprenti = sess.get(Apprenti, self.selected_id) + attachments = [] + + if self.email_attach_abs and apprenti: + pdf_bytes = _absence_pdf_apprenti(sess, apprenti) + attachments.append((pdf_bytes, f"Absences_{apprenti.nom}_{apprenti.prenom}.pdf")) + + if self.email_attach_bn and self.has_pdf_bn and apprenti: + pdf_path = DATA_DIR / "pdfs" / self.bn_pdf_fichier + pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom) + if pdf_bytes: + attachments.append((pdf_bytes, f"BN_{apprenti.nom}_{apprenti.prenom}.pdf")) + + if self.email_attach_notes and self.has_pdf_notes and apprenti: + notes_fname = f"notes_{apprenti.classe.replace(' ', '_')}.pdf" + pdf_path = DATA_DIR / "pdfs" / notes_fname + pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom) + if pdf_bytes: + attachments.append((pdf_bytes, f"Notes_{apprenti.nom}_{apprenti.prenom}.pdf")) + + if not attachments: + async with self: + self.email_error = "Sélectionnez au moins un document à joindre." + self.email_sending = False + return + + errors = [] + for to in recipients: + 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=to, subject=self.email_subject, body=self.email_body, + attachments=attachments, + ) + except Exception as e: + errors.append(f"{to}: {e}") + + async with self: + if errors: + self.email_error = "; ".join(errors) + else: + self.email_sent = True + self.email_sending = False + except Exception as e: + async with self: + self.email_error = str(e) + self.email_sending = False + + send_email_action._reflex_background_task = True + + # ── Internal helpers ────────────────────────────────────────────────────── + def _reload(self, reset_email: bool = True): sess = get_session() absences = sess.execute( select(Absence) @@ -497,12 +765,26 @@ class FicheState(AuthState): .order_by(Absence.date, Absence.periode) ).scalars().all() - self.kpi_total = len(absences) - self.kpi_excusees = sum(1 for a in absences if a.statut == "excusee") + self.kpi_total = len(absences) + self.kpi_excusees = sum(1 for a in absences if a.statut == "excusee") self.kpi_non_excusees = sum(1 for a in absences if a.statut == "a_traiter") - self.kpi_blocs = nb_blocs_absences(sess, self.selected_id) - self.quota_atteint = self.kpi_blocs >= QUOTA + 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) ).scalar_one_or_none() @@ -546,14 +828,43 @@ class FicheState(AuthState): self._build_bn(sess) if absences: - self.cal_year = absences[0].date.year + self.cal_year = absences[0].date.year self.cal_month = absences[0].date.month else: today = date.today() - self.cal_year = today.year + self.cal_year = today.year self.cal_month = today.month self._build_calendar_from(absences) + if reset_email: + s = _read_settings() + self.smtp_ok = bool( + s.get("smtp_host") and s.get("smtp_login") and s.get("smtp_password") + ) + if self.smtp_ok: + apprenti = sess.get(Apprenti, self.selected_id) + if apprenti: + tvars = build_template_vars(apprenti, list(absences)) + _def_subj = "Relevé d'absences — {nom_complet} ({classe})" + _def_body = ( + "Bonjour {prenom},\n\n" + "Veuillez trouver ci-joint votre document.\n\n" + "Cordialement,\nL'équipe EPTM" + ) + self.email_subject = render_template( + s.get("email_subject", _def_subj), tvars + ) + self.email_body = render_template( + s.get("email_body", _def_body), tvars + ) + self.email_sent = False + self.email_error = "" + self.email_dest = "apprenti" + self.email_custom = "" + self.email_attach_abs = True + self.email_attach_bn = False + self.email_attach_notes = False + def _build_bn(self, sess): bn_records = sess.execute( select(NotesBulletin, ImportBN) @@ -661,10 +972,13 @@ class FicheState(AuthState): exc = info.get("excusees", 0) non = info.get("non_excusees", 0) has_non_exc = non > 0 - if has_abs and has_non_exc: - lbl = str(day_num) + " !" - elif has_abs: - lbl = str(day_num) + " v" + if has_abs: + parts = [str(day_num)] + if non > 0: + parts.append(f"⚠{non}") + if exc > 0: + parts.append(f"✓{exc}") + lbl = " ".join(parts) else: lbl = str(day_num) days.append({ @@ -703,44 +1017,293 @@ def _info_line(icon: str, value) -> rx.Component: def _cal_day_cell(d) -> rx.Component: + is_selected = d["date_str"] == FicheState.edit_date return rx.cond( d["is_empty"], - rx.box(min_height="34px", border_radius="4px"), + rx.box(min_height="36px", border_radius="4px"), rx.box( rx.text( d["label"], size="1", font_weight=rx.cond(d["is_today"], "700", "400"), color=rx.cond( - d["has_non_exc"], - "#c62828", - rx.cond(d["has_abs"], "#2e7d32", "#333"), + is_selected, "#1565c0", + rx.cond( + d["has_non_exc"], "#c62828", + rx.cond(d["has_abs"], "#2e7d32", "#333"), + ), ), text_align="center", ), - min_height="34px", + min_height="36px", border_radius="4px", background_color=rx.cond( - d["has_non_exc"], - "#ffebee", + is_selected, "#dbeafe", rx.cond( - d["has_abs"], - "#e8f5e9", - rx.cond(d["is_today"], "#e3f2fd", "white"), + d["has_non_exc"], "#ffebee", + rx.cond( + d["has_abs"], "#e8f5e9", + rx.cond(d["is_today"], "#e3f2fd", "white"), + ), ), ), border=rx.cond( - d["is_today"], - "2px solid #1565c0", - "1px solid #eee", + is_selected, "2px solid #1565c0", + rx.cond(d["is_today"], "2px solid #90caf9", "1px solid #eee"), ), display="flex", align_items="center", justify_content="center", + cursor=rx.cond(d["has_abs"], "pointer", "default"), + on_click=FicheState.select_day(d["date_str"]), ), ) +def _period_select(p_num: int, val, setter) -> rx.Component: + return rx.hstack( + rx.text(f"P{p_num}", size="2", weight="medium", color="#555", + min_width="28px", text_align="right"), + rx.select.root( + rx.select.trigger(width="170px"), + rx.select.content( + rx.select.item("Présent", value="present"), + rx.select.item("E — Excusée", value="excusee"), + rx.select.item("N — Non excusée", value="non_excusee"), + ), + value=val, + on_change=setter, + size="1", + ), + spacing="2", + align="center", + ) + + +def _edit_panel() -> rx.Component: + return rx.box( + rx.vstack( + rx.hstack( + rx.icon("pencil", size=15, color="#1565c0"), + rx.text( + "Édition du ", FicheState.edit_date_label, + size="3", weight="bold", color="#37474f", + ), + rx.spacer(), + rx.button( + rx.icon("x", size=14), + on_click=FicheState.cancel_edit, + variant="ghost", color_scheme="gray", size="1", + ), + width="100%", align="center", + ), + rx.divider(), + rx.grid( + _period_select(1, FicheState.edit_p1, FicheState.set_edit_p1), + _period_select(2, FicheState.edit_p2, FicheState.set_edit_p2), + _period_select(3, FicheState.edit_p3, FicheState.set_edit_p3), + _period_select(4, FicheState.edit_p4, FicheState.set_edit_p4), + _period_select(5, FicheState.edit_p5, FicheState.set_edit_p5), + _period_select(6, FicheState.edit_p6, FicheState.set_edit_p6), + _period_select(7, FicheState.edit_p7, FicheState.set_edit_p7), + _period_select(8, FicheState.edit_p8, FicheState.set_edit_p8), + _period_select(9, FicheState.edit_p9, FicheState.set_edit_p9), + _period_select(10, FicheState.edit_p10, FicheState.set_edit_p10), + columns="2", + gap="0.4rem", + width="100%", + ), + rx.hstack( + rx.button( + rx.icon("save", size=14), "Enregistrer", + on_click=FicheState.save_day_edit, + color_scheme="blue", size="2", + ), + rx.button( + "Annuler", + on_click=FicheState.cancel_edit, + variant="outline", color_scheme="gray", size="2", + ), + spacing="3", + ), + spacing="3", width="100%", + ), + padding="1rem", + background_color="#f0f7ff", + border_radius="8px", + border="1px solid #bfdbfe", + width="100%", + ) + + +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( + rx.hstack( + rx.icon("mail", size=16, color="#37474f"), + rx.text("Envoyer par email", size="3", weight="bold", color="#37474f"), + spacing="2", align="center", + ), + rx.divider(), + rx.flex( + # ── Left: destinataire + pièces jointes ────────────────────── + rx.vstack( + rx.text("Destinataire", size="2", weight="bold", color="#555"), + rx.radio_group.root( + rx.vstack( + rx.radio_group.item( + rx.cond( + FicheState.fiche_email_val != "", + rx.text("Apprenti — ", FicheState.fiche_email_val, size="2"), + rx.text("Apprenti (email inconnu)", size="2", color="#999"), + ), + value="apprenti", + disabled=FicheState.fiche_email_val == "", + ), + rx.radio_group.item( + rx.cond( + FicheState.fiche_formateur_email != "", + rx.text("Formateur — ", FicheState.fiche_formateur_email, size="2"), + rx.text("Formateur (email inconnu)", size="2", color="#999"), + ), + value="formateur", + disabled=FicheState.fiche_formateur_email == "", + ), + rx.radio_group.item( + rx.text("Autre adresse", size="2"), + value="autre", + ), + spacing="2", + ), + value=FicheState.email_dest, + on_change=FicheState.set_email_dest, + ), + rx.cond( + FicheState.email_dest == "autre", + rx.input( + value=FicheState.email_custom, + on_change=FicheState.set_email_custom, + placeholder="email1@ex.com, email2@ex.com", + size="1", width="100%", + ), + ), + rx.text("Pièces jointes", size="2", weight="bold", + color="#555", margin_top="0.5rem"), + rx.vstack( + rx.checkbox( + "Tableau des absences", + checked=FicheState.email_attach_abs, + on_change=FicheState.toggle_attach_abs, + size="1", + ), + rx.checkbox( + rx.cond( + FicheState.has_pdf_bn, + "Bulletin de notes", + "Bulletin de notes (indisponible)", + ), + checked=FicheState.email_attach_bn, + on_change=FicheState.toggle_attach_bn, + disabled=~FicheState.has_pdf_bn, + size="1", + ), + rx.checkbox( + rx.cond( + FicheState.has_pdf_notes, + "Notes d'examen", + "Notes d'examen (indisponible)", + ), + checked=FicheState.email_attach_notes, + on_change=FicheState.toggle_attach_notes, + disabled=~FicheState.has_pdf_notes, + size="1", + ), + spacing="2", + ), + spacing="2", flex="1", min_width="220px", + ), + # ── Right: message + envoyer ────────────────────────────────── + rx.vstack( + rx.vstack( + rx.text("Objet", size="2", weight="bold", color="#555"), + rx.input( + value=FicheState.email_subject, + on_change=FicheState.set_email_subject, + width="100%", size="1", + ), + spacing="1", width="100%", + ), + rx.vstack( + rx.text("Corps du message", size="2", weight="bold", color="#555"), + rx.text_area( + value=FicheState.email_body, + on_change=FicheState.set_email_body, + rows="7", width="100%", + font_family="monospace", + font_size="0.82rem", + resize="vertical", + ), + spacing="1", width="100%", + ), + rx.button( + rx.cond( + FicheState.email_sending, + rx.hstack( + rx.spinner(size="1"), + rx.text("Envoi en cours…"), + spacing="2", align="center", + ), + rx.hstack( + rx.icon("send", size=14), + rx.text("Envoyer"), + spacing="2", align="center", + ), + ), + on_click=FicheState.send_email_action, + color_scheme="blue", size="2", width="100%", + disabled=FicheState.email_sending, + ), + rx.cond( + FicheState.email_sent, + rx.callout.root( + rx.callout.icon(rx.icon("check", size=14)), + rx.callout.text("Email envoyé."), + color_scheme="green", variant="soft", size="1", + ), + ), + rx.cond( + FicheState.email_error != "", + rx.callout.root( + rx.callout.icon(rx.icon("triangle-alert", size=14)), + rx.callout.text(FicheState.email_error), + color_scheme="red", variant="soft", size="1", + ), + ), + spacing="3", flex="2", min_width="260px", + ), + gap="1.5rem", flex_wrap="wrap", width="100%", align="start", + ), + spacing="3", width="100%", + ), + padding="1rem", + background_color="white", + border_radius="8px", + border="1px solid #e0e0e0", + width="100%", + ) + + _DOW = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"] @@ -761,46 +1324,46 @@ def fiche_page() -> rx.Component: width="100%", ), - # ── Alerte quota ────────────────────────────────────────── - rx.cond( - FicheState.quota_atteint, - rx.hstack( - rx.icon("triangle-alert", size=16, color="#c62828"), - rx.text( - "Avis de sanction — ", - FicheState.kpi_blocs, - " absences sur 5 autorisees", - size="2", color="#c62828", - ), - padding="0.75rem 1rem", - background_color="#ffebee", - border_radius="6px", - border="1px solid #ffcdd2", - width="100%", spacing="2", align="center", - ), - ), - # ── KPI cards ───────────────────────────────────────────── rx.flex( - _kpi_card("Total periodes", FicheState.kpi_total), - _kpi_card("Excusees", FicheState.kpi_excusees, "#2e7d32"), - _kpi_card("Non excusees", FicheState.kpi_non_excusees, "#c62828"), - _kpi_card("Absences (blocs)", FicheState.kpi_blocs), + _kpi_card("Périodes d'absence", FicheState.kpi_total), + _kpi_card("Périodes à excuser", FicheState.kpi_non_excusees, "#c62828"), + rx.box( + rx.text("Absences", size="1", color="#666"), + rx.text( + FicheState.kpi_blocs, + size="7", font_weight="700", + color=rx.cond(FicheState.quota_atteint, "#c62828", "#37474f"), + ), + rx.cond( + FicheState.quota_atteint, + rx.text( + "Avis de sanction", + size="1", weight="bold", color="#c62828", + ), + ), + padding="1rem", + background_color=rx.cond(FicheState.quota_atteint, "#fff0f0", "white"), + border_radius="8px", + border=rx.cond( + FicheState.quota_atteint, + "1px solid #ffcdd2", + "1px solid #e0e0e0", + ), + flex="1", + min_width="120px", + ), gap="1rem", flex_wrap="wrap", width="100%", ), # ── Fiche détaillée Escada ──────────────────────────────── rx.box( - rx.text( - "Fiche detaillee (Escada)", size="3", - font_weight="700", color="#37474f", margin_bottom="0.75rem", - ), rx.cond( FicheState.fiche_available, rx.vstack( rx.flex( rx.vstack( - rx.text("Eleve", size="2", font_weight="700", color="#37474f"), + rx.text("Élève", size="2", font_weight="700", color="#37474f"), _info_line("map-pin", FicheState.fiche_adresse), _info_line("map-pin", FicheState.fiche_cp_localite), _info_line("phone", FicheState.fiche_telephone), @@ -827,7 +1390,7 @@ def fiche_page() -> rx.Component: gap="1.5rem", flex_wrap="wrap", width="100%", ), rx.text( - "Mis a jour le ", FicheState.fiche_updated_at, " depuis Escada", + "Mis à jour le ", FicheState.fiche_updated_at, " depuis Escada", size="1", color="#9e9e9e", margin_top="0.5rem", ), spacing="3", width="100%", @@ -855,21 +1418,16 @@ def fiche_page() -> rx.Component: rx.cond( FicheState.has_bn, rx.vstack( - rx.text( - FicheState.bn_caption, - size="1", color="#9e9e9e", - ), + rx.text(FicheState.bn_caption, size="1", color="#9e9e9e"), rx.html(FicheState.bn_html), spacing="2", width="100%", ), rx.text( - "Aucun bulletin de notes importe pour cet(te) apprenti(e).", + "Aucun bulletin de notes importé pour cet(te) apprenti(e).", size="2", color="#666", ), ), - value="bn", - width="100%", - padding_top="1rem", + value="bn", width="100%", padding_top="1rem", ), rx.tabs.content( rx.cond( @@ -880,12 +1438,9 @@ def fiche_page() -> rx.Component: size="2", color="#666", ), ), - value="notes", - width="100%", - padding_top="1rem", + value="notes", width="100%", padding_top="1rem", ), - default_value="bn", - width="100%", + default_value="bn", width="100%", ), padding="1rem", background_color="white", @@ -897,37 +1452,27 @@ def fiche_page() -> rx.Component: # ── Export PDF ──────────────────────────────────────────── rx.flex( rx.button( - rx.icon("download", size=13), - "PDF absences", + rx.icon("download", size=13), "PDF absences", on_click=FicheState.download_abs_pdf, - variant="outline", - color_scheme="gray", - size="1", + variant="outline", color_scheme="gray", size="1", ), rx.cond( FicheState.has_pdf_bn, rx.button( - rx.icon("file-text", size=13), - "PDF bulletin", + rx.icon("file-text", size=13), "PDF bulletin", on_click=FicheState.download_bn_pdf, - variant="outline", - color_scheme="blue", - size="1", + variant="outline", color_scheme="blue", size="1", ), ), rx.cond( FicheState.has_pdf_notes, rx.button( - rx.icon("file-text", size=13), - "PDF notes", + rx.icon("file-text", size=13), "PDF notes", on_click=FicheState.download_notes_pdf, - variant="outline", - color_scheme="violet", - size="1", + variant="outline", color_scheme="violet", size="1", ), ), - flex_wrap="wrap", - gap="0.5rem", + flex_wrap="wrap", gap="0.5rem", ), # ── Calendrier mensuel ──────────────────────────────────── @@ -936,8 +1481,7 @@ def fiche_page() -> rx.Component: rx.box( rx.hstack( rx.button( - rx.icon("chevron-left", size=14), - FicheState.cal_prev_name, + rx.icon("chevron-left", size=14), FicheState.cal_prev_name, on_click=FicheState.prev_month, variant="outline", color_scheme="gray", size="2", ), @@ -947,8 +1491,7 @@ def fiche_page() -> rx.Component: flex="1", text_align="center", ), rx.button( - FicheState.cal_next_name, - rx.icon("chevron-right", size=14), + FicheState.cal_next_name, rx.icon("chevron-right", size=14), on_click=FicheState.next_month, variant="outline", color_scheme="gray", size="2", ), @@ -956,10 +1499,8 @@ def fiche_page() -> rx.Component: ), rx.grid( *[ - rx.text( - h, size="1", color="#9e9e9e", - text_align="center", font_weight="600", - ) + rx.text(h, size="1", color="#9e9e9e", + text_align="center", font_weight="600") for h in _DOW ], columns="7", gap="2px", width="100%", margin_bottom="2px", @@ -969,27 +1510,82 @@ def fiche_page() -> rx.Component: columns="7", gap="2px", width="100%", ), rx.hstack( - rx.box( - width="12px", height="12px", - background_color="#ffebee", border_radius="2px", - border="1px solid #eee", - ), - rx.text("Non excusee", size="1", color="#666"), - rx.box( - width="12px", height="12px", - background_color="#e8f5e9", border_radius="2px", - border="1px solid #eee", - ), - rx.text("Excusee", size="1", color="#666"), + rx.box(width="12px", height="12px", background_color="#ffebee", + border_radius="2px", border="1px solid #eee"), + rx.text("Non excusée", size="1", color="#666"), + rx.box(width="12px", height="12px", background_color="#e8f5e9", + border_radius="2px", border="1px solid #eee"), + rx.text("Excusée", size="1", color="#666"), + rx.box(width="12px", height="12px", background_color="#dbeafe", + border_radius="2px", border="2px solid #1565c0"), + rx.text("Sélectionné", size="1", color="#666"), spacing="2", align="center", margin_top="0.5rem", ), + rx.text( + "Cliquez sur un jour avec absences pour éditer les périodes.", + size="1", color="#9e9e9e", margin_top="0.25rem", + ), padding="1rem", background_color="white", border_radius="8px", border="1px solid #e0e0e0", width="100%", ), - rx.text("Aucune absence enregistree.", size="2", color="#666"), + 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( + "Absences à traiter", + 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 != "", + _edit_panel(), + ), + + # ── Email ───────────────────────────────────────────────── + rx.cond( + FicheState.smtp_ok, + _email_section(), + rx.box( + rx.hstack( + rx.icon("mail", size=14, color="#9e9e9e"), + rx.text( + "Email non configuré. Rendez-vous dans Paramètres.", + size="2", color="#9e9e9e", + ), + spacing="2", align="center", + ), + padding="0.75rem 1rem", + background_color="#f9fafb", + border_radius="8px", + border="1px solid #e5e7eb", + width="100%", + ), ), spacing="4", width="100%", diff --git a/eptm_dashboard/pages/login.py b/eptm_dashboard/pages/login.py index 104c9b5..d75da34 100644 --- a/eptm_dashboard/pages/login.py +++ b/eptm_dashboard/pages/login.py @@ -2,51 +2,192 @@ import reflex as rx from ..state import AuthState -def login_page() -> rx.Component: +def _logo() -> rx.Component: return rx.center( - rx.form( + rx.image(src="/logo.png", width="320px", height="auto"), + width="100%", + ) + + +def _error_box(msg) -> rx.Component: + return rx.box( + rx.text(msg, color="red", size="2"), + padding="0.5rem 1rem", + background_color="#fff5f5", + border="1px solid #ffcccc", + border_radius="6px", + width="100%", + ) + + +def _password_form() -> rx.Component: + return rx.form( + rx.vstack( + _logo(), + rx.cond(AuthState.login_error != "", _error_box(AuthState.login_error)), + rx.input( + name="username", + placeholder="Identifiant", + value=AuthState.login_user, + on_change=AuthState.set_login_user, + width="100%", + ), + rx.input( + name="password", + placeholder="Mot de passe", + type="password", + value=AuthState.login_pass, + on_change=AuthState.set_login_pass, + width="100%", + ), + rx.button( + "Se connecter", + type="submit", + width="100%", + color_scheme="indigo", + ), + spacing="3", + width="100%", + align="center", + ), + on_submit=AuthState.handle_login, + width="100%", + ) + + +def _setup_form() -> rx.Component: + """Première connexion : QR code à scanner + code de confirmation.""" + return rx.form( + rx.vstack( + _logo(), + rx.heading("Configuration 2FA", size="4", color="#37474f"), + rx.text( + "Scanne ce QR code avec ton application Authenticator " + "(Google Authenticator, Microsoft Authenticator, Authy…) puis " + "saisis le code à 6 chiffres pour confirmer.", + size="2", + color="#555", + text_align="center", + ), + rx.center( + rx.image( + src=AuthState.totp_qr_data_url, + width="220px", + height="220px", + ), + width="100%", + ), + rx.text( + "Compte : ", AuthState.totp_pending_user, + size="1", + color="var(--gray-9)", + ), + rx.cond(AuthState.totp_error != "", _error_box(AuthState.totp_error)), + rx.input( + name="totp_code", + placeholder="Code à 6 chiffres", + value=AuthState.totp_code, + on_change=AuthState.set_totp_code, + width="100%", + max_length=6, + auto_focus=True, + text_align="center", + font_size="1.4rem", + letter_spacing="0.4rem", + ), rx.vstack( - rx.center( - rx.image(src="/logo.png", width="320px", height="auto"), - width="100%", - ), - rx.cond( - AuthState.login_error != "", - rx.box( - rx.text(AuthState.login_error, color="red", size="2"), - padding="0.5rem 1rem", - background_color="#fff5f5", - border="1px solid #ffcccc", - border_radius="6px", - width="100%", - ), - ), - rx.input( - name="username", - placeholder="Identifiant", - value=AuthState.login_user, - on_change=AuthState.set_login_user, - width="100%", - ), - rx.input( - name="password", - placeholder="Mot de passe", - type="password", - value=AuthState.login_pass, - on_change=AuthState.set_login_pass, - width="100%", - ), rx.button( - "Se connecter", + "Activer 2FA", type="submit", width="100%", color_scheme="indigo", ), - spacing="3", + rx.button( + "Annuler", + on_click=AuthState.cancel_totp, + type="button", + variant="soft", + color_scheme="gray", + width="100%", + ), width="100%", - align="center", + spacing="2", + ), + spacing="3", + width="100%", + align="center", + ), + on_submit=AuthState.verify_totp, + width="100%", + ) + + +def _verify_form() -> rx.Component: + """2FA déjà active : juste demander le code.""" + return rx.form( + rx.vstack( + _logo(), + rx.heading("Vérification 2FA", size="4", color="#37474f"), + rx.text( + "Entre le code à 6 chiffres affiché par ton application Authenticator.", + size="2", + color="#555", + text_align="center", + ), + rx.text( + "Compte : ", AuthState.totp_pending_user, + size="1", + color="var(--gray-9)", + ), + rx.cond(AuthState.totp_error != "", _error_box(AuthState.totp_error)), + rx.input( + name="totp_code", + placeholder="Code à 6 chiffres", + value=AuthState.totp_code, + on_change=AuthState.set_totp_code, + width="100%", + max_length=6, + auto_focus=True, + text_align="center", + font_size="1.4rem", + letter_spacing="0.4rem", + ), + rx.vstack( + rx.button( + "Valider", + type="submit", + width="100%", + color_scheme="indigo", + ), + rx.button( + "Annuler", + on_click=AuthState.cancel_totp, + type="button", + variant="soft", + color_scheme="gray", + width="100%", + ), + width="100%", + spacing="2", + ), + spacing="3", + width="100%", + align="center", + ), + on_submit=AuthState.verify_totp, + width="100%", + ) + + +def login_page() -> rx.Component: + return rx.center( + rx.box( + rx.match( + AuthState.totp_step, + ("setup", _setup_form()), + ("verify", _verify_form()), + _password_form(), ), - on_submit=AuthState.handle_login, width="420px", padding="2rem", background_color="white", diff --git a/eptm_dashboard/state.py b/eptm_dashboard/state.py index 74c261f..7270b67 100644 --- a/eptm_dashboard/state.py +++ b/eptm_dashboard/state.py @@ -1,10 +1,39 @@ +import base64 +import io import os import bcrypt +import pyotp +import qrcode import yaml import reflex as rx from pathlib import Path DATA_DIR = Path(os.getenv("DATA_DIR", "data")) +TOTP_ISSUER = "EPTM Dashboard" + + +def _load_auth_full() -> dict: + """Lit auth.yaml complet (config dict, pas seulement usernames).""" + auth_file = DATA_DIR / "auth.yaml" + if auth_file.exists(): + with open(auth_file, encoding="utf-8") as f: + return yaml.safe_load(f) or {} + return {} + + +def _save_auth_full(cfg: dict) -> None: + auth_file = DATA_DIR / "auth.yaml" + with open(auth_file, "w", encoding="utf-8") as f: + yaml.dump(cfg, f, allow_unicode=True) + + +def _make_totp_qr_data_url(secret: str, label: str) -> str: + """Génère un data URL PNG du QR code TOTP.""" + uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=TOTP_ISSUER) + img = qrcode.make(uri) + buf = io.BytesIO() + img.save(buf, format="PNG") + return f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}" class AuthState(rx.State): @@ -20,6 +49,15 @@ class AuthState(rx.State): login_pass: str = "" login_error: str = "" + # 2FA flow (in-memory, ephémère) + # totp_step in {"password", "setup", "verify"} + totp_step: str = "password" + totp_pending_user: str = "" + totp_secret_pending: str = "" # secret généré en setup, pas encore sauvé + totp_qr_data_url: str = "" + totp_code: str = "" + totp_error: str = "" + sidebar_collapsed: bool = False mobile_menu_open: bool = False admin_expanded: bool = True @@ -55,6 +93,11 @@ class AuthState(rx.State): def set_login_pass(self, value: str): self.login_pass = value + def set_totp_code(self, value: str): + # Garder uniquement les chiffres, max 6 + self.totp_code = "".join(ch for ch in value if ch.isdigit())[:6] + self.totp_error = "" + def index_redirect(self): if self.authenticated: return rx.redirect("/accueil") @@ -77,20 +120,104 @@ class AuthState(rx.State): self.login_error = "" users = self._load_users() user = users.get(self.login_user) - if user: - try: - ok = bcrypt.checkpw(self.login_pass.encode(), user["password"].encode()) - except Exception: - ok = False - if ok: - self.username = self.login_user - self.name = user.get("name", self.login_user) - self.role = user.get("role", "user") - self.photo_url = user.get("avatar_url", "") - self.login_pass = "" - return rx.redirect("/accueil") - self.login_error = "Identifiant ou mot de passe incorrect" + if not user: + self.login_error = "Identifiant ou mot de passe incorrect" + self.login_pass = "" + return + try: + ok = bcrypt.checkpw(self.login_pass.encode(), user["password"].encode()) + except Exception: + ok = False + if not ok: + self.login_error = "Identifiant ou mot de passe incorrect" + self.login_pass = "" + return + + # Mot de passe OK — étape 2FA self.login_pass = "" + self.totp_pending_user = self.login_user + self.totp_code = "" + self.totp_error = "" + + if user.get("totp_secret"): + # 2FA déjà configurée — demander le code + self.totp_step = "verify" + self.totp_secret_pending = "" + self.totp_qr_data_url = "" + else: + # Première connexion 2FA — générer secret + QR + secret = pyotp.random_base32() + self.totp_secret_pending = secret + label = f"{self.login_user}@{TOTP_ISSUER}" + self.totp_qr_data_url = _make_totp_qr_data_url(secret, label) + self.totp_step = "setup" + + def verify_totp(self, form_data: dict | None = None): + """Vérifie le code TOTP saisi. Si setup, sauve le secret. Puis login.""" + self.totp_error = "" + if not self.totp_pending_user: + # Session perdue — retour login + self.cancel_totp() + return rx.redirect("/login") + + if len(self.totp_code) != 6: + self.totp_error = "Code à 6 chiffres requis" + return + + # Déterminer le secret à valider + if self.totp_step == "setup": + secret = self.totp_secret_pending + else: + cfg = _load_auth_full() + user = cfg.get("credentials", {}).get("usernames", {}).get(self.totp_pending_user) + if not user: + self.cancel_totp() + return rx.redirect("/login") + secret = user.get("totp_secret") or "" + + if not secret: + self.totp_error = "Configuration 2FA manquante — contactez un administrateur" + return + + # Vérifier code (valid_window=1 → tolère ±30s de dérive d'horloge) + totp = pyotp.TOTP(secret) + if not totp.verify(self.totp_code, valid_window=1): + self.totp_error = "Code invalide" + self.totp_code = "" + return + + # Code OK + cfg = _load_auth_full() + users = cfg.get("credentials", {}).get("usernames", {}) + user = users.get(self.totp_pending_user) + if not user: + self.cancel_totp() + return rx.redirect("/login") + + # Si setup, sauver le secret dans auth.yaml + if self.totp_step == "setup": + users[self.totp_pending_user]["totp_secret"] = secret + _save_auth_full(cfg) + + # Finaliser la connexion + self.username = self.totp_pending_user + self.name = user.get("name", self.totp_pending_user) + self.role = user.get("role", "user") + self.photo_url = user.get("avatar_url", "") + self._reset_totp_flow() + return rx.redirect("/accueil") + + def cancel_totp(self): + """Annule le flow 2FA et revient à l'étape password.""" + self._reset_totp_flow() + + def _reset_totp_flow(self): + self.totp_step = "password" + self.totp_pending_user = "" + self.totp_secret_pending = "" + self.totp_qr_data_url = "" + self.totp_code = "" + self.totp_error = "" def logout(self): self._clear_session() @@ -104,12 +231,8 @@ class AuthState(rx.State): self.login_user = "" self.login_pass = "" self.login_error = "" + self._reset_totp_flow() @staticmethod def _load_users() -> dict: - auth_file = DATA_DIR / "auth.yaml" - if auth_file.exists(): - with open(auth_file) as f: - data = yaml.safe_load(f) or {} - return data.get("credentials", {}).get("usernames", {}) - return {} + return _load_auth_full().get("credentials", {}).get("usernames", {})
DateExamenEnseignantCoeffTypeNote