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'