diff --git a/data/class_href_cache.json b/data/class_href_cache.json index 2d494ab..364dd22 100644 --- a/data/class_href_cache.json +++ b/data/class_href_cache.json @@ -8,6 +8,6 @@ "MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b51ec970-5bf4-4982-a05e-80546bb7421f", "MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=aaacc343-c248-4f21-93f6-5d9e3079aa5d", "MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=18c1ddbe-471f-44f6-bde6-8619adc3b767", - "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=c2c982af-b6f9-44ab-8ed5-7bc567b967cf", - "EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a4c4c187-920c-4c91-9620-7f153cf3738a" + "EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a4c4c187-920c-4c91-9620-7f153cf3738a", + "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7bddb158-278f-4258-934d-eddb7de88af3" } \ No newline at end of file diff --git a/eptm_dashboard/pages/classe.py b/eptm_dashboard/pages/classe.py index d635e95..282f5c3 100644 --- a/eptm_dashboard/pages/classe.py +++ b/eptm_dashboard/pages/classe.py @@ -286,7 +286,7 @@ def _absence_pdf_apprenti(sess, apprenti) -> bytes: total_periodes = total_e + total_n footer_label = ( f"{len(blocs)} absence(s) | " - f"{total_periodes} periode(s) | " + f"{total_periodes} période(s) | " f"{total_e} excusee(s) | " f"{total_n} non excusee(s)" ) @@ -353,6 +353,15 @@ class ClasseState(AuthState): selected_class: str = "" has_classes: bool = False apprentis_data: list[dict] = [] + class_search: str = "" + class_select_open: bool = False + + @rx.var + def filtered_classes(self) -> list[str]: + q = self.class_search.lower().strip() + if not q: + return self.classes + return [c for c in self.classes if q in c.lower()] def load_data(self): if not self.authenticated: @@ -377,8 +386,18 @@ class ClasseState(AuthState): def set_class(self, classe: str): self.selected_class = classe + self.class_select_open = False + self.class_search = "" self._reload() + def set_class_search(self, v: str): + self.class_search = v + + def set_class_select_open(self, v: bool): + self.class_select_open = v + if not v: + self.class_search = "" + def download_abs_pdf(self, apprenti_id: int): sess = get_session() apprenti = sess.get(Apprenti, apprenti_id) @@ -544,6 +563,75 @@ class ClasseState(AuthState): # ── UI components ───────────────────────────────────────────────────────────── +def _classe_option(classe: rx.Var) -> rx.Component: + return rx.box( + rx.text(classe, size="2"), + padding="0.45rem 0.75rem", + cursor="pointer", + on_click=ClasseState.set_class(classe), + _hover={"background_color": "var(--gray-3)"}, + width="100%", + ) + + +def _classe_searchable_select() -> rx.Component: + return rx.popover.root( + rx.popover.trigger( + rx.box( + rx.flex( + rx.cond( + ClasseState.selected_class != "", + rx.text(ClasseState.selected_class, size="2"), + rx.text("Sélectionner une classe…", size="2", color="var(--gray-9)"), + ), + rx.spacer(), + rx.icon("chevron-down", size=18, color="var(--gray-9)"), + align="center", + width="100%", + ), + padding="0.5rem 0.75rem", + border="1px solid var(--gray-7)", + border_radius="6px", + background_color="white", + cursor="pointer", + width="100%", + ), + ), + rx.popover.content( + rx.vstack( + rx.input( + placeholder="Rechercher une classe…", + value=ClasseState.class_search, + on_change=ClasseState.set_class_search, + size="2", + width="100%", + auto_focus=True, + ), + rx.cond( + ClasseState.filtered_classes.length() > 0, + rx.box( + rx.foreach(ClasseState.filtered_classes, _classe_option), + max_height="280px", + overflow_y="auto", + width="100%", + ), + rx.box( + rx.text("Aucun résultat", size="2", color="var(--gray-9)"), + padding="0.5rem 0.75rem", + ), + ), + spacing="2", + width="100%", + ), + min_width="280px", + max_width="400px", + padding="0.5rem", + ), + open=ClasseState.class_select_open, + on_open_change=ClasseState.set_class_select_open, + ) + + def _kpi_mini(label: str, value, color: str = "#37474f") -> rx.Component: return rx.box( rx.text(label, size="1", color="#888"), @@ -656,7 +744,7 @@ def _apprenti_card(item) -> rx.Component: spacing="2", width="100%", ), rx.text( - "Aucun bulletin de notes importe.", + "Aucun bulletin de notes importé.", size="2", color="#666", ), ), @@ -702,12 +790,7 @@ def classe_page() -> rx.Component: rx.cond( ClasseState.has_classes, rx.vstack( - rx.select( - ClasseState.classes, - value=ClasseState.selected_class, - on_change=ClasseState.set_class, - width="100%", - ), + _classe_searchable_select(), rx.cond( ClasseState.apprentis_data.length() > 0, diff --git a/eptm_dashboard/pages/cron.py b/eptm_dashboard/pages/cron.py index a98d74e..c1646f6 100644 --- a/eptm_dashboard/pages/cron.py +++ b/eptm_dashboard/pages/cron.py @@ -18,6 +18,7 @@ if str(_ROOT) not in sys.path: from src.db import CronJob, get_session, init_db, Apprenti # noqa: E402 from src.notifier import test_telegram # noqa: E402 +from src.logger import app_log # noqa: E402 from ..state import AuthState from ..sidebar import layout @@ -348,9 +349,11 @@ class CronState(AuthState): classes_json = json.dumps(self.f_classes) sess = get_session() + user = self.username or "?" try: now = datetime.now() - if self.editing_id == 0: + is_new = self.editing_id == 0 + if is_new: job = CronJob( name=self.f_name.strip(), enabled=self.f_enabled, @@ -391,6 +394,12 @@ class CronState(AuthState): job.notify_chat_id = self.f_notify_chat_id job.updated_at = now sess.commit() + verb = "création" if is_new else "modification" + app_log( + f"[cron] {user} : {verb} tâche '{job.name}' (id={job.id}) — " + f"{job.task_kind} / {schedule_value} / " + f"{'activée' if job.enabled else 'désactivée'}" + ) self.save_ok = True self._refresh() self.edit_open = False @@ -402,6 +411,7 @@ class CronState(AuthState): def toggle_enabled(self, job_id: int): sess = get_session() + user = self.username or "?" try: job = sess.get(CronJob, job_id) if job: @@ -413,17 +423,25 @@ class CronState(AuthState): if was_disabled and job.enabled: job.last_run_at = None sess.commit() + app_log( + f"[cron] {user} : " + f"{'activation' if job.enabled else 'désactivation'} " + f"tâche '{job.name}' (id={job.id})" + ) self._refresh() finally: sess.close() def delete_job(self, job_id: int): sess = get_session() + user = self.username or "?" try: job = sess.get(CronJob, job_id) if job: + job_name = job.name sess.delete(job) sess.commit() + app_log(f"[cron] {user} : suppression tâche '{job_name}' (id={job_id})") self._refresh() finally: sess.close() diff --git a/eptm_dashboard/pages/escada.py b/eptm_dashboard/pages/escada.py index 1e055ca..8a82539 100644 --- a/eptm_dashboard/pages/escada.py +++ b/eptm_dashboard/pages/escada.py @@ -159,7 +159,7 @@ class EscadaState(AuthState): self.is_refreshing = False self.is_pushing = False self.import_in_progress = False - app_log("Sync annulee") + app_log("Sync annulée") # ── load_data ────────────────────────────────────────────────────────────── @@ -228,10 +228,11 @@ class EscadaState(AuthState): @_background async def refresh_classes(self): - app_log("Rafraichissement liste classes Escada") async with self: + user = self.username or "?" self.is_refreshing = True - self.op_log = "Connexion a Escadaweb..." + self.op_log = "Connexion à Escadaweb…" + app_log(f"Rafraîchissement liste classes Escada par {user}") cmd = [sys.executable, str(_SYNC_SCRIPT), "--list-classes"] lines: list[str] = [] @@ -329,9 +330,9 @@ class EscadaState(AuthState): ] if ui_classes: - app_log(f"Classes recuperees : {', '.join(ui_classes)}") + app_log(f"Classes récupérées : {', '.join(ui_classes)}") else: - app_log(f"Aucune classe recuperee (code={_rc_holder[0]}, lignes={len(lines)})") + app_log(f"Aucune classe récupérée (code={_rc_holder[0]}, lignes={len(lines)})") try: _t = asyncio.current_task() @@ -353,7 +354,7 @@ class EscadaState(AuthState): self.op_log = "\n".join(lines[-60:]) self.is_refreshing = False except Exception as _e: - app_log(f"Erreur mise a jour etat refresh : {_e}") + app_log(f"Erreur mise à jour état refresh : {_e}") try: async with self: self.is_refreshing = False @@ -384,7 +385,16 @@ class EscadaState(AuthState): self.sync_res_notes = [] self.sync_res_matu = [] - app_log(f"Sync Escada — {len(selected)} classe(s) : {', '.join(selected)}") + _types = [] + if sync_abs: _types.append("abs" + ("/forcé" if force_abs else "")) + if sync_bn: _types.append("BN") + if sync_notes: _types.append("notes") + if sync_fiches: _types.append("fiches") + _types_label = ", ".join(_types) or "—" + app_log( + f"Sync Escada démarrée par {username} — " + f"{len(selected)} classe(s) [{_types_label}] : {', '.join(selected)}" + ) args = ["--sync-all"] + selected if not sync_abs: args.append("--skip-abs") @@ -485,7 +495,7 @@ class EscadaState(AuthState): except Exception: pass _rc_holder[0] = _proc.wait() or 0 - app_log(f" subprocess termine, code={_rc_holder[0]}", debug=True) + app_log(f" subprocess terminé, code={_rc_holder[0]}", debug=True) break elif _all_done_payload: @@ -507,7 +517,7 @@ class EscadaState(AuthState): except Exception: pass _rc = _rc_holder[0] - app_log(f"Sync script termine — code={_rc}, lignes={len(lines)}") + app_log(f"Sync script terminé — code={_rc}, lignes={len(lines)}") if not _all_done_payload: app_log(f"ALL_DONE non trouve (code={_rc})") @@ -590,7 +600,7 @@ class EscadaState(AuthState): break # ── État final — async with self #3 ────────────────────────────────────── - app_log(f"Poll termine — result_ready={_result_ready}") + app_log(f"Poll terminé — result_ready={_result_ready}") _uncancel() async with self: self.import_in_progress = False @@ -601,22 +611,23 @@ class EscadaState(AuthState): self.sync_res_matu = _result_data.get("res_matu", []) self.sync_errors = _result_data.get("errors", []) self.sync_done = True - app_log("Resultats charges — sync terminee OK") + app_log("Résultats chargés — sync terminée OK") else: - self.sync_errors = ["Import timeout — verifiez les logs (> 15min)."] + self.sync_errors = ["Import timeout — vérifiez les logs (> 15min)."] # ── Background: push vers Escada ─────────────────────────────────────────── @_background async def push_escada(self): async with self: + user = self.username or "?" self.is_pushing = True - self.op_log = "Envoi vers Escadaweb..." + self.op_log = "Envoi vers Escadaweb…" self.push_done = False self.push_ok = 0 self.push_errors = [] - app_log("Push Escada demarre") + app_log(f"Push Escada démarré par {user}") extra: list[str] = [] cmd = [sys.executable, str(_PUSH_SCRIPT), *extra] lines: list[str] = [] @@ -706,7 +717,7 @@ class EscadaState(AuthState): app_log(f" Erreur parse PUSH_DONE : {_e}", debug=True) if push_done: - app_log(f"Push termine — ok:{push_ok} erreurs:{len(push_errors)}") + app_log(f"Push terminé — ok:{push_ok} erreurs:{len(push_errors)}") else: app_log(f"Push : PUSH_DONE non trouve (code={_rc}, lignes={len(lines)})") @@ -723,7 +734,7 @@ class EscadaState(AuthState): self.is_pushing = False self._reload_pending() except Exception as _e: - app_log(f"Erreur mise a jour etat push : {_e}") + app_log(f"Erreur mise à jour état push : {_e}") try: async with self: self.is_pushing = False @@ -910,7 +921,7 @@ def _sync_progress() -> rx.Component: size="3", font_weight="600", color="#1565c0", ), rx.text( - "Telechargement depuis escadaweb.vs.ch (1-3 min)", + "Téléchargement depuis escadaweb.vs.ch (1-3 min)", size="2", color="#555", ), spacing="0", @@ -935,7 +946,7 @@ def _sync_progress() -> rx.Component: ), ), - # Phase 2 : import en base + # Phase 2 : import rx.cond( EscadaState.import_in_progress, rx.box( @@ -943,11 +954,11 @@ def _sync_progress() -> rx.Component: rx.spinner(size="3"), rx.vstack( rx.text( - "Import des donnees en cours...", + "Import des données en cours…", size="3", font_weight="600", color="#e65100", ), rx.text( - "Insertion en base de donnees (~30s)", + "Insertion dans la DB (~30s)", size="2", color="#555", ), spacing="0", @@ -969,7 +980,7 @@ def _sync_progress() -> rx.Component: rx.vstack( rx.callout.root( rx.callout.icon(rx.icon("check", size=16)), - rx.callout.text("Synchronisation et import termines."), + rx.callout.text("Synchronisation et import terminés."), color_scheme="green", variant="soft", size="1", @@ -1061,8 +1072,7 @@ def escada_page() -> rx.Component: rx.vstack( rx.heading("Synchronisation Escada", size="7"), rx.text( - "Telecharge absences, BN, notes et fiches depuis escadaweb.vs.ch " - "et les importe directement en base.", + "Télécharge absences, BN, notes et données apprentis depuis Escadaweb.", size="2", color="#666", ), @@ -1106,7 +1116,7 @@ def escada_page() -> rx.Component: ~EscadaState.has_classes, rx.box( rx.text( - "Cliquez sur Actualiser pour recuperer la liste des classes depuis Escadaweb.", + "Cliquez sur Actualiser pour récupérer la liste des classes depuis Escadaweb.", size="2", color="#555", ), padding="0.75rem", @@ -1133,7 +1143,7 @@ def escada_page() -> rx.Component: rx.flex( rx.checkbox(checked=EscadaState.sync_bn, on_change=EscadaState.set_sync_bn, size="2"), - rx.text("BN + Matu", size="2"), + rx.text("BN + moyennes Matu", size="2"), gap="0.4rem", align="center", ), rx.flex( @@ -1145,7 +1155,7 @@ def escada_page() -> rx.Component: rx.flex( rx.checkbox(checked=EscadaState.sync_fiches, on_change=EscadaState.set_sync_fiches, size="2"), - rx.text("Fiches", size="2"), + rx.text("Données apprentis", size="2"), gap="0.4rem", align="center", ), gap="1rem", @@ -1155,16 +1165,30 @@ def escada_page() -> rx.Component: rx.cond( EscadaState.sync_abs, rx.flex( + rx.icon( + "triangle-alert", + size=14, + color="#b45309", + ), rx.checkbox( checked=EscadaState.force_abs, on_change=EscadaState.set_force_abs, size="2", + color_scheme="amber", ), rx.text( - "Forcer la reimportation des absences existantes", - size="2", color="#555", + "Les modifications non uploadées sur Escada lors de l'import sont conservées. Forcer la ré-importation complète des absences pour reprendre l'état complet des absences sur Escada.", + size="2", + color="#92400e", + font_weight="600", ), - gap="0.4rem", align="center", + gap="0.5rem", + align="center", + padding="0.5rem 0.75rem", + background_color="#fef3c7", + border="1px solid #fcd34d", + border_radius="6px", + flex_wrap="wrap", ), ), @@ -1284,7 +1308,7 @@ def escada_page() -> rx.Component: EscadaState.push_ok > 0, rx.text( EscadaState.push_ok, - " changement(s) envoye(s) avec succes.", + " changement(s) envoyé(s) avec succès.", size="2", color="#2e7d32", font_weight="600", ), ), diff --git a/eptm_dashboard/pages/fiche.py b/eptm_dashboard/pages/fiche.py index fd9423d..0dc1c81 100644 --- a/eptm_dashboard/pages/fiche.py +++ b/eptm_dashboard/pages/fiche.py @@ -21,6 +21,7 @@ from src.db import ( 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 +from src.logger import app_log MOIS_FR = [ "janvier", "fevrier", "mars", "avril", "mai", "juin", @@ -313,7 +314,7 @@ def _absence_pdf_apprenti(sess, apprenti) -> bytes: total_periodes = total_e + total_n footer_label = ( f"{len(blocs)} absence(s) | " - f"{total_periodes} periode(s) | " + f"{total_periodes} période(s) | " f"{total_e} excusee(s) | " f"{total_n} non excusee(s)" ) @@ -381,6 +382,15 @@ class FicheState(AuthState): selected_label: str = "" selected_id: int = 0 has_apprentis: bool = False + apprenti_search: str = "" + apprenti_select_open: bool = False + + @rx.var + def filtered_apprenti_labels(self) -> list[str]: + q = self.apprenti_search.lower().strip() + if not q: + return self.apprenti_labels + return [l for l in self.apprenti_labels if q in l.lower()] # ── KPIs ───────────────────────────────────────────────────────────────── kpi_total: int = 0 @@ -455,16 +465,23 @@ class FicheState(AuthState): 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 + # Note: rx.segmented_control passe str | list[str] — on coerce. + @staticmethod + def _coerce_period(v) -> str: + if isinstance(v, list): + return v[0] if v else "present" + return v or "present" + + def set_edit_p1(self, v): self.edit_p1 = self._coerce_period(v) + def set_edit_p2(self, v): self.edit_p2 = self._coerce_period(v) + def set_edit_p3(self, v): self.edit_p3 = self._coerce_period(v) + def set_edit_p4(self, v): self.edit_p4 = self._coerce_period(v) + def set_edit_p5(self, v): self.edit_p5 = self._coerce_period(v) + def set_edit_p6(self, v): self.edit_p6 = self._coerce_period(v) + def set_edit_p7(self, v): self.edit_p7 = self._coerce_period(v) + def set_edit_p8(self, v): self.edit_p8 = self._coerce_period(v) + def set_edit_p9(self, v): self.edit_p9 = self._coerce_period(v) + def set_edit_p10(self, v): self.edit_p10 = self._coerce_period(v) # ── Setters (email) ─────────────────────────────────────────────────────── def set_email_dest(self, v: str): self.email_dest = v @@ -506,8 +523,18 @@ class FicheState(AuthState): except ValueError: pass self.edit_date = "" + self.apprenti_select_open = False + self.apprenti_search = "" self._reload(reset_email=True) + def set_apprenti_search(self, v: str): + self.apprenti_search = v + + def set_apprenti_select_open(self, v: bool): + self.apprenti_select_open = v + if not v: + self.apprenti_search = "" + def navigate_to(self, apprenti_id: int): if apprenti_id in self.apprenti_ids: idx = self.apprenti_ids.index(apprenti_id) @@ -577,6 +604,12 @@ class FicheState(AuthState): return sess = get_session() d = date.fromisoformat(self.edit_date) + apprenti = sess.get(Apprenti, self.selected_id) + appr_label = ( + f"{apprenti.nom} {apprenti.prenom} ({apprenti.classe})" + if apprenti else f"id={self.selected_id}" + ) + user = self.username or "?" existing = sess.execute( select(Absence).where( Absence.apprenti_id == self.selected_id, @@ -590,21 +623,31 @@ class FicheState(AuthState): 7: self.edit_p7, 8: self.edit_p8, 9: self.edit_p9, 10: self.edit_p10, } + d_str = d.strftime("%d.%m.%Y") 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) + app_log( + f"[abs] {user} : {appr_label} — {d_str} P{p} : " + f"{ab.type_origine} → présent (suppression)" + ) else: type_o = "E" if choice == "excusee" else "N" statut = "excusee" if choice == "excusee" else "a_traiter" if ab: if ab.statut != statut: + old_type = ab.type_origine ab.type_origine = type_o ab.statut = statut ab.updated_by = self.username upsert_escada_pending(sess, self.selected_id, d, p, type_o) + app_log( + f"[abs] {user} : {appr_label} — {d_str} P{p} : " + f"{old_type} → {type_o}" + ) else: sess.add(Absence( apprenti_id=self.selected_id, @@ -613,6 +656,10 @@ class FicheState(AuthState): updated_by=self.username, import_id=None, )) upsert_escada_pending(sess, self.selected_id, d, p, type_o) + app_log( + f"[abs] {user} : {appr_label} — {d_str} P{p} : " + f"présent → {type_o} (création)" + ) sess.commit() self.edit_date = "" self._reload(reset_email=False) @@ -621,6 +668,13 @@ class FicheState(AuthState): def excuse_day(self, date_str: str): sess = get_session() d = date.fromisoformat(date_str) + apprenti = sess.get(Apprenti, self.selected_id) + appr_label = ( + f"{apprenti.nom} {apprenti.prenom} ({apprenti.classe})" + if apprenti else f"id={self.selected_id}" + ) + user = self.username or "?" + d_str = d.strftime("%d.%m.%Y") absences = sess.execute( select(Absence).where( Absence.apprenti_id == self.selected_id, @@ -629,10 +683,15 @@ class FicheState(AuthState): ) ).scalars().all() for ab in absences: + old_type = ab.type_origine ab.statut = "excusee" ab.type_origine = "E" ab.updated_by = self.username upsert_escada_pending(sess, self.selected_id, d, ab.periode, "E") + app_log( + f"[abs] {user} : {appr_label} — {d_str} P{ab.periode} : " + f"{old_type} → E (excuse rapide)" + ) sess.commit() if self.edit_date == date_str: self.edit_date = "" @@ -991,6 +1050,75 @@ class FicheState(AuthState): # ── UI components ───────────────────────────────────────────────────────────── +def _apprenti_option(label: rx.Var) -> rx.Component: + return rx.box( + rx.text(label, size="2"), + padding="0.45rem 0.75rem", + cursor="pointer", + on_click=FicheState.handle_select(label), + _hover={"background_color": "var(--gray-3)"}, + width="100%", + ) + + +def _apprenti_searchable_select() -> rx.Component: + return rx.popover.root( + rx.popover.trigger( + rx.box( + rx.flex( + rx.cond( + FicheState.selected_label != "", + rx.text(FicheState.selected_label, size="2"), + rx.text("Sélectionner un apprenti…", size="2", color="var(--gray-9)"), + ), + rx.spacer(), + rx.icon("chevron-down", size=18, color="var(--gray-9)"), + align="center", + width="100%", + ), + padding="0.5rem 0.75rem", + border="1px solid var(--gray-7)", + border_radius="6px", + background_color="white", + cursor="pointer", + width="100%", + ), + ), + rx.popover.content( + rx.vstack( + rx.input( + placeholder="Rechercher un apprenti…", + value=FicheState.apprenti_search, + on_change=FicheState.set_apprenti_search, + size="2", + width="100%", + auto_focus=True, + ), + rx.cond( + FicheState.filtered_apprenti_labels.length() > 0, + rx.box( + rx.foreach(FicheState.filtered_apprenti_labels, _apprenti_option), + max_height="280px", + overflow_y="auto", + width="100%", + ), + rx.box( + rx.text("Aucun résultat", size="2", color="var(--gray-9)"), + padding="0.5rem 0.75rem", + ), + ), + spacing="2", + width="100%", + ), + min_width="320px", + max_width="500px", + padding="0.5rem", + ), + open=FicheState.apprenti_select_open, + on_open_change=FicheState.set_apprenti_select_open, + ) + + def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component: return rx.box( rx.text(label, size="1", color="#666"), @@ -1064,16 +1192,20 @@ 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"), - ), + rx.segmented_control.root( + rx.segmented_control.item("Présent", value="present"), + rx.segmented_control.item("E", value="excusee"), + rx.segmented_control.item("N", value="non_excusee"), value=val, on_change=setter, size="1", + color_scheme=rx.match( + val, + ("excusee", "orange"), + ("non_excusee", "red"), + "gray", + ), + radius="medium", ), spacing="2", align="center", @@ -1316,13 +1448,8 @@ def fiche_page() -> rx.Component: FicheState.has_apprentis, rx.vstack( - # ── Sélecteur apprenti ──────────────────────────────────── - rx.select( - FicheState.apprenti_labels, - value=FicheState.selected_label, - on_change=FicheState.handle_select, - width="100%", - ), + # ── Sélecteur apprenti (recherche intégrée) ─────────────── + _apprenti_searchable_select(), # ── KPI cards ───────────────────────────────────────────── rx.flex( @@ -1542,7 +1669,7 @@ def fiche_page() -> rx.Component: rx.hstack( rx.icon("clock", size=15, color="#b45309"), rx.text( - "Absences à traiter", + "Valider toutes les absences d'une journée", size="2", weight="bold", color="#92400e", ), spacing="2", align="center", @@ -1592,7 +1719,7 @@ def fiche_page() -> rx.Component: ), rx.box( rx.text( - "Aucun apprenti en base. Faites d'abord un import.", + "Aucun apprenti. Faites d'abord un import.", size="2", color="#666", ), padding="1rem", diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py index a2e8af3..4e8a956 100644 --- a/eptm_dashboard/sidebar.py +++ b/eptm_dashboard/sidebar.py @@ -26,7 +26,7 @@ _ADMIN_PAGES = [ ("Cron", "/cron", "alarm-clock"), ("Logs", "/logs", "file-text"), ("Utilisateurs", "/users", "user-cog"), - ("Parametres", "/params", "settings"), + ("Paramètres", "/params", "settings"), ] diff --git a/src/app.py b/src/app.py deleted file mode 100644 index cf67b85..0000000 --- a/src/app.py +++ /dev/null @@ -1,3733 +0,0 @@ -"""Interface Streamlit — Absences EPTM Sion.""" - -import sys as _sys -from pathlib import Path as _Path - -# Streamlit prepends src/ to sys.path[0] before running this script. -# That makes `from src.db import …` look for src inside src/, breaking the -# package resolution. Strip every src/ variant and put the project root first. -_root = _Path(__file__).resolve().parent.parent -_src_dir = (_root / "src").resolve() -_sys.path[:] = [p for p in _sys.path - if _Path(p).resolve() != _src_dir] if _sys.path else _sys.path -if str(_root) not in _sys.path: - _sys.path.insert(0, str(_root)) -# On Streamlit hot-reload the old src.* modules stay in sys.modules; clear them -# so the fresh import uses the updated sys.path order. -for _k in [k for k in _sys.modules if k == "src" or k.startswith("src.")]: - del _sys.modules[_k] -del _src_dir - -import io -import os -import re -import sys -import calendar as _cal -from datetime import date, datetime -from pathlib import Path -from urllib.parse import quote as _url_quote - -import bcrypt -import json -import pyotp -import qrcode as _qrcode -import subprocess -import time -import uuid -import pandas as pd -import plotly.express as px -from PIL import Image -import streamlit as st -from streamlit_option_menu import option_menu -import yaml -from yaml.loader import SafeLoader -from sqlalchemy import delete, func, or_, select -from sqlalchemy.orm import sessionmaker - - -from src.db import ( - STATUTS, Absence, Apprenti, ApprentiFiche, EscadaPending, Import, ImportBN, ImportMatu, - NotesBulletin, NotesMatu, NotesExamen, SanctionExport, init_db, upsert_apprenti_fiche, - upsert_escada_pending, _norm_prenom, -) -from src.importer import import_pdf as do_import -from src.importer_bn import import_bn as do_import_bn -from src.importer_matu import import_matu as do_import_matu -from src.parser_bn import ann_short_label, sem_short_label -from src import stats - -DATA_DIR = _root / "data" -PDFS_DIR = DATA_DIR / "pdfs" -AUTH_FILE = DATA_DIR / "auth.yaml" -AUTH_TOKENS_FILE = DATA_DIR / "auth_tokens.json" -SETTINGS_FILE = DATA_DIR / "settings.json" -_SANCTION_TPL = DATA_DIR / "templates" / "GF_FO_Avis_de_sanction.pdf" -_SESSION_DURATION = 24 * 3600 # secondes - -_MOIS_FR = ["janvier","février","mars","avril","mai","juin","juillet", - "août","septembre","octobre","novembre","décembre"] - - -def _load_settings() -> dict: - if SETTINGS_FILE.exists(): - try: - return json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) - except Exception: - pass - return {} - - -def _save_settings(s: dict): - SETTINGS_FILE.write_text(json.dumps(s, ensure_ascii=False, indent=2), encoding="utf-8") - - -def _load_tokens() -> dict: - if AUTH_TOKENS_FILE.exists(): - try: - return json.loads(AUTH_TOKENS_FILE.read_text(encoding="utf-8")) - except Exception: - pass - return {} - - -def _save_tokens(tokens: dict) -> None: - now = time.time() - valid = {k: v for k, v in tokens.items() if now - v["ts"] < _SESSION_DURATION} - AUTH_TOKENS_FILE.write_text(json.dumps(valid, ensure_ascii=False), encoding="utf-8") - - -def _create_session_token(username: str, name: str) -> str: - tokens = _load_tokens() - token = uuid.uuid4().hex - tokens[token] = {"username": username, "name": name, "ts": time.time()} - _save_tokens(tokens) - return token - - -def _validate_token(token: str) -> "dict | None": - if not token: - return None - tokens = _load_tokens() - entry = tokens.get(token) - if not entry: - return None - if time.time() - entry["ts"] >= _SESSION_DURATION: - tokens.pop(token, None) - _save_tokens(tokens) - return None - return entry - - -def _get_cookie_token() -> str: - """Lit eptm_token depuis le header Cookie HTTP (Streamlit 1.37+).""" - try: - cookie_header = st.context.headers.get("Cookie", "") - for part in cookie_header.split(";"): - part = part.strip() - if part.startswith("eptm_token="): - return part[len("eptm_token="):] - except Exception: - pass - return "" - - -# ── TOTP helpers ────────────────────────────────────────────────────────────── - -def _save_user_totp(username: str, secret: "str | None") -> None: - with open(AUTH_FILE, encoding="utf-8") as f: - cfg = yaml.load(f, Loader=SafeLoader) - cfg["credentials"]["usernames"][username]["totp_secret"] = secret - with open(AUTH_FILE, "w", encoding="utf-8") as f: - yaml.dump(cfg, f, allow_unicode=True) - - -def _generate_totp_qr(username: str, secret: str) -> bytes: - uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name="EPTM Dashboard") - qr = _qrcode.QRCode(box_size=8, border=2) - qr.add_data(uri) - qr.make(fit=True) - img = qr.make_image(fill_color="black", back_color="white") - buf = io.BytesIO() - img.save(buf, format="PNG") - return buf.getvalue() - - -def _complete_login(uname: str, name: str, role: str) -> None: - for k in ("_pending_totp", "_pending_username", "_pending_name", "_pending_role", "_setup_secret", "_setup_qr_bytes"): - st.session_state.pop(k, None) - tok = _create_session_token(uname, name) - st.session_state["authenticated"] = True - st.session_state["username"] = uname - st.session_state["name"] = name - st.session_state["role"] = role - st.session_state["_token"] = tok - st.query_params["t"] = tok - st.rerun() - - -_CSS_NO_SIDEBAR = """ - -""" - - -def _show_totp_page() -> None: - """Page intermédiaire TOTP : enrollment QR (1re fois) ou vérification code.""" - st.markdown(_CSS_NO_SIDEBAR, unsafe_allow_html=True) - uname = st.session_state.get("_pending_username", "") - name = st.session_state.get("_pending_name", "") - role = st.session_state.get("_pending_role", "user") - users = _load_users() - totp_secret = users.get(uname, {}).get("totp_secret") - - _, mid, _ = st.columns([1, 2, 1]) - with mid: - _logo_path = DATA_DIR / "logo.png" - if _logo_path.exists(): - _lc, _lm, _lr = st.columns([1, 2, 1]) - _lm.image(str(_logo_path), width=160) - - if not totp_secret: - # ── Enrollment : première connexion ─────────────────────────────── - if "_setup_secret" not in st.session_state: - st.session_state["_setup_secret"] = pyotp.random_base32() - secret = st.session_state["_setup_secret"] - - st.markdown("### Activation de l'authentification à 2 facteurs") - st.info( - "Scannez ce QR code avec votre application d'authentification " - "(Google Authenticator, Authy, Microsoft Authenticator…)" - ) - if "_setup_qr_bytes" not in st.session_state: - st.session_state["_setup_qr_bytes"] = _generate_totp_qr(uname, secret) - st.image(st.session_state["_setup_qr_bytes"], width=240) - with st.expander("Saisie manuelle du code secret"): - st.code(secret, language=None) - st.markdown("---") - with st.form("totp_setup_form"): - code = st.text_input("Code à 6 chiffres (pour vérifier l'activation)", max_chars=6) - if st.form_submit_button("Activer", use_container_width=True, type="primary"): - if pyotp.TOTP(secret).verify(code.strip(), valid_window=1): - _save_user_totp(uname, secret) - _complete_login(uname, name, role) - else: - st.error("Code incorrect — vérifiez que l'heure de votre appareil est exacte.") - - else: - # ── Vérification : connexions suivantes ─────────────────────────── - st.markdown("### Vérification en deux étapes") - with st.form("totp_verify_form"): - code = st.text_input("Code à 6 chiffres", max_chars=6) - if st.form_submit_button("Vérifier", use_container_width=True, type="primary"): - if pyotp.TOTP(totp_secret).verify(code.strip(), valid_window=1): - _complete_login(uname, name, role) - else: - st.error("Code incorrect.") - - st.markdown("") - if st.button("← Retour à la connexion", use_container_width=True): - for k in ("_pending_totp", "_pending_username", "_pending_name", "_pending_role", "_setup_secret", "_setup_qr_bytes"): - st.session_state.pop(k, None) - st.rerun() - -st.set_page_config( - page_title="EPTM Dashboard", - page_icon=Image.open(Path(__file__).parent.parent / "static" / "favicon.png"), - layout="wide", - initial_sidebar_state="expanded", -) - -# ── Auth ────────────────────────────────────────────────────────────────────── - -def _load_users() -> dict: - if not AUTH_FILE.exists(): - st.error(f"Fichier d'authentification manquant : `{AUTH_FILE}`") - st.stop() - with open(AUTH_FILE, encoding="utf-8") as f: - return yaml.load(f, Loader=SafeLoader)["credentials"]["usernames"] - - -def _migrate_roles(): - """Ajoute role/totp_secret dans auth.yaml si absents. Exécuté au démarrage.""" - if not AUTH_FILE.exists(): - return - try: - with open(AUTH_FILE, encoding="utf-8") as f: - cfg = yaml.load(f, Loader=SafeLoader) - users = cfg["credentials"]["usernames"] - changed = any("role" not in d or "totp_secret" not in d for d in users.values()) - if changed: - for uname, udata in users.items(): - if "role" not in udata: - udata["role"] = "admin" if uname == "julbal" else "user" - if "totp_secret" not in udata: - udata["totp_secret"] = None - with open(AUTH_FILE, "w", encoding="utf-8") as f: - yaml.dump(cfg, f, allow_unicode=True) - except Exception: - pass - - -_migrate_roles() - -import streamlit.components.v1 as _stc_auth - -# Restaurer la session : token URL en priorité, sinon cookie HTTP -if not st.session_state.get("authenticated"): - _url_token = st.query_params.get("t", "") or _get_cookie_token() - _entry = _validate_token(_url_token) - if _entry: - st.session_state["authenticated"] = True - st.session_state["username"] = _entry["username"] - st.session_state["name"] = _entry["name"] - st.session_state["_token"] = _url_token - try: - _u = _load_users() - st.session_state["role"] = _u.get(_entry["username"], {}).get("role", "user") - except Exception: - st.session_state["role"] = "user" - -if not st.session_state.get("authenticated") and st.session_state.get("_pending_totp"): - _show_totp_page() - st.stop() - -if not st.session_state.get("authenticated"): - st.markdown(_CSS_NO_SIDEBAR, unsafe_allow_html=True) - _, mid, _ = st.columns([1, 2, 1]) - with mid: - _logo_path = DATA_DIR / "logo.png" - if _logo_path.exists(): - _lc, _lm, _lr = st.columns([1, 2, 1]) - _lm.image(str(_logo_path), width=160) - else: - st.markdown("## EPTM Dashboard") - with st.form("login_form"): - _uname = st.text_input("Identifiant") - _pwd = st.text_input("Mot de passe", type="password") - if st.form_submit_button("Se connecter", use_container_width=True, type="primary"): - _users = _load_users() - if _uname in _users: - if bcrypt.checkpw(_pwd.encode(), _users[_uname]["password"].encode()): - st.session_state["_pending_totp"] = True - st.session_state["_pending_username"] = _uname - st.session_state["_pending_name"] = _users[_uname]["name"] - st.session_state["_pending_role"] = _users[_uname].get("role", "user") - st.rerun() - else: - st.error("Mot de passe incorrect.") - else: - st.error("Identifiant inconnu.") - st.stop() - -_current_user: str = st.session_state["username"] -_current_name: str = st.session_state["name"] -_current_role: str = st.session_state.get("role", "user") -_is_admin: bool = _current_role == "admin" - -# Écrire/rafraîchir le cookie de session à chaque rendu authentifié -_tok_val = st.session_state.get("_token", "") -if _tok_val: - _stc_auth.html( - f"", - height=0, - ) - -# ── DB ──────────────────────────────────────────────────────────────────────── - -@st.cache_resource -def _engine(): - PDFS_DIR.mkdir(parents=True, exist_ok=True) - return init_db() - - -def _session(): - return sessionmaker(bind=_engine())() - - -def _sem_filter(sess, semestre: str | None): - """Clause SQLAlchemy pour filtrer par semestre, None = pas de filtre.""" - if semestre is None: - return None - ids = sess.execute( - select(Import.id).where(Import.semestre == semestre) - ).scalars().all() - if not ids: - return None - return or_(Absence.import_id.in_(set(ids)), Absence.import_id.is_(None)) - - -# ── BN helpers ──────────────────────────────────────────────────────────────── - -_GROUP_LABELS = { - "CG": "Culture Gén.", - "BP": "Branches Prof.", - "TP": "Trav. Pratiques", -} -_GROUP_ORDER = {"DUAL": ["CG", "BP"], "EM": ["BP", "TP"]} - - -def _bn_rows_for_class(sess, classe: str) -> list[tuple]: - """Return [(Apprenti, NotesBulletin), ...] from the most recent BN import for *classe*.""" - latest = sess.execute( - select(ImportBN) - .where(ImportBN.classe == classe) - .order_by(ImportBN.date_import.desc()) - .limit(1) - ).scalar_one_or_none() - if latest is None: - return [] - return sess.execute( - select(Apprenti, NotesBulletin) - .join(NotesBulletin, NotesBulletin.apprenti_id == Apprenti.id) - .where(NotesBulletin.import_id == latest.id) - .order_by(Apprenti.nom, Apprenti.prenom) - ).all() - - -def _bn_fmt(v) -> str: - """Format a BN value as French decimal string, empty string if None.""" - if v is None: - return "" - try: - return f"{float(v):.1f}".replace(".", ",") - except (TypeError, ValueError): - return "" - - -def _bn_cell_style(v) -> str: - """Inline style for a BN data cell; red if value < 4.0.""" - base = "border:1px solid #dee2e6;padding:5px 10px;text-align:center" - if v is None: - return f"{base};color:#bbb" - try: - if float(v) < 4.0: - return f"{base};background:#ffcccc;color:#B71C1C;font-weight:bold" - except (TypeError, ValueError): - pass - return base - - -def _bn_html_table(name: "str | None", d: dict, sem_labels: list, groups_order: list) -> str: - """Return an HTML table for one apprenti's BN data. - - Rows = groups / moyennes ; columns = 8 semester slots. - Annual averages span 2 columns (one per year pair). - """ - N = 8 - TD = "border:1px solid #dee2e6;padding:5px 10px" - TH = "border:1px solid #dee2e6;padding:5px 10px;text-align:center;background:#f8f9fa" - - # ── Header row ──────────────────────────────────────────────────────────── - header = f'' - for i in range(N): - raw = sem_labels[i] if i < len(sem_labels) else None - short = sem_short_label(raw, i) - year_part = "" - if raw: - parts = str(raw).replace("\n", " ").split() - if len(parts) >= 4: - year_part = ( - f"
" - f"{parts[2]} {parts[3]}" - ) - header += f'{short}{year_part}' - - # ── Body rows ───────────────────────────────────────────────────────────── - body = "" - - SEP = ";border-top:3px solid #9e9e9e" - - def _moy_sem_row(label: str, gd: dict, label_style: str, sep: bool = False) -> str: - s = SEP if sep else "" - cells = f'{label}' - for i in range(N): - v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None - cells += f'{_bn_fmt(v)}' - return f"{cells}" - - def _moy_ann_row(label: str, gd: dict, label_style: str, sep: bool = False) -> str: - s = SEP if sep else "" - cells = f'{label}' - for year_start in range(0, N, 2): - v = gd["moy_ann"][year_start] if year_start < len(gd.get("moy_ann", [])) else None - cells += ( - f'{_bn_fmt(v)}' - ) - return f"{cells}" - - for grp in groups_order: - gd = d["groupes"].get(grp, {"moy_sem": [None] * N, "moy_ann": [None] * N}) - lbl = _GROUP_LABELS.get(grp, grp) - body += _moy_sem_row(lbl, gd, f"{TD};font-weight:bold") - body += _moy_ann_row("Moyenne annuelle du groupe", gd, f"{TD};font-style:italic;color:#555") - - body += _moy_sem_row("Moyenne semestrielle globale", d["globale"], f"{TD};font-style:italic", sep=True) - body += _moy_ann_row("Moyenne annuelle globale", d["globale"], f"{TD};font-weight:bold") - - # ── Apprenti name header ────────────────────────────────────────────────── - name_html = "" - top_border = f"border-top:1px solid #dee2e6" - if name: - name_html = ( - f'
{name}
' - ) - top_border = "border-top:none" - - return ( - f'
' - f"{name_html}" - f'' - f"{header}" - f"{body}" - f"
" - ) - - -def _matu_html_table(nm: NotesMatu) -> str: - """Small HTML table for one apprenti's Matu (MP) data.""" - TD = "border:1px solid #dee2e6;padding:5px 10px" - TDc = f"{TD};text-align:center" - - def _cell_moy(v): - if v is None: - return f'—' - style = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if v < 4.0 else TDc - return f'{_bn_fmt(v)}' - - def _cell_prom(p, info): - if not p: - return f'—' - red = p == "NB" - style = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if red else TDc - info_cell = f'{info or ""}' - return f'{p}{info_cell}' - - rows = ( - f'Moyenne du semestre{_cell_moy(nm.moy)}' - f'Promotion{_cell_prom(nm.promotion, nm.prom_info)}' - ) - header = ( - f'
Matu — {nm.classe_mp} — {nm.sem_label}
' - ) - return ( - f'
{header}' - f'' - f'{rows}
' - ) - - -def _absence_html_table(total: int, excusees: int, non_exc: int, blocs: int, quota: int = 5) -> str: - """HTML table for absence summary per apprenti, styled like BN/Matu tables.""" - TD = "border:1px solid #dee2e6;padding:5px 10px" - TDc = f"{TD};text-align:center" - - def _cell(v, rouge=False): - s = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if rouge and v > 0 else TDc - return f'{v}' - - blocs_s = ( - f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" - if blocs >= quota else TDc - ) - rows = ( - f'Périodes d\'absence{_cell(total)}' - f'dont excusées : {excusees}' - f'Nombre d\'absences' - f'{"🚨 " if blocs >= quota else ""}{blocs}' - f'avis de sanction >{quota}' - ) - header = ( - f'
Absences
' - ) - return ( - f'
{header}' - f'' - f'{rows}
' - ) - - -def _absence_excel_apprenti(sess, apprenti_id: int, semestre: str | None = None) -> bytes: - """Excel grid: one row per absence date, columns P1-P10 with E/N values.""" - q = ( - select(Absence) - .where(Absence.apprenti_id == apprenti_id) - .order_by(Absence.date, Absence.periode) - ) - clause = _sem_filter(sess, semestre) - if clause is not None: - q = q.where(clause) - absences = sess.execute(q).scalars().all() - - cols = ["Date"] + [f"P{i}" for i in range(1, 11)] - if not absences: - buf = io.BytesIO() - pd.DataFrame(columns=cols).to_excel(buf, index=False, engine="openpyxl") - return buf.getvalue() - - by_date: dict = {} - for ab in absences: - by_date.setdefault(ab.date, {})[ab.periode] = "E" if ab.statut == "excusee" else "N" - - rows = [ - {"Date": d.strftime("%d.%m.%Y"), - **{f"P{p}": by_date[d].get(p, "") for p in range(1, 11)}} - for d in sorted(by_date) - ] - buf = io.BytesIO() - pd.DataFrame(rows, columns=cols).to_excel(buf, index=False, engine="openpyxl") - return buf.getvalue() - - -def _absence_pdf_apprenti(sess, apprenti: "Apprenti", semestre: str | None = None) -> bytes: - """PDF A4 paysage: tableau unique Abs. | Date | P1-P10 avec ligne de totaux.""" - from reportlab.lib import colors as _rl_colors - from reportlab.lib.pagesizes import A4, landscape - from reportlab.lib.styles import getSampleStyleSheet - from reportlab.lib.units import cm - from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer - - q = ( - select(Absence) - .where(Absence.apprenti_id == apprenti.id) - .order_by(Absence.date, Absence.periode) - ) - clause = _sem_filter(sess, semestre) - if clause is not None: - q = q.where(clause) - absences = sess.execute(q).scalars().all() - - by_date: dict = {} - for ab in absences: - by_date.setdefault(ab.date, {})[ab.periode] = "E" if ab.statut == "excusee" else "N" - - sorted_dates = sorted(by_date) - - # ── Blocs d'absences (weekend transparent : jeudi→lundi = 1 bloc) ─────── - blocs: list[list] = [] - if sorted_dates: - from datetime import timedelta as _td - dates_set = set(sorted_dates) - cur = [sorted_dates[0]] - for d in sorted_dates[1:]: - check = cur[-1] + _td(days=1) - gap_ok = True - while check < d: - if check.weekday() < 5 and check not in dates_set: - gap_ok = False - break - check += _td(days=1) - if gap_ok: - cur.append(d) - else: - blocs.append(cur) - cur = [d] - blocs.append(cur) - - DARK = _rl_colors.HexColor("#37474F") - BLUE_BG = _rl_colors.HexColor("#E3F2FD") - BLUE_FG = _rl_colors.HexColor("#0D47A1") - RED_BG = _rl_colors.HexColor("#FFEBEE") - RED_FG = _rl_colors.HexColor("#B71C1C") - GREY_BG = _rl_colors.HexColor("#F5F5F5") - FOOT_BG = _rl_colors.HexColor("#ECEFF1") - - # ── Tableau unique : Abs. | Date | P1 … P10 ────────────────────────────── - # Col indices : 0=Abs, 1=Date, 2..11=P1..P10 - data = [["Abs.", "Date"] + [f"P{i}" for i in range(1, 11)]] - styles_tbl = [ - ("BACKGROUND", (0, 0), (-1, 0), DARK), - ("TEXTCOLOR", (0, 0), (-1, 0), _rl_colors.white), - ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), - ("FONTSIZE", (0, 0), (-1, -1), 9), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("GRID", (0, 0), (-1, -1), 0.5, _rl_colors.HexColor("#CCCCCC")), - ("ROWBACKGROUNDS", (0, 1), (-1, -1), [_rl_colors.white, GREY_BG]), - ] - - total_e = total_n = 0 - row_idx = 1 # 0 = header - - # Build a quick date→bloc_num map - date_to_bloc: dict = {} - for bloc_num, bloc_dates in enumerate(blocs, start=1): - for d in bloc_dates: - date_to_bloc[d] = bloc_num - - for bloc_num, bloc_dates in enumerate(blocs, start=1): - first_row = row_idx - for i, d in enumerate(bloc_dates): - periods = by_date[d] - row = [str(bloc_num) if i == 0 else "", d.strftime("%d.%m.%Y")] - for p in range(1, 11): - val = periods.get(p, "") - row.append(val) - if val == "E": - total_e += 1 - styles_tbl += [ - ("BACKGROUND", (p + 1, row_idx), (p + 1, row_idx), BLUE_BG), - ("TEXTCOLOR", (p + 1, row_idx), (p + 1, row_idx), BLUE_FG), - ("FONTNAME", (p + 1, row_idx), (p + 1, row_idx), "Helvetica-Bold"), - ] - elif val == "N": - total_n += 1 - styles_tbl += [ - ("BACKGROUND", (p + 1, row_idx), (p + 1, row_idx), RED_BG), - ("TEXTCOLOR", (p + 1, row_idx), (p + 1, row_idx), RED_FG), - ("FONTNAME", (p + 1, row_idx), (p + 1, row_idx), "Helvetica-Bold"), - ] - data.append(row) - row_idx += 1 - # Merge the Abs. column cells for this bloc - if row_idx - first_row > 1: - styles_tbl.append(("SPAN", (0, first_row), (0, row_idx - 1))) - - # ── Ligne de totaux ─────────────────────────────────────────────────────── - total_row = row_idx - total_periodes = total_e + total_n - footer_label = ( - f"{len(blocs)} absence(s) | " - f"{total_periodes} periode(s) | " - f"{total_e} excusee(s) | " - f"{total_n} non excusee(s)" - ) - data.append([footer_label] + [""] * 11) - styles_tbl += [ - ("SPAN", (0, total_row), (-1, total_row)), - ("BACKGROUND", (0, total_row), (-1, total_row), FOOT_BG), - ("FONTNAME", (0, total_row), (-1, total_row), "Helvetica-Bold"), - ("ALIGN", (0, total_row), (-1, total_row), "LEFT"), - ("LEFTPADDING",(0, total_row), (-1, total_row), 8), - ] - - # usable width ≈ 26.7 cm (A4 landscape - 2×1.5 cm margins) - # Abs.(1.5) + Date(2.8) + 10×P(2.24) = 26.7 cm - col_w = [1.5 * cm, 2.8 * cm] + [2.24 * cm] * 10 - t = Table(data if data[1:] else [data[0]], colWidths=col_w, repeatRows=1) - t.setStyle(TableStyle(styles_tbl)) - - # ── Mise en page ────────────────────────────────────────────────────────── - buf = io.BytesIO() - doc = SimpleDocTemplate( - buf, - pagesize=landscape(A4), - leftMargin=1.5 * cm, rightMargin=1.5 * cm, - topMargin=1.5 * cm, bottomMargin=1.5 * cm, - ) - styles = getSampleStyleSheet() - sem_label = semestre or "Tous semestres" - title = Paragraph( - f"Absences — {apprenti.nom} {apprenti.prenom}" - f"    Classe : {apprenti.classe}" - f"    Semestre : {sem_label}", - styles["Normal"], - ) - doc.build([title, Spacer(1, 0.5 * cm), t]) - return buf.getvalue() - - -def _latest_matu(sess, apprenti_id: int) -> "NotesMatu | None": - """Return the most recent NotesMatu for *apprenti_id*, or None.""" - return sess.execute( - select(NotesMatu) - .join(ImportMatu, ImportMatu.id == NotesMatu.import_id) - .where(NotesMatu.apprenti_id == apprenti_id) - .order_by(ImportMatu.date_import.desc()) - .limit(1) - ).scalar_one_or_none() - - -def _render_bn_table(sess, classe: str) -> None: - """Render BN tables (+ Matu if available) for all apprentis of *classe*.""" - rows = _bn_rows_for_class(sess, classe) - if not rows: - st.info("Aucun bulletin de notes importé pour cette classe.") - return - - first_bn: NotesBulletin = rows[0][1] - sem_labels = json.loads(first_bn.sem_labels_json) - groups_order = _GROUP_ORDER.get(first_bn.type_classe, ["BP"]) - - html_parts = [] - for apprenti, bn in rows: - d = json.loads(bn.donnees_json) - html = _bn_html_table(f"{apprenti.nom} {apprenti.prenom}", d, sem_labels, groups_order) - nm = _latest_matu(sess, apprenti.id) - if nm: - html += _matu_html_table(nm) - html_parts.append(html) - st.markdown("\n".join(html_parts), unsafe_allow_html=True) - - latest = sess.execute( - select(ImportBN) - .where(ImportBN.classe == classe) - .order_by(ImportBN.date_import.desc()) - .limit(1) - ).scalar_one_or_none() - if latest: - st.caption( - f"Import BN du {latest.date_import.strftime('%d.%m.%Y %H:%M')} " - f"— {latest.nb_apprentis} apprenti(e)s — par {latest.imported_by}" - ) - - -# ── Sidebar ─────────────────────────────────────────────────────────────────── - -PAGE_ACCUEIL = "🏠 Accueil" -PAGE_TRAITER = "⚠️ À traiter" -PAGE_FICHE = "👤 Fiche apprenti" -PAGE_CLASSE = "🏫 Vue classe" -PAGE_IMPORT = "📥 Import PDF" -PAGE_ESCADA = "🔄 Escada" -PAGE_EXPORT = "📤 Export Excel" -PAGE_LOGS = "📋 Logs" -PAGE_USERS = "⚙️ Utilisateurs" -PAGE_PARAMS = "🔧 Paramètres" -PAGE_RESET = "🗑️ Vider données" - -_LOG_FILE = DATA_DIR / "logs" / "operations.log" - - -def _app_log(msg: str) -> None: - """Écrit une ligne dans le fichier de log (événements applicatifs).""" - ts = datetime.now().strftime("%H:%M:%S") - line = f"[{ts}] {msg}" - try: - _LOG_FILE.parent.mkdir(parents=True, exist_ok=True) - with _LOG_FILE.open("a", encoding="utf-8") as _f: - _f.write(line + "\n") - except Exception: - pass - - -_MAIN_PAGES = [PAGE_ACCUEIL, PAGE_TRAITER, PAGE_FICHE, PAGE_CLASSE] -_OUTILS_PAGES = [PAGE_IMPORT, PAGE_ESCADA, PAGE_EXPORT, PAGE_LOGS, PAGE_USERS, PAGE_PARAMS, PAGE_RESET] - -_RESPONSIVE_CSS = """""" - -_NAV_CSS = """""" - -_MENU_STYLES = { - "container": { - "padding": "0 !important", - "background-color": "transparent", - "border": "none", - }, - "icon": {"color": "#9e9e9e", "font-size": "0.95rem"}, - "nav-link": { - "color": "#555555", - "font-size": "0.875rem", - "font-weight": "400", - "padding": "0.58rem 1rem 0.58rem 1.1rem", - "border-radius": "0", - "border-left": "3px solid transparent", - "--hover-color": "#f8f9fa", - "margin": "0", - }, - "nav-link-selected": { - "background-color": "#e8eaf6", - "color": "#3949ab", - "font-weight": "600", - "border-left": "3px solid #3949ab", - "border-radius": "0", - }, - "menu-title": { - "color": "#9e9e9e", - "font-size": "0.6rem", - "font-weight": "700", - "letter-spacing": "0.12em", - "text-transform": "uppercase", - "padding": "0.75rem 1rem 0.25rem", - "margin": "0", - }, -} - -def _fill_sanction_pdf(nom: str, prenom: str, classe: str) -> bytes | None: - if not _SANCTION_TPL.exists(): - return None - s = _load_settings() - now = datetime.now() - date_str = f"{'1er' if now.day == 1 else now.day} {_MOIS_FR[now.month - 1]} {now.year}" - nom_complet = f"{prenom} {nom}" - try: - from pypdf import PdfReader, PdfWriter - reader = PdfReader(str(_SANCTION_TPL)) - writer = PdfWriter() - writer.append(reader) - writer.update_page_form_field_values( - writer.pages[0], - { - "NomParents": nom_complet, - "Adresse": "", - "NPA-Ville": "", - "NomApprenti": nom_complet, - "Classe": classe, - "TexteDescription": s.get("texte_sanction", - "Selon le règlement de l'EM, l'apprenti a dépassé le nombre d'absences limite."), - "Date": date_str, - "Prof": _current_name, - "CS": s.get("chef_section", "Patrick Rausis"), - }, - ) - buf = io.BytesIO() - writer.write(buf) - return buf.getvalue() - except Exception: - return None - - - - -st.markdown(_RESPONSIVE_CSS, unsafe_allow_html=True) - -def _nav_fiche(apprenti_id: int) -> None: - st.session_state["_current_page"] = PAGE_FICHE - st.session_state["_prefill_fiche"] = apprenti_id - st.session_state["sidebar_nav"] = "Fiche apprenti" - -def _nav_classe(classe: str) -> None: - st.session_state["_current_page"] = PAGE_CLASSE - st.session_state["_prefill_classe"] = classe - st.session_state["sidebar_nav"] = "Vue classe" - -if "_current_page" not in st.session_state: - st.session_state["_current_page"] = PAGE_ACCUEIL - -# ── Query param navigation / actions (déclenchés par dans le HTML) ── -_qp = st.query_params -if "nav_fiche" in _qp: - try: - _nav_fiche(int(_qp["nav_fiche"])) - except (ValueError, KeyError): - pass - st.query_params.clear() - st.rerun() -elif "nav_classe" in _qp: - _nav_classe(_qp["nav_classe"]) - st.query_params.clear() - st.rerun() -elif "sanc_gen" in _qp or "sanc_regen" in _qp: - _sanc_aid = int(_qp.get("sanc_gen") or _qp.get("sanc_regen")) - _sanc_sess = _session() - try: - _ap = _sanc_sess.get(Apprenti, _sanc_aid) - if _ap: - _pdf = _fill_sanction_pdf(_ap.nom, _ap.prenom, _ap.classe) - if _pdf: - _nb = stats.nb_blocs_absences(_sanc_sess, _sanc_aid) - _sanc_sess.add(SanctionExport( - apprenti_id=_sanc_aid, - exported_by=_current_user, - nb_absences=_nb, - )) - _sanc_sess.commit() - except Exception: - pass - finally: - _sanc_sess.close() - st.query_params.clear() - st.rerun() - -# Compteur absences à traiter (calculé avant rendu pour le badge) -try: - _cnt_sess = _session() - _n_traiter = _cnt_sess.execute( - select(func.count(Absence.id)).where( - Absence.statut.in_(["a_traiter", "en_attente_justificatif"]) - ) - ).scalar() or 0 - _cnt_sess.close() -except Exception: - _n_traiter = 0 - -st.markdown(_NAV_CSS, unsafe_allow_html=True) - -with st.sidebar: - # ── Logo ────────────────────────────────────────────────────────────── - _logo_path = DATA_DIR / "logo.png" - if _logo_path.exists(): - st.markdown('', unsafe_allow_html=True) - else: - st.markdown( - '', - unsafe_allow_html=True, - ) - - # ── Build page list for current user ────────────────────────────────── - _outils_visible = _OUTILS_PAGES if _is_admin else [PAGE_USERS] - _all_pages_def = [ - (PAGE_ACCUEIL, "Accueil", "house"), - (PAGE_TRAITER, "À traiter", "exclamation-triangle"), - (PAGE_FICHE, "Fiche apprenti", "person"), - (PAGE_CLASSE, "Vue classe", "building"), - (PAGE_IMPORT, "Import PDF", "cloud-upload"), - (PAGE_ESCADA, "Escada", "arrow-repeat"), - (PAGE_EXPORT, "Export Excel", "download"), - (PAGE_LOGS, "Logs", "list-ul"), - (PAGE_USERS, "Utilisateurs", "people"), - (PAGE_PARAMS, "Paramètres", "gear"), - (PAGE_RESET, "Vider données", "trash3"), - ] - _vis = [ - (p, l, i) for p, l, i in _all_pages_def - if p in (_MAIN_PAGES + _outils_visible) - ] - _vis_pages = [x[0] for x in _vis] - _vis_labels = [x[1] for x in _vis] - _vis_icons = [x[2] for x in _vis] - - # Ensure current page is accessible - _cur = st.session_state["_current_page"] - if _cur not in _vis_pages: - _cur = PAGE_ACCUEIL - st.session_state["_current_page"] = PAGE_ACCUEIL - _cur_idx = _vis_pages.index(_cur) - - # ── Navigation menu ─────────────────────────────────────────────────── - _selected = option_menu( - "Menu", - _vis_labels, - icons=_vis_icons, - default_index=_cur_idx, - key="sidebar_nav", - styles=_MENU_STYLES, - ) - if _selected and _selected in _vis_labels: - _sel_page = _vis_pages[_vis_labels.index(_selected)] - if _sel_page != st.session_state["_current_page"]: - st.session_state["_current_page"] = _sel_page - st.rerun() - - page = st.session_state["_current_page"] - - st.divider() - - # ── Sélecteur de semestre ───────────────────────────────────────────── - try: - _sem_sess = _session() - _sems_dispo = _sem_sess.execute( - select(Import.semestre).distinct().order_by(Import.semestre) - ).scalars().all() - _sem_sess.close() - except Exception: - _sems_dispo = [] - - if _sems_dispo: - _sem_opts = ["Tous"] + list(_sems_dispo) - if "sem_sel" not in st.session_state: - st.session_state["sem_sel"] = _sems_dispo[-1] - _sem_sel = st.selectbox("Semestre", _sem_opts, key="sem_sel") - _semestre_actif: str | None = None if _sem_sel == "Tous" else _sem_sel - else: - _semestre_actif = None - - st.divider() - - # ── User card ───────────────────────────────────────────────────────── - _role_lbl = "Administrateur" if _is_admin else "Utilisateur" - st.markdown( - f'
' - f'
👤
' - f'
{_current_name}
' - f'
{_role_lbl}
' - f'
', - unsafe_allow_html=True, - ) - if st.button("↩ Déconnexion", use_container_width=True): - _tok = st.session_state.pop("_token", None) - if _tok: - _toks = _load_tokens() - _toks.pop(_tok, None) - _save_tokens(_toks) - for _k in ("authenticated", "username", "name", "role"): - st.session_state.pop(_k, None) - st.query_params.clear() - st.rerun() - - -# ── Page : Accueil ──────────────────────────────────────────────────────────── - -_PDF_SVG = ( - '' - '' - '' -) -_BTN_STYLE = ( - "display:inline-flex;align-items:center;gap:4px;" - "padding:3px 8px;border:1px solid #ddd;border-radius:5px;" - "text-decoration:none;color:#333;font-size:0.8em;" - "white-space:nowrap;flex-shrink:0" -) - - -def _pdf_dl_link(pdf_bytes: bytes, filename: str, label: str) -> str: - import base64 as _b64 - b64 = _b64.b64encode(pdf_bytes).decode() - return ( - f'
{_PDF_SVG} {label}' - ) - - -def _render_sanction_table(df_quota, last_exports: dict, sess, nb_total: int = 0) -> None: - """Sanction cards — bordure gauche rouge, liens natifs .""" - st.subheader("🚨 Avis de sanction — quota atteint") - st.error(f"{nb_total} apprenti(s) en avis de sanction") - - _CARD = ( - "display:flex;align-items:center;flex-wrap:wrap;gap:10px;" - "background:#fff;border:1px solid #f5c6cb;border-left:4px solid #dc3545;" - "border-radius:8px;padding:10px 14px;margin:3px 0;" - ) - _META = "font-size:0.75rem;color:#999;margin-top:3px" - _BADGE = ( - "background:#ffcccc;color:#B71C1C;font-weight:700;" - "padding:2px 10px;border-radius:10px;font-size:0.82em;white-space:nowrap" - ) - _NAME = ( - "font-size:1.05rem;font-weight:700;text-decoration:none;" - "color:inherit" - ) - _CLS_LINK = "color:#999;text-decoration:none" - _lbl_dl = "Télécharger l'avis" - _lbl_regen = "🔄 Régénérer" - _lbl_gen = "📄 Générer l'avis" - - st.markdown('
', unsafe_allow_html=True) - for _, row in df_quota.iterrows(): - aid = int(row["_id"]) - exp = last_exports.get(aid) - fname = ( - f"Sanction_{row['Nom']}_{row['Prénom']}_" - f"{datetime.now().strftime('%Y%m%d')}.pdf" - ) - _cls_enc = _url_quote(row["Classe"]) - _cls_txt = ( - f'' - f'{row["Classe"]}' - ) - if exp is not None: - _nb_info = f" · {exp.nb_absences} abs." if exp.nb_absences is not None else "" - _meta_txt = ( - f'{_cls_txt}  ·  Avis généré le ' - f'{exp.date_export.strftime("%d.%m.%Y %H:%M")}' - f' par {exp.exported_by}{_nb_info}' - ) - else: - _meta_txt = _cls_txt - - # Prépare PDF + boutons action tout en HTML (tout dans la card) - _pdf_data = None - if _SANCTION_TPL.exists() and exp is not None: - _pdf_data = _fill_sanction_pdf(row["Nom"], row["Prénom"], row["Classe"]) - - _btns_html = "" - if _SANCTION_TPL.exists(): - if exp is not None: - _dl = _pdf_dl_link(_pdf_data, fname, _lbl_dl) if _pdf_data else "" - _btns_html = ( - f'
' - f'{_dl}' - f'{_lbl_regen}
' - ) - else: - _btns_html = ( - f'
' - f'{_lbl_gen}
' - ) - - st.markdown( - f'
' - f'
' - f'
' - f' ' - f'{row["Nom"]} {row["Prénom"]}' - f' 🔴 {row["Absences"]} abs.' - f'
' - f'
{_meta_txt}
' - f'
' - f'{_btns_html}' - f'
', - unsafe_allow_html=True, - ) - - st.markdown('
', unsafe_allow_html=True) - - -def page_accueil(): - st.title("Tableau de bord") - if _semestre_actif: - st.caption(f"Filtre actif : {_semestre_actif}") - - sess = _session() - try: - kpi = stats.kpis(sess, semestre=_semestre_actif) - - st.markdown( - f'
' - f'
' - f'
Absences ce mois
' - f'
{kpi["total_ce_mois"]}
' - f'
' - f'
Total
' - f'
{kpi["total_global"]}
' - f'
' - f'
À traiter
' - f'
{kpi["n_a_traiter"]}
' - f'
', - unsafe_allow_html=True, - ) - - st.divider() - - df_quota = stats.alertes_quota_absences(sess, seuil=5, semestre=_semestre_actif) - - if df_quota.empty: - st.success("Aucun apprenti n'a atteint le quota de 5 absences.") - else: - # Dernière génération par apprenti - _last_exports: dict[int, SanctionExport] = {} - for _sx in sess.execute(select(SanctionExport)).scalars().all(): - prev = _last_exports.get(_sx.apprenti_id) - if prev is None or _sx.date_export > prev.date_export: - _last_exports[_sx.apprenti_id] = _sx - - _render_sanction_table(df_quota, _last_exports, sess, len(df_quota)) - - st.divider() - - # ── Notes insuffisantes (BN < 4.0 ou Matu < 4.0) ──────────────────── - st.subheader("📉 Notes insuffisantes (BN / Matu < 4.0)") - - try: - # ── BN : latest per apprenti ────────────────────────────────────── - all_bn = sess.execute( - select(Apprenti, NotesBulletin, ImportBN) - .join(NotesBulletin, NotesBulletin.apprenti_id == Apprenti.id) - .join(ImportBN, ImportBN.id == NotesBulletin.import_id) - ).all() - - latest_bn: dict[int, tuple] = {} - for apprenti, bn, imp in all_bn: - prev = latest_bn.get(apprenti.id) - if prev is None or imp.date_import > prev[2].date_import: - latest_bn[apprenti.id] = (apprenti, bn, imp) - - # ── Matu : latest per apprenti ──────────────────────────────────── - all_matu_rows = sess.execute( - select(Apprenti, NotesMatu, ImportMatu) - .join(NotesMatu, NotesMatu.apprenti_id == Apprenti.id) - .join(ImportMatu, ImportMatu.id == NotesMatu.import_id) - ).all() - - latest_matu: dict[int, tuple] = {} - for apprenti, nm, imp in all_matu_rows: - prev = latest_matu.get(apprenti.id) - if prev is None or imp.date_import > prev[2].date_import: - latest_matu[apprenti.id] = (apprenti, nm, imp) - - # ── Build insuff set ────────────────────────────────────────────── - # key: apprenti.id → (apprenti, bn_data|None, sem_labels|None, grp_order|None, nm|None) - insuff: dict[int, tuple] = {} - - for apprenti, bn, imp in latest_bn.values(): - d = json.loads(bn.donnees_json) - sem_labels = json.loads(bn.sem_labels_json) - grp_order = _GROUP_ORDER.get(bn.type_classe, ["BP"]) - all_series = list(d["groupes"].values()) + [d["globale"]] - last_sem = max( - (i for s in all_series for i, v in enumerate(s.get("moy_sem", [])) if v is not None), - default=-1, - ) - last_ann = max( - (i for s in all_series for i, v in enumerate(s.get("moy_ann", [])) if v is not None), - default=-1, - ) - has_insuff = any( - (last_sem >= 0 and last_sem < len(s.get("moy_sem", [])) - and s["moy_sem"][last_sem] is not None and s["moy_sem"][last_sem] < 4.0) - or - (last_ann >= 0 and last_ann < len(s.get("moy_ann", [])) - and s["moy_ann"][last_ann] is not None and s["moy_ann"][last_ann] < 4.0) - for s in all_series - ) - if has_insuff: - _me = latest_matu.get(apprenti.id) - nm = _me[1] if _me else None - insuff[apprenti.id] = (apprenti, d, sem_labels, grp_order, nm) - - for apprenti, nm, imp in latest_matu.values(): - if nm.moy is not None and nm.moy < 4.0 and apprenti.id not in insuff: - # Try to attach BN data even if BN wasn't insufficient - _be = latest_bn.get(apprenti.id) - if _be: - _, _bn, _ = _be - _d = json.loads(_bn.donnees_json) - _sl = json.loads(_bn.sem_labels_json) - _go = _GROUP_ORDER.get(_bn.type_classe, ["BP"]) - else: - _d = _sl = _go = None - insuff[apprenti.id] = (apprenti, _d, _sl, _go, nm) - - if not insuff: - st.success("Aucune note insuffisante dans le dernier semestre importé.") - else: - sorted_insuff = sorted(insuff.values(), key=lambda x: (x[0].classe, x[0].nom)) - st.error( - f"{len(sorted_insuff)} apprenti(e)s avec note(s) insuffisante(s)" - ) - _SECT = ( - 'font-size:1.15rem;font-weight:700;padding:8px 0 4px;' - 'border-bottom:2px solid #e8eaf6;margin:16px 0 8px' - ) - for ap, d, sem_labels, grp_order, nm in sorted_insuff: - _cls_enc = _url_quote(ap.classe) - st.markdown( - f'
' - f'' - f'{ap.prenom} {ap.nom}' - f'' - f'{ap.classe}
', - unsafe_allow_html=True, - ) - html = "" - if d: - html += _bn_html_table("Cours professionnels", d, sem_labels, grp_order) - if nm: - html += _matu_html_table(nm) - if html: - st.markdown(html, unsafe_allow_html=True) - - except Exception as _e: - st.info("Importez des bulletins de notes pour voir les notes insuffisantes.") - - - finally: - sess.close() - - -# ── Page : Import PDF ───────────────────────────────────────────────────────── - -def page_import(): - st.title("Import PDF") - - uploaded = st.file_uploader( - "Glisser-déposer un PDF ou cliquer pour choisir", type="pdf" - ) - - if uploaded: - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - dest = PDFS_DIR / f"{ts}_{uploaded.name}" - dest.write_bytes(uploaded.getvalue()) - - from src.parser import parse_pdf - with st.spinner("Lecture du PDF…"): - data = parse_pdf(dest) - - st.subheader("Aperçu") - c1, c2, c3, c4 = st.columns(4) - c1.metric("Classe", data["classe"]) - c2.metric("Semestre", data["semestre"]) - c3.metric("Apprentis", len(data["apprentis"])) - c4.metric( - "Absences détectées", - sum(len(a["absences"]) for a in data["apprentis"]), - ) - - st.dataframe( - pd.DataFrame([ - { - "Nom": a["nom"], - "Prénom": a["prenom"], - "Total": len(a["absences"]), - "Excusées": sum(1 for ab in a["absences"] if ab["type_absence"] == "E"), - "NON excusées": sum(1 for ab in a["absences"] if ab["type_absence"] == "N"), - } - for a in data["apprentis"] - ]), - use_container_width=True, - hide_index=True, - ) - - if st.button("✅ Importer en base", type="primary"): - with st.spinner("Import en cours…"): - sess = _session() - try: - result = do_import(dest, sess, imported_by=_current_user) - st.session_state["_import_result"] = result - except Exception as e: - st.session_state.pop("_import_result", None) - st.error(f"Erreur : {e}") - dest.unlink(missing_ok=True) - finally: - sess.close() - - if "_import_result" in st.session_state: - _r = st.session_state["_import_result"] - _parts = [f"**{_r.nb_absences_nouvelles}** nouvelles"] - if _r.nb_absences_mises_a_jour: - _parts.append(f"**{_r.nb_absences_mises_a_jour}** mises à jour (N→E)") - _parts.append(f"**{_r.nb_absences_doublons}** doublons ignorés") - st.success("Import terminé — " + ", ".join(_parts) + ".") - - _show_warn = st.checkbox("Afficher les avertissements", key="show_import_warnings") - if _show_warn: - if _r.details_nouvelles: - st.info( - f"**Nouvelles absences ({_r.nb_absences_nouvelles}) :**\n\n" - + "\n\n".join(f"• {d}" for d in _r.details_nouvelles) - ) - if _r.details_mises_a_jour: - st.warning( - f"**Mises à jour N→E ({_r.nb_absences_mises_a_jour}) :**\n\n" - + "\n\n".join(f"• {d}" for d in _r.details_mises_a_jour) - ) - - st.divider() - st.subheader("Historique des imports") - sess = _session() - try: - imports = sess.execute( - select(Import).order_by(Import.date_import.desc()) - ).scalars().all() - if not imports: - st.info("Aucun import encore.") - else: - st.dataframe( - pd.DataFrame([{ - "Date": i.date_import.strftime("%d.%m.%Y %H:%M"), - "Fichier": i.fichier, - "Classe": i.classe, - "Semestre": i.semestre, - "Apprentis": i.nb_apprentis, - "Nouvelles": i.nb_absences_nouvelles, - "Doublons": i.nb_absences_doublons, - "Par": i.imported_by, - } for i in imports]), - use_container_width=True, - hide_index=True, - ) - finally: - sess.close() - - -# ── Page : À traiter ────────────────────────────────────────────────────────── - -def page_a_traiter(): - st.title("À traiter") - if _semestre_actif: - st.caption(f"Filtre actif : {_semestre_actif}") - - sess = _session() - try: - q = ( - select( - Apprenti.id.label("apprenti_id"), - Apprenti.nom, - Apprenti.prenom, - Apprenti.classe, - Absence.date, - func.count(Absence.id).label("nb"), - ) - .join(Apprenti) - .where( - Absence.type_origine == "N", - Absence.statut == "a_traiter", - ) - .group_by(Apprenti.id, Absence.date) - .order_by(Apprenti.nom, Absence.date) - ) - sem_clause = _sem_filter(sess, _semestre_actif) - if sem_clause is not None: - q = q.where(sem_clause) - rows = sess.execute(q).all() - - if not rows: - st.success("Tout est traité ✓") - return - - total = sum(r.nb for r in rows) - st.caption(f"{len(rows)} journée(s) — {total} période(s) NON excusées à traiter") - - f1, f2 = st.columns(2) - apprentis_dispo = sorted(set( - f"{r.nom} {r.prenom} ({r.classe})" for r in rows - )) - filtre_apprenti = f1.selectbox("Filtrer par apprenti", ["Tous"] + apprentis_dispo) - classes_dispo = sorted(set(r.classe for r in rows)) - filtre_classe = f2.selectbox("Filtrer par classe", ["Toutes"] + classes_dispo) - - df = pd.DataFrame([{ - "_key": f"{row.apprenti_id}__{row.date.isoformat()}", - "Sélectionner": False, - "Nom": row.nom, - "Prénom": row.prenom, - "Classe": row.classe, - "Date": row.date.strftime("%d.%m.%Y"), - "NON excusées": row.nb, - } for row in rows]).set_index("_key") - - if filtre_apprenti != "Tous": - mask = ( - df["Nom"] + " " + df["Prénom"] + " (" + df["Classe"] + ")" - ) == filtre_apprenti - df = df[mask] - if filtre_classe != "Toutes": - df = df[df["Classe"] == filtre_classe] - - edited = st.data_editor( - df, - column_config={ - "Sélectionner": st.column_config.CheckboxColumn("✓", default=False, width="small"), - "Nom": st.column_config.TextColumn(disabled=True), - "Prénom": st.column_config.TextColumn(disabled=True), - "Classe": st.column_config.TextColumn(disabled=True), - "Date": st.column_config.TextColumn(disabled=True), - "NON excusées": st.column_config.NumberColumn(disabled=True, width="small"), - }, - use_container_width=True, - hide_index=True, - num_rows="fixed", - ) - - selected = edited[edited["Sélectionner"]] - if not selected.empty: - if st.button( - f"✅ Excuser les {len(selected)} journée(s) sélectionnée(s)", - type="primary", - ): - for key in selected.index: - apprenti_id_str, date_str = key.split("__") - abs_date = date.fromisoformat(date_str) - abs_list = sess.execute( - select(Absence).where( - Absence.apprenti_id == int(apprenti_id_str), - Absence.date == abs_date, - Absence.type_origine == "N", - Absence.statut == "a_traiter", - ) - ).scalars().all() - for ab in abs_list: - ab.statut = "excusee" - ab.updated_by = _current_user - upsert_escada_pending(sess, ab.apprenti_id, ab.date, ab.periode, "E") - sess.commit() - st.rerun() - finally: - sess.close() - - -# ── Page : Fiche apprenti ───────────────────────────────────────────────────── - -_MOIS_FR = [ - "Janvier", "Février", "Mars", "Avril", "Mai", "Juin", - "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre", -] - -_CHOIX_PERIODE = ["Présent", "E (Excusée)", "N (NON excusée)"] - - -def _parse_notes_pdf(pdf_path: Path, nom: str, prenom: str) -> "list[dict] | None": - """Parse le PDF de notes d'examen, retourne les branches+examens de l'apprenti.""" - import re as _re - try: - import pdfplumber as _pp - except ImportError: - return None - - _RE_BR = _re.compile(r"^(.+?)\s+[A-Z][A-Z0-9\-]+(?: \d+)?\s+\((\d+\.\d+)\)\s+(\d+\.\d+)$") - _RE_DT = _re.compile(r"^(\d{2}\.\d{2}\.\d{4})\s+(.+)$") - - def _exam(line): - m = _RE_DT.match(line) - if not m: return None - date, rest = m.group(1), m.group(2).strip() - tok = rest.split(); note = None; disp = False; i = len(tok) - 1 - if i >= 0 and _re.match(r"^\d+\.\d+$", tok[i]): - note = float(tok[i]); i -= 1 - elif i >= 0 and tok[i].lower() in ("disp.", "disp"): - note = "disp."; i -= 1 - if i >= 0 and tok[i].lower() == "x": - disp = True; i -= 1 - typ = tok[i] if i >= 0 else ""; i -= 1 - coeff = None - if i >= 0 and _re.match(r"^\d+\.\d+$", tok[i]): - coeff = float(tok[i]); i -= 1 - ens = "" - if i >= 0 and _re.match(r"^[A-Z]{3,8}$", tok[i]): - ens = tok[i]; i -= 1 - desc = " ".join(tok[:i+1]) - if coeff is None and not ens: return None - return {"date": date, "description": desc, "enseignant": ens, - "coefficient": coeff, "type": typ, "dispensed": disp, "note": note} - - _SKIP = {"Departement", "Service", "Ecole professionnelle", "Chemin", - "Case postale", "Rue", "Monthey", "Sion", "Seite", "Liste interm", - "Classe:", "Absences", "Matiere", "Date Examen", - "Branches de culture", "Branches professionnelles"} - try: - nom_l = nom.lower(); prenom_l = prenom.lower() - with _pp.open(pdf_path) as pdf: - pages = [ - p.extract_text(x_tolerance=2) or "" - for p in pdf.pages - if nom_l in (p.extract_text(x_tolerance=2) or "").lower() - and prenom_l in (p.extract_text(x_tolerance=2) or "").lower() - ] - if not pages: return None - lines = [l.strip() for l in "\n".join(pages).splitlines() if l.strip()] - branches: list[dict] = []; cur = None - for line in lines: - if any(kw in line for kw in _SKIP): continue - if _re.match(r"^\d{4}$", line): continue - m = _RE_BR.match(line) - if m: - cur = {"branche": m.group(1).strip(), - "moy_prov": float(m.group(2)), - "moy_arr": float(m.group(3)), "examens": []} - branches.append(cur); continue - if cur and _re.match(r"^\d{2}\.\d{2}\.\d{4}", line): - e = _exam(line) - if e: cur["examens"].append(e) - return branches or None - except Exception: - return None - - -def _render_notes_html(notes_data: list[dict]) -> str: - """Génère le HTML groupé par branche pour l'affichage des notes d'examen.""" - html = ( - '
' - ) - for _br in notes_data: - _moy = _br.get("moy_arr") - _moy_prov = _br.get("moy_prov") - _insuf = _moy is not None and float(_moy) < 4.0 - _mc = "#c62828" if _insuf else ("#e65100" if _moy and float(_moy) < 5.0 else "#2e7d32") - _br_name = ("⚠️ " if _insuf else "") + _br["branche"] - _moy_html = ( - f'{_moy}' - + (f' ({_moy_prov})' - if _moy_prov is not None else "") - ) if _moy is not None else "—" - html += ( - f'
' - f'{_br_name}' - f'Moyenne : {_moy_html}
' - '' - '' - ) - for _ex in _br.get("examens", []): - _n = _ex["note"] - if _n is None: - _note_html = '' - elif _n == "disp.": - _note_html = 'disp.' - else: - _nc = "#c62828" if float(_n) < 4.0 else ("#e65100" if float(_n) < 5.0 else "#2e7d32") - _disp_tag = ' [disp.]' if _ex.get("dispensed") else "" - _note_html = f'{_n}{_disp_tag}' - html += ( - f'' - f'' - f'' - f'' - f'' - f'' - ) - html += "
DateExamenEnseignantCoeffTypeNote
{_ex["date"]}{_ex["description"]}{_ex["enseignant"]}{_ex["coefficient"] or ""}{_ex["type"]}{_note_html}
" - html += "
" - return html - - -@st.cache_data(show_spinner=False) -def _extract_bn_pages_cached(pdf_path_str: str, mtime: float, nom: str, prenom: str) -> bytes | None: - try: - import pdfplumber - from pypdf import PdfReader, PdfWriter - - nom_n = nom.lower().strip() - prenom_n = prenom.lower().strip() - - matching: list[int] = [] - with pdfplumber.open(pdf_path_str) as pdf: - for i, page in enumerate(pdf.pages): - text = (page.extract_text() or "").lower() - if nom_n in text and prenom_n in text: - matching.append(i) - - if not matching: - return None - - reader = PdfReader(pdf_path_str) - writer = PdfWriter() - for i in matching: - writer.add_page(reader.pages[i]) - - buf = io.BytesIO() - writer.write(buf) - return buf.getvalue() - except Exception: - return None - - -def _extract_bn_pages(pdf_path: Path, nom: str, prenom: str) -> bytes | None: - """Extrait du PDF BN les pages contenant nom+prénom. Résultat mis en cache par mtime.""" - _mtime = pdf_path.stat().st_mtime if pdf_path.exists() else 0.0 - return _extract_bn_pages_cached(str(pdf_path), _mtime, nom, prenom) - - -def page_fiche(): - st.title("Fiche apprenti") - if _semestre_actif: - st.caption(f"Filtre actif : {_semestre_actif}") - - sess = _session() - try: - apprentis = sess.execute( - select(Apprenti).order_by(Apprenti.nom, Apprenti.prenom) - ).scalars().all() - - if not apprentis: - st.info("Aucun apprenti en base. Faites d'abord un import PDF.") - return - - _prefill_id = st.session_state.pop("_prefill_fiche", None) - if _prefill_id is not None: - _match = next((a for a in apprentis if a.id == _prefill_id), None) - if _match: - st.session_state["fiche_apprenti_sel"] = _match - - choix = st.selectbox( - "Apprenti", - options=apprentis, - format_func=lambda a: f"{a.nom} {a.prenom} ({a.classe})", - key="fiche_apprenti_sel", - ) - - q_abs = ( - select(Absence) - .where(Absence.apprenti_id == choix.id) - .order_by(Absence.date, Absence.periode) - ) - sem_clause = _sem_filter(sess, _semestre_actif) - if sem_clause is not None: - q_abs = q_abs.where(sem_clause) - absences = sess.execute(q_abs).scalars().all() - - # ── Fiche détaillée (Escada) ────────────────────────────────────────── - _fiche = sess.execute( - select(ApprentiFiche).where(ApprentiFiche.apprenti_id == choix.id) - ).scalar_one_or_none() - - with st.expander("📋 Fiche détaillée", expanded=False): - if _fiche is None: - st.info( - "Aucune fiche disponible. Cochez **👤 Fiches apprentis** " - "lors de la prochaine synchronisation Escada." - ) - else: - _f_col1, _f_col2, _f_col3 = st.columns(3) - - with _f_col1: - st.markdown("**Élève**") - if _fiche.adresse: - st.write(_fiche.adresse) - if _fiche.code_postal or _fiche.localite: - st.write(f"{_fiche.code_postal or ''} {_fiche.localite or ''}".strip()) - if _fiche.telephone: - st.write(f"📞 {_fiche.telephone}") - if _fiche.email: - st.write(f"✉️ {_fiche.email}") - if _fiche.date_naissance: - st.write(f"🎂 {_fiche.date_naissance}") - if _fiche.majeur is not None: - st.write(f"Majeur : {'oui' if _fiche.majeur else 'non'}") - - with _f_col2: - st.markdown("**Entreprise**") - if _fiche.entreprise_nom: - st.write(_fiche.entreprise_nom) - if _fiche.entreprise_adresse: - st.write(_fiche.entreprise_adresse) - if _fiche.entreprise_code_postal or _fiche.entreprise_localite: - st.write(f"{_fiche.entreprise_code_postal or ''} {_fiche.entreprise_localite or ''}".strip()) - if _fiche.entreprise_telephone: - st.write(f"📞 {_fiche.entreprise_telephone}") - if _fiche.entreprise_email: - st.write(f"✉️ {_fiche.entreprise_email}") - - with _f_col3: - st.markdown("**Formateur**") - if _fiche.formateur_nom: - st.write(_fiche.formateur_nom) - if _fiche.formateur_email: - st.write(f"✉️ {_fiche.formateur_email}") - - st.caption(f"Mis à jour le {_fiche.updated_at.strftime('%d.%m.%Y %H:%M')} depuis Escada") - - # ── Bulletin de notes ───────────────────────────────────────────────── - _bn_pdf_email: bytes | None = None - _notes_pdf_email: bytes | None = None - - _notes_pdf_path = PDFS_DIR / f"notes_{choix.classe.replace(' ', '_')}.pdf" - if _notes_pdf_path.exists(): - _notes_pdf_email = _extract_bn_pages(_notes_pdf_path, choix.nom, choix.prenom) - - st.divider() - - bn_records = sess.execute( - select(NotesBulletin, ImportBN) - .join(ImportBN, ImportBN.id == NotesBulletin.import_id) - .where(NotesBulletin.apprenti_id == choix.id) - .order_by(ImportBN.date_import.desc()) - ).all() - - import base64 as _b64 - _pdf_svg = ( - '' - ) - _link_style = ( - 'display:inline-flex;align-items:center;gap:4px;' - 'padding:3px 8px;border:1px solid #ddd;border-radius:5px;' - 'text-decoration:none;color:#333;font-size:0.8em;' - 'white-space:nowrap;flex-shrink:0' - ) - _bn_link = "" - _notes_link = "" - _cap_txt = "" - - if bn_records: - _bn0, _imp0 = bn_records[0] - _cap_txt = ( - f"Import BN du {_imp0.date_import.strftime('%d.%m.%Y %H:%M')}" - f" — {_imp0.imported_by}" - ) - _pdf_path = PDFS_DIR / _imp0.fichier - if _pdf_path.exists(): - _pdf_bytes = _extract_bn_pages(_pdf_path, choix.nom, choix.prenom) - _bn_pdf_email = _pdf_bytes - if _pdf_bytes: - _b64data = _b64.b64encode(_pdf_bytes).decode() - _filename = f"BN_{choix.nom}_{choix.prenom}.pdf" - _bn_link = ( - f'' - f' {_pdf_svg.replace("{color}", "#E53935")} PDF BN' - ) - - if _notes_pdf_email: - _nb64 = _b64.b64encode(_notes_pdf_email).decode() - _nfn = f"Notes_{choix.nom}_{choix.prenom}.pdf" - _notes_link = ( - f'' - f' {_pdf_svg.replace("{color}", "#1565C0")} PDF Notes' - ) - - if _bn_link or _notes_link: - st.markdown( - '
' - + _bn_link + _notes_link + "
", - unsafe_allow_html=True, - ) - - _tab_bn, _tab_notes = st.tabs(["📊 Cours professionnels", "📝 Notes d'examen"]) - - with _tab_bn: - if not bn_records: - st.info("Aucun bulletin de notes importé pour cet(te) apprenti(e).") - else: - bn, imp = bn_records[0] - sem_labels = json.loads(bn.sem_labels_json) - d = json.loads(bn.donnees_json) - type_classe = bn.type_classe - groups_order = _GROUP_ORDER.get(type_classe, ["BP"]) - html_bn = _bn_html_table("Cours professionnels", d, sem_labels, groups_order) - nm = _latest_matu(sess, choix.id) - if nm: - html_bn += _matu_html_table(nm) - st.markdown(html_bn, unsafe_allow_html=True) - - with _tab_notes: - _ne_rec = sess.execute( - select(NotesExamen).where(NotesExamen.apprenti_id == choix.id) - ).scalar_one_or_none() - _notes_data = json.loads(_ne_rec.donnees_json) if _ne_rec else None - if not _notes_data: - st.info( - "Aucune note d'examen disponible. " - "Lancez une synchronisation Escada avec l'option **Notes**." - ) - else: - st.markdown(_render_notes_html(_notes_data), unsafe_allow_html=True) - - # ── Récapitulatif des absences ──────────────────────────────────────── - st.divider() - - # KPIs basés sur le statut (pas type_origine) - nb_excusees = sum(1 for a in absences if a.statut == "excusee") - nb_non_excusees = sum(1 for a in absences if a.statut == "a_traiter") - nb_blocs = stats.nb_blocs_absences(sess, choix.id, semestre=_semestre_actif) - QUOTA = 5 - - _delta_v = nb_blocs - QUOTA - _delta_c = "#2e7d32" if _delta_v < 0 else "#c62828" - _delta_a = "↓" if _delta_v < 0 else "↑" - st.markdown( - f'
' - f'
' - f'
Total périodes
' - f'
{len(absences)}
' - f'
' - f'
Excusées
' - f'
{nb_excusees}
' - f'
' - f'
NON excusées
' - f'
{nb_non_excusees}
' - f'
' - f'
Nombre d\'absences
' - f'
{nb_blocs}
' - f'
{_delta_a} {_delta_v} / quota
' - f'
', - unsafe_allow_html=True, - ) - - if nb_blocs >= QUOTA: - st.error(f"🚨 Avis de sanction — {nb_blocs} absences sur {QUOTA} autorisées") - - _pdf_abs = _absence_pdf_apprenti(sess, choix, _semestre_actif) - st.markdown( - f'
' - f'{_pdf_dl_link(_pdf_abs, f"Absences_{choix.nom}_{choix.prenom}.pdf", "Tableau des absences")}' - f'
', - unsafe_allow_html=True, - ) - - # ── Envoi email ─────────────────────────────────────────────────────── - _email_dest = _fiche.email if _fiche else None - _se_email = _load_settings() - _user_login = _se_email.get("smtp_login", _se_email.get("smtp_email", "")) - _user_smtp_pwd = _se_email.get("smtp_password", "") - _user_sender = _se_email.get("smtp_sender", "") - _email_cfg_ok = bool(_user_login and _user_smtp_pwd and _user_sender) - - st.divider() - st.subheader("✉️ Envoyer par email") - - if not _email_cfg_ok: - st.warning("Email ou app password non configuré. Rendez-vous dans **🔧 Paramètres**.") - else: - from src.email_sender import build_template_vars, render_template, send_email as _send_email - _se = _load_settings() - _smtp_host = _se.get("smtp_host", "smtp-relay.brevo.com") - _smtp_port = int(_se.get("smtp_port", 587)) - _smtp_login = _se.get("smtp_login", _se.get("smtp_email", "")) - _smtp_sender = _se.get("smtp_sender", "") - _tvars = build_template_vars(choix, absences, _semestre_actif or "") - - _em_left, _em_right = st.columns([1, 2]) - - with _em_left: - # ── Destinataire ────────────────────────────────────────────── - st.markdown("**Destinataire**") - _ap_email = (_fiche.email if _fiche else None) or "" - _fmt_email = (_fiche.formateur_email if _fiche else None) or "" - - _dest_opts = [] - _dest_labels = [] - if _ap_email: - _dest_opts.append("apprenti") - _dest_labels.append(f"Apprenti — {_ap_email}") - if _fmt_email: - _dest_opts.append("formateur") - _dest_labels.append(f"Formateur — {_fmt_email}") - _dest_opts.append("autre") - _dest_labels.append("Autre") - - _dest_choice = st.radio( - "Destinataire", options=_dest_opts, - format_func=lambda o: _dest_labels[_dest_opts.index(o)], - key="em_dest_radio", label_visibility="collapsed", - ) - _dest_custom = st.text_input( - "Adresse(s) email (séparées par des virgules)", - key="em_dest_custom", label_visibility="collapsed", - placeholder="email1@ex.com, email2@ex.com", - disabled=(_dest_choice != "autre"), - ) - - st.markdown("**Documents à joindre**") - _chk_abs = st.checkbox( - "Tableau des absences", value=True, key="em_chk_abs", - ) - _chk_bn = st.checkbox( - "Bulletin de notes" + ("" if _bn_pdf_email else " (indisponible)"), - key="em_chk_bn", disabled=not bool(_bn_pdf_email), - ) - _chk_notes = st.checkbox( - "Liste des notes" + ("" if _notes_pdf_email else " (indisponible)"), - key="em_chk_notes", disabled=not bool(_notes_pdf_email), - ) - - with _em_right: - # ── Message ─────────────────────────────────────────────────── - _def_subj = "Relevé d'absences — {nom_complet} ({classe})" - _def_body = "Bonjour {prenom},\n\nVeuillez trouver ci-joint votre document.\n\nCordialement,\nL'équipe EPTM" - _subj_tpl = render_template(_se.get("email_subject", _def_subj), _tvars) - _body_tpl = render_template(_se.get("email_body", _def_body), _tvars) - - _em_subj = st.text_input("Objet", value=_subj_tpl, key="em_subj") - _em_body = st.text_area("Message", value=_body_tpl, height=180, key="em_body") - _btn_send = st.button("📤 Envoyer", type="primary", key="em_send", use_container_width=True) - - # ── Traitement de l'envoi ───────────────────────────────────────── - if _btn_send: - # Résoudre les destinataires - if _dest_choice == "apprenti": - _recipients = [_ap_email] - elif _dest_choice == "formateur": - _recipients = [_fmt_email] - else: - _recipients = [e.strip() for e in _dest_custom.split(",") if e.strip()] - - # Construire la liste des pièces jointes - _attachments = [] - if _chk_abs: - _attachments.append((_pdf_abs, f"Absences_{choix.nom}_{choix.prenom}.pdf")) - if _chk_bn and _bn_pdf_email: - _attachments.append((_bn_pdf_email, f"BN_{choix.nom}_{choix.prenom}.pdf")) - if _chk_notes and _notes_pdf_email: - _attachments.append((_notes_pdf_email, f"Notes_{choix.nom}_{choix.prenom}.pdf")) - - if not _recipients: - st.error("Aucune adresse email valide.") - elif not _attachments: - st.error("Sélectionnez au moins un document à joindre.") - else: - _ok = _fail = 0 - for _to in _recipients: - try: - _send_email( - smtp_host=_smtp_host, smtp_port=_smtp_port, - smtp_login=_smtp_login, smtp_password=_user_smtp_pwd, - smtp_sender=_smtp_sender, - to_email=_to, subject=_em_subj, body=_em_body, - attachments=_attachments, - ) - _ok += 1 - except Exception as _e: - st.error(f"Échec vers {_to} : {_e}") - _fail += 1 - if _ok: - _dest_str = ", ".join(_recipients) - st.success(f"Email envoyé à {_dest_str}") - - if not absences: - st.info("Aucune absence enregistrée.") - return - - st.divider() - - # ── Navigation mensuelle ────────────────────────────────────────────── - yk = f"cal_y_{choix.id}" - mk = f"cal_m_{choix.id}" - if yk not in st.session_state: - st.session_state[yk] = absences[0].date.year - st.session_state[mk] = absences[0].date.month - - yr = st.session_state[yk] - mo = st.session_state[mk] - - st.markdown( - f"

" - f"{_MOIS_FR[mo - 1]} {yr}

", - unsafe_allow_html=True, - ) - if st.button("◀ " + _MOIS_FR[(mo - 2) % 12].capitalize(), key=f"prev_{choix.id}", use_container_width=True): - if mo == 1: - st.session_state[mk] = 12 - st.session_state[yk] = yr - 1 - else: - st.session_state[mk] = mo - 1 - st.rerun() - if st.button(_MOIS_FR[mo % 12].capitalize() + " ▶", key=f"next_{choix.id}", use_container_width=True): - if mo == 12: - st.session_state[mk] = 1 - st.session_state[yk] = yr + 1 - else: - st.session_state[mk] = mo + 1 - st.rerun() - - # ── Calendrier ──────────────────────────────────────────────────────── - abs_by_date: dict[date, list] = {} - for ab in absences: - abs_by_date.setdefault(ab.date, []).append(ab) - - today_d = date.today() - edit_key = f"edit_day_{choix.id}" - - # ── Calendrier (grille cliquable) ───────────────────────────────────── - sel_key = f"cal_sel_{choix.id}" - if sel_key not in st.session_state: - _abs_mo = [ab for ab in absences if ab.date.year == yr and ab.date.month == mo] - st.session_state[sel_key] = _abs_mo[0].date if _abs_mo else date(yr, mo, 1) - - _DOW = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"] - - st.markdown("""""", unsafe_allow_html=True) - - # Sur mobile : force les colonnes Streamlit en pile via JS - # (CSS !important ne peut pas écraser les styles inline de Streamlit) - st.components.v1.html("""""", height=0) - - st.markdown( - '
' - + "".join( - f'
{d}
' - for d in _DOW - ) - + "
", - unsafe_allow_html=True, - ) - - for _week in _cal.monthcalendar(yr, mo): - _wcols = st.columns(7) - for _i, _dn in enumerate(_week): - if _dn == 0: - _wcols[_i].write("") - continue - _d = date(yr, mo, _dn) - _dabs = abs_by_date.get(_d, []) - _nb_n = sum(1 for ab in _dabs if ab.statut in ("a_traiter", "non_excusee")) - _nb_e = sum(1 for ab in _dabs if ab.statut == "excusee") - _is_edit = (st.session_state.get(edit_key) == _d) - - _num = f"⭕{_dn}" if _d == today_d else str(_dn) - if _nb_n and _nb_e: - _ind = f"🔴{_nb_n} 🟢{_nb_e}" - elif _nb_n: - _ind = f"🔴{_nb_n}" - elif _nb_e: - _ind = f"🟢{_nb_e}" - else: - _ind = "" - - _label = f"{_num} \n{_ind}" if _ind else _num - if _wcols[_i].button( - _label, - key=f"cal_{choix.id}_{_d}", - type="primary" if _is_edit else "secondary", - use_container_width=True, - ): - st.session_state[sel_key] = _d - st.session_state[edit_key] = _d - st.rerun() - - st.markdown( - '
' - '🟢 Excusée  |  🔴 À traiter  |  ⭕ Aujourd\'hui
', - unsafe_allow_html=True, - ) - - # ── Actions rapides : excuser les jours en attente ──────────────────── - _pending = sorted([ - _d for _d, _al in abs_by_date.items() - if any(ab.statut == "a_traiter" for ab in _al) - ]) - if _pending: - st.markdown("**Absences à traiter :**") - _pcols = st.columns(min(len(_pending), 4)) - for _i, _pd in enumerate(_pending): - _nb_p = sum(1 for ab in abs_by_date[_pd] if ab.statut == "a_traiter") - if _pcols[_i % 4].button( - f"✅ {_pd.strftime('%d.%m')} ({_nb_p})", - key=f"exc_{choix.id}_{_pd}", - help=f"Excuser {_nb_p} absence(s) du {_pd.strftime('%d.%m.%Y')}", - use_container_width=True, - ): - for ab in abs_by_date[_pd]: - if ab.statut == "a_traiter": - ab.statut = "excusee" - ab.updated_by = _current_user - upsert_escada_pending(sess, ab.apprenti_id, ab.date, ab.periode, "E") - sess.commit() - st.rerun() - - # ── Panneau d'édition des périodes ──────────────────────────────────── - if edit_key in st.session_state: - st.divider() - ed: date = st.session_state[edit_key] - st.markdown('
', unsafe_allow_html=True) - st.components.v1.html( - '', - height=0, - ) - st.subheader(f"✏️ Absences du {ed.strftime('%d.%m.%Y')}") - - day_abs_edit = abs_by_date.get(ed, []) - period_map = {ab.periode: ab for ab in day_abs_edit} - - def _idx_defaut(p: int) -> int: - ab = period_map.get(p) - if ab is None: - return 0 - return 1 if ab.statut == "excusee" else 2 - - with st.form(f"edit_form_{choix.id}_{ed.isoformat()}"): - selections: dict[int, str] = {} - for p in range(1, 11): - selections[p] = st.radio( - f"P{p}", - _CHOIX_PERIODE, - index=_idx_defaut(p), - horizontal=True, - ) - - sc, cc = st.columns([2, 1]) - save = sc.form_submit_button("💾 Enregistrer", type="primary") - cancel = cc.form_submit_button("❌ Annuler") - - if cancel: - del st.session_state[edit_key] - st.rerun() - - if save: - for p, sel in selections.items(): - existing = period_map.get(p) - if sel == "Présent": - if existing: - upsert_escada_pending(sess, existing.apprenti_id, existing.date, existing.periode, "clear") - sess.delete(existing) - else: - type_o = "E" if sel.startswith("E") else "N" - statut = "excusee" if type_o == "E" else "a_traiter" - if existing: - if existing.type_origine != type_o: - existing.type_origine = type_o - existing.statut = statut - existing.updated_by = _current_user - upsert_escada_pending(sess, existing.apprenti_id, existing.date, existing.periode, type_o) - else: - sess.add(Absence( - apprenti_id = choix.id, - date = ed, - periode = p, - type_origine = type_o, - statut = statut, - updated_by = _current_user, - import_id = None, - )) - upsert_escada_pending(sess, choix.id, ed, p, type_o) - sess.commit() - del st.session_state[edit_key] - st.rerun() - - finally: - sess.close() - - -# ── Page : Esacada ─────────────────────────────────────────────────────────── - -_CLASSES_CACHE = DATA_DIR / "esacada_classes.json" -_SEL_CACHE = DATA_DIR / "esacada_last_sel.json" -_SYNC_SCRIPT = _root / "scripts" / "sync_esacada.py" -_PUSH_SCRIPT = _root / "scripts" / "push_to_escada.py" - - -def _esacada_stream(args: list[str], log_placeholder, timeout: int = 600) -> tuple[str, int]: - """Lance sync_esacada.py et affiche les logs en temps réel dans log_placeholder. - - Retourne (stdout_complet, returncode). - """ - proc = subprocess.Popen( - [sys.executable, str(_SYNC_SCRIPT)] + args, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - encoding="utf-8", - errors="replace", - ) - lines: list[str] = [] - deadline = time.time() + timeout - try: - for line in proc.stdout: - line = line.rstrip() - lines.append(line) - log_placeholder.code("\n".join(lines[-40:]), language=None) - if time.time() > deadline: - proc.kill() - lines.append("ERR Délai dépassé.") - log_placeholder.code("\n".join(lines[-40:]), language=None) - return "\n".join(lines), -1 - proc.wait() - except Exception as _e: - lines.append(f"ERR {_e}") - proc.kill() - return "\n".join(lines), proc.returncode - - -def page_esacada(): - st.title("Synchronisation Esacada") - - # ── Vérification Playwright ─────────────────────────────────────────────── - try: - import playwright # noqa: F401 - except ImportError: - st.error("Playwright n'est pas installé.") - st.code("python -m playwright install chromium", language="bash") - return - - st.markdown( - "Télécharge automatiquement les absences, les bulletins de notes et les notes Matu " - "depuis **escadaweb.vs.ch** et les importe directement en base. " - "La connexion (identifiant, mot de passe, 2FA) est gérée automatiquement." - ) - st.divider() - - # ── Chargement du cache des classes ─────────────────────────────────────── - cached: list[str] = [] - if _CLASSES_CACHE.exists(): - try: - cached = json.loads(_CLASSES_CACHE.read_text(encoding="utf-8")) - except Exception: - pass - - col_info, col_refresh = st.columns([4, 1]) - col_info.caption(f"{len(cached)} classe(s) en cache." if cached else "Aucun cache de classes.") - - if col_refresh.button("🔄 Actualiser la liste", use_container_width=True): - _lc_ph = st.empty() - _lc_out, _lc_rc = _esacada_stream(["--list-classes"], _lc_ph, timeout=360) - if _lc_rc == -1: - st.error("Délai dépassé (6 min).") - return - - new_cached: list[str] = [] - for line in _lc_out.splitlines(): - if line.startswith("CLASSES_JSON:"): - try: - new_cached = json.loads(line[len("CLASSES_JSON:"):]) - _CLASSES_CACHE.write_text( - json.dumps(new_cached, ensure_ascii=False), encoding="utf-8" - ) - except Exception: - pass - - if new_cached: - st.success(f"{len(new_cached)} classes récupérées.") - st.rerun() - else: - st.error("Impossible de récupérer les classes.") - with st.expander("Logs"): - st.code(_lc_out[-2000:]) - return - - if not cached: - st.info("Cliquez sur **Actualiser la liste** pour récupérer les classes depuis Escadaweb.") - return - - st.divider() - - # ── Sélection des classes (mémorisée) ───────────────────────────────────── - last_sel: list[str] = [] - if _SEL_CACHE.exists(): - try: - saved = json.loads(_SEL_CACHE.read_text(encoding="utf-8")) - last_sel = [c for c in saved if c in cached] - except Exception: - pass - - selected = st.multiselect( - "Classes à synchroniser", - options=cached, - default=last_sel, - placeholder="Sélectionnez les classes…", - ) - - if not selected: - st.warning("Sélectionnez au moins une classe.") - return - - col_abs, col_bn, col_notes, col_fiches = st.columns(4) - with col_abs: - sync_abs = st.checkbox("📋 Absences", value=True) - with col_bn: - sync_bn_matu = st.checkbox("📊 BN + Matu", value=True) - with col_notes: - sync_notes = st.checkbox("📝 Notes", value=True) - with col_fiches: - sync_fiches = st.checkbox("👤 Fiches", value=False) - - if not sync_abs and not sync_bn_matu and not sync_notes and not sync_fiches: - st.warning("Sélectionnez au moins un type de synchronisation.") - return - - force_abs = False - if sync_abs: - force_abs = st.checkbox( - "🔄 Forcer la réimportation des absences", - value=False, - help="Réinitialise le statut de toutes les absences existantes " - "d'après les données Escada, même si elles ont été modifiées manuellement. " - "À utiliser après un vidage de données pour repartir de zéro.", - ) - - _types = [] - if sync_abs: - _types.append("absences") - if sync_bn_matu: - _types.append("BN + Matu") - if sync_notes: - _types.append("Notes") - if sync_fiches: - _types.append("fiches") - _types_label = " + ".join(_types) - - if st.button(f"⬇️ Synchroniser {len(selected)} classe(s)", type="primary"): - _SEL_CACHE.write_text(json.dumps(selected, ensure_ascii=False), encoding="utf-8") - _timeout = max(900, len(selected) * 300) - _sync_args = ["--sync-all"] + selected - if not sync_abs: - _sync_args.append("--skip-abs") - if not sync_bn_matu: - _sync_args.append("--skip-bn") - if not sync_notes: - _sync_args.append("--skip-notes") - if not sync_fiches: - _sync_args.append("--skip-fiches") - - # Barre de progression — juste sous le bouton - _imp_bar = st.progress(0.0) - _imp_txt = st.empty() - _imp_txt.caption(f"Synchronisation {len(selected)} classe(s) — {_types_label}…") - - # Logs live dans un placeholder effacé après le sync - _app_log(f"=== SYNC démarré par {_current_user} — {len(selected)} classe(s) [{_types_label}] ===") - _log_ph = st.empty() - _stdout, _rc = _esacada_stream(_sync_args, _log_ph, timeout=_timeout) - _log_ph.empty() # Effacer les logs live — ils seront dans "Logs complets" - - if _rc == -1: - _imp_bar.empty() - _imp_txt.empty() - st.error(f"Délai dépassé ({_timeout // 60} min).") - return - - abs_pdfs, bn_pdfs, matu_pdfs, notes_pdfs, fiches_data, sync_errors = [], [], [], [], {}, [] - for line in _stdout.splitlines(): - if "ALL_DONE " in line: - try: - payload = json.loads(line[line.index("ALL_DONE ") + len("ALL_DONE "):]) - abs_pdfs = payload.get("abs", []) - bn_pdfs = payload.get("bn", []) - matu_pdfs = payload.get("matu", []) - notes_pdfs = payload.get("notes", []) - fiches_data = payload.get("fiches", {}) - sync_errors = payload.get("errors", []) - except Exception: - pass - - _sr: dict = {"abs": [], "bn": [], "matu": [], "notes": [], "fiches": {}, "errors": sync_errors, "stdout": _stdout} - - # ── Progression de l'import ─────────────────────────────────────────── - _total_import = len(abs_pdfs) + len(bn_pdfs) + len(notes_pdfs) + len(matu_pdfs) + len(fiches_data) - _done_import = 0 - if not _total_import: - _imp_bar.empty() - _imp_txt.empty() - - def _imp_before(label: str) -> None: - pct = _done_import / _total_import - _imp_bar.progress(pct) - _imp_txt.caption(f"Import {_done_import + 1}/{_total_import} ({pct:.0%}) — {label}…") - - def _imp_after() -> None: - nonlocal _done_import - _done_import += 1 - _imp_bar.progress(_done_import / _total_import) - - if abs_pdfs: - sess = _session() - try: - for pdf_path in abs_pdfs: - _cls = Path(pdf_path).stem.replace("esacada_", "").replace("_", " ") - _imp_before(f"Absences {_cls}") - try: - res = do_import(Path(pdf_path), sess, imported_by=_current_user, force=force_abs) - _sr["abs"].append(res) - except Exception as e: - _sr["errors"].append(f"Import absences {Path(pdf_path).name} : {e}") - _imp_after() - finally: - sess.close() - - if bn_pdfs: - sess_bn = _session() - try: - for pdf_path in bn_pdfs: - _cls = Path(pdf_path).stem.replace("bn_", "").replace("_", " ") - _imp_before(f"BN {_cls}") - try: - res_bn = do_import_bn(Path(pdf_path), sess_bn, imported_by=_current_user) - _sr["bn"].append({"classe": res_bn.classe, "nb": res_bn.nb_apprentis}) - except Exception as e: - _sr["errors"].append(f"Import BN {Path(pdf_path).name} : {e}") - _imp_after() - finally: - sess_bn.close() - - if notes_pdfs: - sess_ne = _session() - try: - for pdf_path in notes_pdfs: - _ne_path = Path(pdf_path) - _ne_classe = _ne_path.stem.replace("notes_", "").replace("_", " ") - _imp_before(f"Notes {_ne_classe}") - _ne_apprentis = sess_ne.execute( - select(Apprenti).where(Apprenti.classe == _ne_classe) - ).scalars().all() - _ne_ids = [ap.id for ap in _ne_apprentis] - if _ne_ids: - sess_ne.execute( - delete(NotesExamen).where(NotesExamen.apprenti_id.in_(_ne_ids)) - ) - _nb_ne = 0 - for ap in _ne_apprentis: - _branches = _parse_notes_pdf(_ne_path, ap.nom, ap.prenom) - if _branches is None: - continue - sess_ne.add(NotesExamen( - apprenti_id=ap.id, - donnees_json=json.dumps(_branches, ensure_ascii=False), - )) - _nb_ne += 1 - sess_ne.commit() - _sr["notes"].append({"classe": _ne_classe, "nb": _nb_ne}) - _imp_after() - except Exception as e: - _sr["errors"].append(f"Import notes : {e}") - sess_ne.rollback() - finally: - sess_ne.close() - - if matu_pdfs: - sess_matu = _session() - try: - for pdf_path in matu_pdfs: - _cls = Path(pdf_path).stem.replace("matu_", "").replace("_", " ") - _imp_before(f"Matu {_cls}") - try: - res_matu, unmatched = do_import_matu(Path(pdf_path), sess_matu, imported_by=_current_user) - nom_classe = res_matu.classe_mp or _cls - _sr["matu"].append({"classe": nom_classe, "nb": res_matu.nb_apprentis, "unmatched": unmatched}) - except Exception as e: - _sr["errors"].append(f"Import Matu {Path(pdf_path).name} : {e}") - _imp_after() - finally: - sess_matu.close() - - if fiches_data: - sess_f = _session() - try: - nb_fiches = 0 - for cls, fiches_list in fiches_data.items(): - _imp_before(f"Fiches {cls}") - cls_apprentis = sess_f.execute( - select(Apprenti).where(Apprenti.classe == cls) - ).scalars().all() - for fiche in fiches_list: - nom_eleve = fiche.get("nom_eleve", "") - if not nom_eleve: - continue - nom_norm = _norm_prenom(nom_eleve) - ap = None - for a in cls_apprentis: - full_norm = _norm_prenom(f"{a.nom} {a.prenom}") - if full_norm == nom_norm: - ap = a - break - if ap is None: - for a in cls_apprentis: - full_norm = _norm_prenom(f"{a.nom} {a.prenom}") - short, long = ( - (nom_norm, full_norm) if len(nom_norm) <= len(full_norm) - else (full_norm, nom_norm) - ) - if long == short or long.startswith(short + " "): - ap = a - break - if ap: - upsert_apprenti_fiche(sess_f, ap.id, fiche) - nb_fiches += 1 - _imp_after() - sess_f.commit() - _sr["fiches"] = {cls: len(lst) for cls, lst in fiches_data.items()} - _sr["fiches"]["_total"] = nb_fiches - except Exception as e: - _sr["errors"].append(f"Import fiches : {e}") - sess_f.rollback() - finally: - sess_f.close() - - # Résumé final - _imp_parts: list[str] = [] - for _res in _sr["abs"]: - _n_new = _res.nb_absences_nouvelles - _n_maj = _res.nb_absences_mises_a_jour or 0 - _detail = f"{_n_new} nouvelles" + (f", {_n_maj} mises à jour" if _n_maj else "") - _imp_parts.append(f"Absences {_res.classe} ({_detail})") - for _res_bn in _sr["bn"]: - _imp_parts.append(f"BN {_res_bn['classe']} ({_res_bn['nb']} apprenti(e)s)") - for _res_ne in _sr.get("notes", []): - _imp_parts.append(f"Notes {_res_ne['classe']} ({_res_ne['nb']} apprenti(e)s)") - for _res_m in _sr["matu"]: - _imp_parts.append(f"Matu {_res_m['classe']} ({_res_m['nb']} matchés)") - if _sr.get("fiches", {}).get("_total"): - _imp_parts.append(f"Fiches ({_sr['fiches']['_total']} apprenti(e)s)") - if _imp_bar: - _imp_bar.progress(1.0) - if _imp_parts: - _imp_txt.caption("✅ Import terminé — " + " · ".join(_imp_parts)) - elif _total_import: - _imp_txt.caption("✅ Import terminé.") - - # Écrire le manifeste pour l'upload sélectif - _manifest_files = [Path(p).name for p in abs_pdfs + bn_pdfs + matu_pdfs + notes_pdfs] - # La DB est modifiée seulement si des données ont été importées (pas pour les Notes seules) - _db_updated = bool(abs_pdfs or bn_pdfs or matu_pdfs or notes_pdfs or fiches_data) - if _manifest_files or _db_updated: - (DATA_DIR / "last_sync.json").write_text( - json.dumps({ - "timestamp": datetime.now().isoformat(), - "files": _manifest_files, - "db_updated": _db_updated, - }), - encoding="utf-8", - ) - - st.session_state["_sync_results"] = _sr - - # ── Affichage des résultats (piloté par la case à cocher) ───────────────── - if "_sync_results" in st.session_state: - _sr = st.session_state["_sync_results"] - _show_warn_esc = st.checkbox( - "Afficher les avertissements", key="show_import_warnings", value=False - ) - - if _sr["abs"]: - st.subheader(f"📋 Absences — {len(_sr['abs'])} PDF(s)") - for res in _sr["abs"]: - _parts = [f"{res.nb_absences_nouvelles} nouvelles"] - if res.nb_absences_mises_a_jour: - _parts.append(f"{res.nb_absences_mises_a_jour} mises à jour") - if res.nb_absences_doublons: - _parts.append(f"{res.nb_absences_doublons} doublons ignorés") - st.write(f"✅ **{res.classe}** — " + ", ".join(_parts)) - if _show_warn_esc: - if res.details_nouvelles: - st.info( - f"**Nouvelles ({res.nb_absences_nouvelles}) :**\n\n" - + "\n\n".join(f"• {d}" for d in res.details_nouvelles) - ) - if res.details_mises_a_jour: - st.warning( - f"**Mises à jour N→E ({res.nb_absences_mises_a_jour}) :**\n\n" - + "\n\n".join(f"• {d}" for d in res.details_mises_a_jour) - ) - - if _sr["bn"]: - st.subheader(f"📊 Bulletins de notes — {len(_sr['bn'])} PDF(s)") - for res_bn in _sr["bn"]: - st.write(f"✅ **{res_bn['classe']}** — {res_bn['nb']} apprenti(e)s") - - if _sr.get("notes"): - st.subheader(f"📝 Notes — {len(_sr['notes'])} PDF(s)") - for _cls_n in _sr["notes"]: - st.write(f"✅ **{_cls_n['classe']}** — {_cls_n['nb']} apprenti(e)s") - - if _sr["matu"]: - st.subheader(f"🎓 Notes Matu — {len(_sr['matu'])} PDF(s)") - for m in _sr["matu"]: - st.write(f"✅ **{m['classe']}** — {m['nb']} apprenti(e)s matchés") - if m["unmatched"] and _show_warn_esc: - st.warning( - f"⚠️ {len(m['unmatched'])} nom(s) non matchés : " - + ", ".join(f"**{n}**" for n in m["unmatched"]) - ) - - if _sr.get("fiches"): - _f = _sr["fiches"] - _total_f = _f.pop("_total", sum(_f.values())) - st.subheader(f"👤 Fiches apprentis — {_total_f} fiche(s) importée(s)") - for _cls_f, _nb_f in _f.items(): - st.write(f"✅ **{_cls_f}** — {_nb_f} élève(s)") - - nb_abs = len(_sr["abs"]) - nb_bn = len(_sr["bn"]) - nb_mat = len(_sr["matu"]) - nb_not = len(_sr.get("notes", [])) - nb_fich = _sr.get("fiches", {}).get("_total", sum(v for k, v in _sr.get("fiches", {}).items() if k != "_total")) - if not nb_abs and not nb_bn and not nb_mat and not nb_not and not nb_fich and not _sr["errors"]: - st.warning("Aucun PDF téléchargé.") - elif nb_abs or nb_bn or nb_mat or nb_not or nb_fich: - st.success(f"Synchronisation terminée — {nb_abs} abs. / {nb_bn} BN / {nb_mat} Matu / {nb_not} Notes / {nb_fich} fiches") - - if _sr["errors"]: - st.error(f"{len(_sr['errors'])} erreur(s) lors de la synchronisation :") - for e in _sr["errors"]: - st.write(f"• {e}") - - with st.expander("Logs complets"): - st.code(_sr["stdout"][-4000:] or "(vide)") - - # ── Pousser vers Escada ─────────────────────────────────────────────────── - st.divider() - st.subheader("⬆️ Pousser les modifications vers Escada") - - _push_sess = _session() - try: - from sqlalchemy.orm import joinedload as _jl - _pending_list = _push_sess.execute( - select(EscadaPending) - .options(_jl(EscadaPending.apprenti)) - .join(Apprenti, EscadaPending.apprenti_id == Apprenti.id) - .order_by(Apprenti.classe, EscadaPending.date, Apprenti.nom) - ).scalars().all() - finally: - _push_sess.close() - - _nb_pending = len(_pending_list) - if _nb_pending == 0: - st.info("Aucun changement en attente de synchronisation vers Escada.") - else: - st.warning( - f"**{_nb_pending} changement(s)** en attente d'envoi vers Escada." - ) - with st.expander(f"Voir les {_nb_pending} changement(s)"): - _rows_pending = [ - { - "Classe": ep.apprenti.classe, - "Nom": ep.apprenti.nom, - "Prénom": ep.apprenti.prenom, - "Date": ep.date.strftime("%d.%m.%Y"), - "Période": ep.periode, - "Action": ep.action, - } - for ep in _pending_list - ] - st.dataframe(_rows_pending, use_container_width=True, hide_index=True) - - _test_mode = st.checkbox("Mode test (Poidevin Alexandre / EM-AU 1 uniquement)") - if st.button("⬆️ Pousser vers Escada", type="primary"): - _push_args = ["--test"] if _test_mode else [] - _app_log(f"=== PUSH démarré par {_current_user}{' [MODE TEST]' if _test_mode else ''} ===") - with st.spinner("Envoi vers Escadaweb en cours…"): - try: - _pr = subprocess.run( - [sys.executable, str(_PUSH_SCRIPT)] + _push_args, - capture_output=True, text=True, timeout=600, - encoding="utf-8", errors="replace", - ) - except subprocess.TimeoutExpired: - st.error("Délai dépassé (10 min).") - return - - _push_ok = 0 - _push_errs: list[str] = [] - _push_done_found = False - for _line in _pr.stdout.splitlines(): - if "PUSH_DONE " in _line: - _push_done_found = True - try: - _payload = json.loads(_line[_line.index("PUSH_DONE ") + len("PUSH_DONE "):]) - _push_ok = _payload.get("ok", 0) - _push_errs = _payload.get("err", []) - except Exception: - pass - - _has_problem = bool(_push_errs) or not _push_done_found or _pr.returncode != 0 - - _log_combined = "" - if _pr.stdout.strip(): - _log_combined += _pr.stdout[-4000:] - if _pr.stderr.strip(): - _log_combined += "\n--- STDERR ---\n" + _pr.stderr[-2000:] - - st.session_state["_push_result"] = { - "ok": _push_ok, "errs": _push_errs, - "done": _push_done_found, "logs": _log_combined, - } - try: - (_root / "data" / "push_last.log").write_text( - _log_combined, encoding="utf-8" - ) - except Exception: - pass - st.rerun() - - # Affichage des résultats du dernier push (après rerun) - if "_push_result" in st.session_state: - _res = st.session_state["_push_result"] - if _res["ok"]: - st.success(f"{_res['ok']} changement(s) envoyé(s) avec succès.") - if _res["errs"]: - st.error(f"{len(_res['errs'])} erreur(s) lors de l'envoi :") - for _e in _res["errs"]: - st.markdown(f"  • {_e}") - if not _res["done"]: - st.error("Le script s'est terminé sans résultat (crash ou timeout).") - elif not _res["ok"] and not _res["errs"]: - st.warning("Aucun changement traité.") - _has_problem = bool(_res["errs"]) or not _res["done"] - with st.expander("Logs push", expanded=_has_problem): - st.code(_res["logs"] or "(vide)") - - -# ── Page : Vue classe ───────────────────────────────────────────────────────── - -def page_vue_classe(): - st.title("Vue classe") - if _semestre_actif: - st.caption(f"Filtre actif : {_semestre_actif}") - - sess = _session() - try: - classes = sess.execute( - select(Apprenti.classe).distinct().order_by(Apprenti.classe) - ).scalars().all() - - if not classes: - st.info("Aucune donnée. Importez d'abord un PDF.") - return - - _prefill_cl = st.session_state.pop("_prefill_classe", None) - if _prefill_cl is not None and _prefill_cl in classes: - st.session_state["vue_classe_sel"] = _prefill_cl - - classe = st.selectbox("Classe", classes, key="vue_classe_sel") - - # ── BN data for this class ───────────────────────────────────────────── - bn_rows = _bn_rows_for_class(sess, classe) - bn_by_id: dict[int, "NotesBulletin"] = {ap.id: bn for ap, bn in bn_rows} - first_bn = bn_rows[0][1] if bn_rows else None - sem_labels = json.loads(first_bn.sem_labels_json) if first_bn else [] - groups_order = _GROUP_ORDER.get(first_bn.type_classe, ["BP"]) if first_bn else ["BP"] - - # ── Absence synthesis per apprenti ──────────────────────────────────── - df_syn = stats.synthese_classe(sess, classe, semestre=_semestre_actif) - abs_by_name: dict[tuple, dict] = {} - if not df_syn.empty: - for _, r in df_syn.iterrows(): - abs_by_name[(r["Nom"], r["Prénom"])] = r - - # ── Latest BN import (needed for PDF path) ─────────────────────────── - latest_imp = sess.execute( - select(ImportBN) - .where(ImportBN.classe == classe) - .order_by(ImportBN.date_import.desc()) - .limit(1) - ).scalar_one_or_none() - - _vc_bn_path = (PDFS_DIR / latest_imp.fichier) if latest_imp else None - _vc_notes_path = PDFS_DIR / f"notes_{classe.replace(' ', '_')}.pdf" - - # Shared style / SVG pour les boutons PDF - _vc_link_style = ( - "display:inline-flex;align-items:center;gap:4px;" - "padding:3px 8px;border:1px solid #ddd;border-radius:5px;" - "text-decoration:none;color:#333;font-size:0.8em;white-space:nowrap" - ) - _vc_pdf_svg = ( - '' - ) - - # ── Notes d'examen pour la classe (1 requête pour tous) ────────────── - _vc_ne_rows = sess.execute( - select(NotesExamen, Apprenti) - .join(Apprenti, Apprenti.id == NotesExamen.apprenti_id) - .where(Apprenti.classe == classe) - ).all() - _vc_ne_by_id: dict[int, list] = {ne.apprenti_id: json.loads(ne.donnees_json) for ne, _ in _vc_ne_rows} - - # ── All apprentis for this class ────────────────────────────────────── - apprentis_liste = sess.execute( - select(Apprenti) - .where(Apprenti.classe == classe) - .order_by(Apprenti.nom, Apprenti.prenom) - ).scalars().all() - - QUOTA = 5 - - for apprenti in apprentis_liste: - st.markdown( - f'
' - f'' - f'{apprenti.prenom} {apprenti.nom}
', - unsafe_allow_html=True, - ) - - # ── Absence summary ─────────────────────────────────────────────── - abs_data = abs_by_name.get((apprenti.nom, apprenti.prenom)) - total = int(abs_data["Total"]) if abs_data is not None else 0 - excusees = int(abs_data["Excusées"]) if abs_data is not None else 0 - non_exc = int(abs_data["NON excusées"]) if abs_data is not None else 0 - blocs = stats.nb_blocs_absences(sess, apprenti.id, semestre=_semestre_actif) - st.markdown( - _absence_html_table(total, excusees, non_exc, blocs, QUOTA), - unsafe_allow_html=True, - ) - _pdf_abs = _absence_pdf_apprenti(sess, apprenti, _semestre_actif) - - # Construire la ligne de liens PDF (Absences + BN + Notes) - import base64 as _b64vc - _vc_links = _pdf_dl_link( - _pdf_abs, - f"Absences_{apprenti.nom}_{apprenti.prenom}.pdf", - "Tableau des absences", - ) - - if _vc_bn_path and _vc_bn_path.exists(): - _vc_bn_bytes = _extract_bn_pages(_vc_bn_path, apprenti.nom, apprenti.prenom) - if _vc_bn_bytes: - _b64bn = _b64vc.b64encode(_vc_bn_bytes).decode() - _fn_bn = f"BN_{apprenti.nom}_{apprenti.prenom}.pdf" - _vc_links += ( - f'' - f' {_vc_pdf_svg.replace("{color}", "#E53935")} PDF BN' - ) - - if _vc_notes_path.exists(): - _vc_nt_bytes = _extract_bn_pages(_vc_notes_path, apprenti.nom, apprenti.prenom) - if _vc_nt_bytes: - _b64nt = _b64vc.b64encode(_vc_nt_bytes).decode() - _fn_nt = f"Notes_{apprenti.nom}_{apprenti.prenom}.pdf" - _vc_links += ( - f'' - f' {_vc_pdf_svg.replace("{color}", "#1565C0")} PDF Notes' - ) - - st.markdown( - f'
' - f'{_vc_links}
', - unsafe_allow_html=True, - ) - - # ── Tabs BN / Notes ─────────────────────────────────────────────── - _vc_tab_bn, _vc_tab_notes = st.tabs(["📊 Cours professionnels", "📝 Notes d'examen"]) - - with _vc_tab_bn: - bn = bn_by_id.get(apprenti.id) - if bn: - d = json.loads(bn.donnees_json) - html_vc = _bn_html_table("Cours professionnels", d, sem_labels, groups_order) - nm = _latest_matu(sess, apprenti.id) - if nm: - html_vc += _matu_html_table(nm) - st.markdown(html_vc, unsafe_allow_html=True) - else: - st.info("Aucun bulletin de notes importé.") - - with _vc_tab_notes: - _vc_nd = _vc_ne_by_id.get(apprenti.id) - if _vc_nd: - st.markdown(_render_notes_html(_vc_nd), unsafe_allow_html=True) - else: - st.info("Aucune note d'examen. Lancez une sync avec l'option **Notes**.") - - finally: - sess.close() - - -# ── Page : Utilisateurs ─────────────────────────────────────────────────────── - -def _render_account_form(uname: str, udata: dict, users: dict, save_fn, is_me: bool, allow_role: bool): - """Formulaire d'édition d'un compte (informations + mot de passe + rôle).""" - - with st.form(key=f"info_{uname}"): - st.markdown("**Informations du compte**") - ci1, ci2 = st.columns(2) - new_login = ci1.text_input("Identifiant", value=uname, disabled=is_me) - new_name = ci2.text_input("Nom affiché", value=udata.get("name", "")) - new_email = st.text_input("Email", value=udata.get("email", "")) - - new_role = udata.get("role", "user") - if allow_role and not is_me: - new_role = st.selectbox( - "Rôle", ["user", "admin"], - index=0 if new_role == "user" else 1, - key=f"role_{uname}", - format_func=lambda r: "👑 Admin" if r == "admin" else "👤 User", - ) - elif allow_role and is_me: - st.caption(f"Rôle : **{'👑 Admin' if new_role == 'admin' else '👤 User'}** *(non modifiable pour son propre compte)*") - - if st.form_submit_button("💾 Mettre à jour"): - new_login_clean = uname if is_me else new_login.strip().lower() - errs = [] - if not new_login_clean: - errs.append("L'identifiant ne peut pas être vide.") - if " " in new_login_clean: - errs.append("L'identifiant ne doit pas contenir d'espaces.") - if new_login_clean != uname and new_login_clean in users: - errs.append(f"L'identifiant « {new_login_clean} » est déjà utilisé.") - if not new_name.strip(): - errs.append("Le nom affiché ne peut pas être vide.") - if errs: - for e in errs: st.error(e) - else: - udata["name"] = new_name.strip() - udata["email"] = new_email.strip() - if allow_role and not is_me: - udata["role"] = new_role - if new_login_clean != uname: - users[new_login_clean] = udata - del users[uname] - save_fn() - st.success(f"Identifiant renommé : `{uname}` → `{new_login_clean}`") - else: - save_fn() - st.success("Informations mises à jour.") - st.rerun() - - st.markdown("---") - - with st.form(key=f"pwd_{uname}"): - st.markdown("**Changer le mot de passe**") - p1 = st.text_input("Nouveau mot de passe", type="password") - p2 = st.text_input("Confirmer", type="password") - if st.form_submit_button("💾 Enregistrer le mot de passe"): - if len(p1) < 6: - st.error("Minimum 6 caractères.") - elif p1 != p2: - st.error("Les mots de passe ne correspondent pas.") - else: - users[uname]["password"] = bcrypt.hashpw( - p1.encode(), bcrypt.gensalt(12) - ).decode() - save_fn() - st.success(f"Mot de passe de **{udata['name']}** mis à jour.") - - st.markdown("---") - st.markdown("**Authentification à 2 facteurs**") - if users.get(uname, {}).get("totp_secret"): - st.caption("Statut : ✅ Activé") - if st.button("🔄 Réinitialiser l'authentificateur 2FA", key=f"totp_reset_{uname}"): - users[uname]["totp_secret"] = None - save_fn() - st.success( - f"Authentificateur de **{udata['name']}** réinitialisé. " - "Un nouveau QR code sera demandé à la prochaine connexion." - ) - st.rerun() - else: - st.caption("Statut : ⏳ Non configuré — sera demandé à la prochaine connexion") - - -def page_utilisateurs(): - if not AUTH_FILE.exists(): - st.error(f"Fichier introuvable : {AUTH_FILE}") - return - - with open(AUTH_FILE, encoding="utf-8") as f: - cfg = yaml.load(f, Loader=SafeLoader) - - users = cfg["credentials"]["usernames"] - - def _save_cfg(): - with open(AUTH_FILE, "w", encoding="utf-8") as fh: - yaml.dump(cfg, fh, allow_unicode=True) - - # ── Vue User : profil personnel uniquement ──────────────────────────────── - if not _is_admin: - st.title("Mon profil") - udata = users.get(_current_user, {}) - if not udata: - st.error("Compte introuvable.") - return - _render_account_form(_current_user, udata, users, _save_cfg, is_me=True, allow_role=False) - return - - # ── Vue Admin : gestion complète ────────────────────────────────────────── - st.title("Gestion des utilisateurs") - st.subheader("Comptes existants") - - for uname, udata in list(users.items()): - is_me = uname == _current_user - role_badge = "👑" if udata.get("role") == "admin" else "👤" - label = f"{role_badge} **{udata['name']}** — `{uname}`" + (" *(vous)*" if is_me else "") - - with st.expander(label): - _render_account_form(uname, udata, users, _save_cfg, is_me=is_me, allow_role=True) - - if not is_me: - st.markdown("---") - if st.button( - f"🗑️ Supprimer le compte {udata['name']}", - key=f"del_{uname}", - type="secondary", - ): - del users[uname] - _save_cfg() - st.success(f"Compte **{uname}** supprimé.") - st.rerun() - - st.divider() - - # ── Ajouter un utilisateur ──────────────────────────────────────────────── - st.subheader("Ajouter un utilisateur") - - with st.form("add_user_form"): - col1, col2 = st.columns(2) - new_uname = col1.text_input("Identifiant de connexion", placeholder="jean.dupont") - new_name = col2.text_input("Prénom / Nom affiché", placeholder="Jean Dupont") - new_email = st.text_input("Email (optionnel)") - new_role_add = st.selectbox( - "Rôle", ["user", "admin"], - format_func=lambda r: "👑 Admin" if r == "admin" else "👤 User", - ) - p1 = st.text_input("Mot de passe", type="password") - p2 = st.text_input("Confirmer le mot de passe", type="password") - - if st.form_submit_button("➕ Créer le compte"): - uname_clean = new_uname.strip().lower() - errs = [] - if not uname_clean or not new_name.strip(): - errs.append("L'identifiant et le nom sont obligatoires.") - if " " in uname_clean: - errs.append("L'identifiant ne doit pas contenir d'espaces.") - if uname_clean in users: - errs.append(f"L'identifiant « {uname_clean} » est déjà utilisé.") - if len(p1) < 6: - errs.append("Mot de passe trop court (6 caractères minimum).") - if p1 != p2: - errs.append("Les mots de passe ne correspondent pas.") - - if errs: - for e in errs: st.error(e) - else: - users[uname_clean] = { - "email": new_email.strip(), - "name": new_name.strip(), - "role": new_role_add, - "password": bcrypt.hashpw(p1.encode(), bcrypt.gensalt(12)).decode(), - "totp_secret": None, - } - _save_cfg() - st.success( - f"Compte **{new_name.strip()}** créé (`{uname_clean}`, rôle : {new_role_add})." - ) - st.rerun() - - -# ── Page : Export Excel ─────────────────────────────────────────────────────── - -def page_export(): - st.title("Export Excel") - if _semestre_actif: - st.caption(f"Filtre actif : {_semestre_actif}") - - sess = _session() - try: - kpi = stats.kpis(sess, semestre=_semestre_actif) - if kpi["total_global"] == 0: - st.info("Aucune donnée à exporter.") - return - xlsx = stats.export_excel_global(sess, semestre=_semestre_actif) - st.download_button( - label="📥 Télécharger toutes les absences (.xlsx)", - data=xlsx, - file_name=f"absences_export_{datetime.now().strftime('%Y%m%d')}.xlsx", - mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ) - finally: - sess.close() - - -# ── Page : Vider les données ────────────────────────────────────────────────── - -def page_reset_data(): - from sqlalchemy import delete as _delete - - st.title("Vider les données") - st.warning( - "⚠️ Cette opération supprime **définitivement** toutes les données de la base : " - "absences, apprentis, imports, bulletins de notes et notes Matu. " - "Les fichiers PDF ne sont pas supprimés." - ) - - if st.button("🗑️ Vider toutes les données", type="secondary"): - st.session_state["_confirm_reset"] = True - - if st.session_state.get("_confirm_reset"): - st.error("Confirmez-vous la suppression de **toutes les données** ? Cette action est irréversible.") - c1, c2 = st.columns(2) - if c1.button("✅ Oui, tout supprimer", type="primary"): - sess = _session() - try: - for model in (EscadaPending, SanctionExport, - NotesMatu, NotesBulletin, Absence, - ImportMatu, ImportBN, Import, Apprenti): - sess.execute(_delete(model)) - sess.commit() - st.session_state.pop("_confirm_reset", None) - st.success("Toutes les données ont été supprimées.") - st.rerun() - except Exception as e: - sess.rollback() - st.error(f"Erreur : {e}") - finally: - sess.close() - if c2.button("❌ Annuler"): - st.session_state.pop("_confirm_reset", None) - st.rerun() - - - - -def _load_auth_cfg() -> dict: - """Charge auth.yaml complet (pas uniquement les usernames).""" - with open(AUTH_FILE, encoding="utf-8") as f: - return yaml.load(f, Loader=SafeLoader) - - -def _save_auth_cfg(cfg: dict) -> None: - import yaml as _yaml - AUTH_FILE.write_text( - _yaml.dump(cfg, allow_unicode=True, default_flow_style=False), - encoding="utf-8", - ) - - -def page_params(): - st.title("Paramètres") - s = _load_settings() - - # ── Avis de sanction ────────────────────────────────────────────────────── - st.subheader("📄 Avis de sanction") - - texte = st.text_area( - "Texte de description par défaut (champ TexteDescription)", - value=s.get( - "texte_sanction", - "Selon le règlement de l'EM, l'apprenti a dépassé le nombre d'absences limite.", - ), - height=120, - ) - cs = st.text_input( - "Chef de section (CS)", - value=s.get("chef_section", "Patrick Rausis"), - ) - - if not _SANCTION_TPL.exists(): - st.warning(f"Template PDF introuvable : `{_SANCTION_TPL.relative_to(_root)}`") - else: - st.caption(f"Template : `{_SANCTION_TPL.relative_to(_root)}` ✓") - - if st.button("💾 Enregistrer sanctions", type="primary"): - s["texte_sanction"] = texte.strip() - s["chef_section"] = cs.strip() - _save_settings(s) - st.success("Paramètres enregistrés.") - - st.divider() - - # ── Email ───────────────────────────────────────────────────────────────── - st.subheader("✉️ Configuration email") - - _smtp_c1, _smtp_c2 = st.columns(2) - smtp_host = _smtp_c1.text_input( - "Serveur SMTP", value=s.get("smtp_host", "smtp-relay.brevo.com") - ) - smtp_port = _smtp_c1.number_input( - "Port", value=int(s.get("smtp_port", 587)), min_value=1, max_value=65535, step=1 - ) - _smtp_login_default = s.get("smtp_login", s.get("smtp_email", "")) - smtp_login = _smtp_c2.text_input( - "Login SMTP (authentification)", value=_smtp_login_default, - help="Identifiant utilisé pour l'authentification SMTP (peut différer de l'expéditeur)" - ) - smtp_pwd = _smtp_c2.text_input( - "Mot de passe SMTP", value=s.get("smtp_password", ""), type="password", - ) - smtp_sender = st.text_input( - "Expéditeur", - value=s.get("smtp_sender", "EPTM Automation "), - help="Format : Nom Affiché ou simplement email@domaine.ch", - ) - if st.button("💾 Enregistrer configuration SMTP", type="primary"): - s["smtp_host"] = smtp_host.strip() - s["smtp_port"] = int(smtp_port) - s["smtp_login"] = smtp_login.strip() - s["smtp_password"] = smtp_pwd.strip() - s["smtp_sender"] = smtp_sender.strip() - s.pop("smtp_email", None) - _save_settings(s) - st.success("Configuration SMTP enregistrée.") - - st.divider() - - # ── Connexion Escada ────────────────────────────────────────────────────── - st.subheader("🔐 Connexion Escada (synchronisation automatique)") - st.caption("Si renseignés, identifiant, mot de passe et code 2FA sont saisis automatiquement lors de la synchronisation.") - _esc_c1, _esc_c2 = st.columns(2) - escada_username = _esc_c1.text_input( - "Identifiant Escada", - value=s.get("escada_username", ""), - ) - escada_password = _esc_c2.text_input( - "Mot de passe Escada", - value=s.get("escada_password", ""), - type="password", - ) - totp_secret = st.text_input( - "Clé secrète 2FA (TOTP)", - value=s.get("totp_secret", ""), - type="password", - help="Clé secrète TOTP de votre application d'authentification (ex. Google Authenticator). Format Base32.", - ) - if st.button("💾 Enregistrer connexion Escada", type="primary"): - s["escada_username"] = escada_username.strip() - s["escada_password"] = escada_password.strip() - s["totp_secret"] = totp_secret.strip() - _save_settings(s) - st.success("Identifiants Escada enregistrés.") - - st.divider() - - # ── Template email ──────────────────────────────────────────────────────── - st.subheader("📝 Template email") - st.caption( - "Variables : `{prenom}`, `{nom}`, `{nom_complet}`, `{classe}`, " - "`{nb_absences}`, `{nb_excusees}`, `{nb_non_excusees}`, `{nb_a_traiter}`, " - "`{semestre}`, `{date_du_jour}`" - ) - - _tpl_def_subj = "Document EPTM — {nom_complet} ({classe})" - _tpl_def_body = ( - "Bonjour {prenom},\n\n" - "Veuillez trouver ci-joint votre document pour la classe {classe}.\n\n" - "Cordialement,\nL'équipe EPTM" - ) - _tpl_subj = st.text_input("Objet", value=s.get("email_subject", _tpl_def_subj), key="tpl_subj") - _tpl_body = st.text_area("Corps du message", value=s.get("email_body", _tpl_def_body), height=220, key="tpl_body") - - if st.button("💾 Enregistrer template", type="primary"): - s["email_subject"] = _tpl_subj - s["email_body"] = _tpl_body - _save_settings(s) - st.success("Template enregistré.") - - -# ── Dispatch ────────────────────────────────────────────────────────────────── - - -def page_logs() -> None: - """Page de consultation, export et effacement des logs opérationnels.""" - import re as _re - - st.title("📋 Logs") - - # ── Contrôles ───────────────────────────────────────────────────────────── - c_level, c_refresh, c_export, c_clear = st.columns([2, 1, 1, 1]) - with c_level: - _level = st.radio( - "Niveau", - ["PROD", "DEBUG"], - horizontal=True, - key="log_level", - help="PROD : démarrages, fins et résumés. DEBUG : tous les logs.", - ) - with c_refresh: - st.button("🔄 Rafraîchir", key="_logs_refresh") - - if not _LOG_FILE.exists() or _LOG_FILE.stat().st_size == 0: - with c_export: - st.button("⬇️ Exporter", disabled=True) - with c_clear: - st.button("🗑️ Effacer", disabled=True) - st.info("Aucun log disponible.") - return - - raw = _LOG_FILE.read_text(encoding="utf-8", errors="replace") - - with c_export: - st.download_button( - "⬇️ Exporter", - data=raw.encode("utf-8"), - file_name="operations.log", - mime="text/plain", - ) - - with c_clear: - if "confirm_clear_logs" not in st.session_state: - st.session_state["confirm_clear_logs"] = False - if not st.session_state["confirm_clear_logs"]: - if st.button("🗑️ Effacer", type="secondary"): - st.session_state["confirm_clear_logs"] = True - st.rerun() - else: - c1, c2 = st.columns(2) - with c1: - if st.button("✅ Confirmer", type="primary"): - _LOG_FILE.write_text("", encoding="utf-8") - st.session_state["confirm_clear_logs"] = False - st.rerun() - with c2: - if st.button("Annuler"): - st.session_state["confirm_clear_logs"] = False - st.rerun() - - # ── Filtrage ────────────────────────────────────────────────────────────── - lines = raw.splitlines() - if _level == "PROD": - # Garder les lignes top-level : [HH:MM:SS] suivi d'un char non-espace - # (les lignes indentées commencent par [HH:MM:SS] ...) - filtered_lines = [ - ln for ln in lines - if _re.match(r"^\[\d{2}:\d{2}:\d{2}\] [^ ]", ln) or not ln.strip() - ] - content = "\n".join(filtered_lines) - else: - content = raw - - # ── Affichage ───────────────────────────────────────────────────────────── - nb = len(lines) - nb_shown = len(filtered_lines) if _level == "PROD" else nb - st.caption( - f"{nb_shown} ligne(s) affichée(s)" - + (f" / {nb} total" if _level == "PROD" else "") - + f" — fichier : `{_LOG_FILE}`" - ) - st.code(content, language=None) - - -if page == PAGE_ACCUEIL: - page_accueil() -elif page == PAGE_TRAITER: - page_a_traiter() -elif page == PAGE_FICHE: - page_fiche() -elif page == PAGE_CLASSE: - page_vue_classe() -elif page == PAGE_IMPORT: - page_import() -elif page == PAGE_ESCADA: - page_esacada() -elif page == PAGE_LOGS: - page_logs() -elif page == PAGE_USERS: - page_utilisateurs() -elif page == PAGE_PARAMS: - page_params() -elif page == PAGE_EXPORT: - page_export() -elif page == PAGE_RESET: - page_reset_data() diff --git a/src/importer.py b/src/importer.py index 98b8c60..fa0e38d 100644 --- a/src/importer.py +++ b/src/importer.py @@ -37,7 +37,7 @@ def import_pdf( imported_by: str = "system", force: bool = False, ) -> ImportResult: - """Parse un PDF et insère les absences en base. + """Parse un PDF et insère les absences dans la DB. Déduplication : (apprenti_id, date, periode) déjà présent → ignoré. Si force=True, réinitialise le statut de toutes les absences existantes