accents corrigés

This commit is contained in:
Julien Balet 2026-05-10 16:06:58 +02:00
parent f60cbf1b1c
commit ee4e212f7d
8 changed files with 322 additions and 3803 deletions

View file

@ -8,6 +8,6 @@
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b51ec970-5bf4-4982-a05e-80546bb7421f", "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 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", "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"
} }

View file

@ -286,7 +286,7 @@ def _absence_pdf_apprenti(sess, apprenti) -> bytes:
total_periodes = total_e + total_n total_periodes = total_e + total_n
footer_label = ( footer_label = (
f"{len(blocs)} absence(s) | " f"{len(blocs)} absence(s) | "
f"{total_periodes} periode(s) | " f"{total_periodes} période(s) | "
f"{total_e} excusee(s) | " f"{total_e} excusee(s) | "
f"{total_n} non excusee(s)" f"{total_n} non excusee(s)"
) )
@ -353,6 +353,15 @@ class ClasseState(AuthState):
selected_class: str = "" selected_class: str = ""
has_classes: bool = False has_classes: bool = False
apprentis_data: list[dict] = [] 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): def load_data(self):
if not self.authenticated: if not self.authenticated:
@ -377,8 +386,18 @@ class ClasseState(AuthState):
def set_class(self, classe: str): def set_class(self, classe: str):
self.selected_class = classe self.selected_class = classe
self.class_select_open = False
self.class_search = ""
self._reload() 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): def download_abs_pdf(self, apprenti_id: int):
sess = get_session() sess = get_session()
apprenti = sess.get(Apprenti, apprenti_id) apprenti = sess.get(Apprenti, apprenti_id)
@ -544,6 +563,75 @@ class ClasseState(AuthState):
# ── UI components ───────────────────────────────────────────────────────────── # ── 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: def _kpi_mini(label: str, value, color: str = "#37474f") -> rx.Component:
return rx.box( return rx.box(
rx.text(label, size="1", color="#888"), rx.text(label, size="1", color="#888"),
@ -656,7 +744,7 @@ def _apprenti_card(item) -> rx.Component:
spacing="2", width="100%", spacing="2", width="100%",
), ),
rx.text( rx.text(
"Aucun bulletin de notes importe.", "Aucun bulletin de notes importé.",
size="2", color="#666", size="2", color="#666",
), ),
), ),
@ -702,12 +790,7 @@ def classe_page() -> rx.Component:
rx.cond( rx.cond(
ClasseState.has_classes, ClasseState.has_classes,
rx.vstack( rx.vstack(
rx.select( _classe_searchable_select(),
ClasseState.classes,
value=ClasseState.selected_class,
on_change=ClasseState.set_class,
width="100%",
),
rx.cond( rx.cond(
ClasseState.apprentis_data.length() > 0, ClasseState.apprentis_data.length() > 0,

View file

@ -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.db import CronJob, get_session, init_db, Apprenti # noqa: E402
from src.notifier import test_telegram # noqa: E402 from src.notifier import test_telegram # noqa: E402
from src.logger import app_log # noqa: E402
from ..state import AuthState from ..state import AuthState
from ..sidebar import layout from ..sidebar import layout
@ -348,9 +349,11 @@ class CronState(AuthState):
classes_json = json.dumps(self.f_classes) classes_json = json.dumps(self.f_classes)
sess = get_session() sess = get_session()
user = self.username or "?"
try: try:
now = datetime.now() now = datetime.now()
if self.editing_id == 0: is_new = self.editing_id == 0
if is_new:
job = CronJob( job = CronJob(
name=self.f_name.strip(), name=self.f_name.strip(),
enabled=self.f_enabled, enabled=self.f_enabled,
@ -391,6 +394,12 @@ class CronState(AuthState):
job.notify_chat_id = self.f_notify_chat_id job.notify_chat_id = self.f_notify_chat_id
job.updated_at = now job.updated_at = now
sess.commit() 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.save_ok = True
self._refresh() self._refresh()
self.edit_open = False self.edit_open = False
@ -402,6 +411,7 @@ class CronState(AuthState):
def toggle_enabled(self, job_id: int): def toggle_enabled(self, job_id: int):
sess = get_session() sess = get_session()
user = self.username or "?"
try: try:
job = sess.get(CronJob, job_id) job = sess.get(CronJob, job_id)
if job: if job:
@ -413,17 +423,25 @@ class CronState(AuthState):
if was_disabled and job.enabled: if was_disabled and job.enabled:
job.last_run_at = None job.last_run_at = None
sess.commit() 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() self._refresh()
finally: finally:
sess.close() sess.close()
def delete_job(self, job_id: int): def delete_job(self, job_id: int):
sess = get_session() sess = get_session()
user = self.username or "?"
try: try:
job = sess.get(CronJob, job_id) job = sess.get(CronJob, job_id)
if job: if job:
job_name = job.name
sess.delete(job) sess.delete(job)
sess.commit() sess.commit()
app_log(f"[cron] {user} : suppression tâche '{job_name}' (id={job_id})")
self._refresh() self._refresh()
finally: finally:
sess.close() sess.close()

View file

@ -159,7 +159,7 @@ class EscadaState(AuthState):
self.is_refreshing = False self.is_refreshing = False
self.is_pushing = False self.is_pushing = False
self.import_in_progress = False self.import_in_progress = False
app_log("Sync annulee") app_log("Sync annulée")
# ── load_data ────────────────────────────────────────────────────────────── # ── load_data ──────────────────────────────────────────────────────────────
@ -228,10 +228,11 @@ class EscadaState(AuthState):
@_background @_background
async def refresh_classes(self): async def refresh_classes(self):
app_log("Rafraichissement liste classes Escada")
async with self: async with self:
user = self.username or "?"
self.is_refreshing = True 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"] cmd = [sys.executable, str(_SYNC_SCRIPT), "--list-classes"]
lines: list[str] = [] lines: list[str] = []
@ -329,9 +330,9 @@ class EscadaState(AuthState):
] ]
if ui_classes: if ui_classes:
app_log(f"Classes recuperees : {', '.join(ui_classes)}") app_log(f"Classes récupérées : {', '.join(ui_classes)}")
else: 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: try:
_t = asyncio.current_task() _t = asyncio.current_task()
@ -353,7 +354,7 @@ class EscadaState(AuthState):
self.op_log = "\n".join(lines[-60:]) self.op_log = "\n".join(lines[-60:])
self.is_refreshing = False self.is_refreshing = False
except Exception as _e: except Exception as _e:
app_log(f"Erreur mise a jour etat refresh : {_e}") app_log(f"Erreur mise à jour état refresh : {_e}")
try: try:
async with self: async with self:
self.is_refreshing = False self.is_refreshing = False
@ -384,7 +385,16 @@ class EscadaState(AuthState):
self.sync_res_notes = [] self.sync_res_notes = []
self.sync_res_matu = [] 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 args = ["--sync-all"] + selected
if not sync_abs: args.append("--skip-abs") if not sync_abs: args.append("--skip-abs")
@ -485,7 +495,7 @@ class EscadaState(AuthState):
except Exception: except Exception:
pass pass
_rc_holder[0] = _proc.wait() or 0 _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 break
elif _all_done_payload: elif _all_done_payload:
@ -507,7 +517,7 @@ class EscadaState(AuthState):
except Exception: pass except Exception: pass
_rc = _rc_holder[0] _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: if not _all_done_payload:
app_log(f"ALL_DONE non trouve (code={_rc})") app_log(f"ALL_DONE non trouve (code={_rc})")
@ -590,7 +600,7 @@ class EscadaState(AuthState):
break break
# ── État final — async with self #3 ────────────────────────────────────── # ── É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() _uncancel()
async with self: async with self:
self.import_in_progress = False self.import_in_progress = False
@ -601,22 +611,23 @@ class EscadaState(AuthState):
self.sync_res_matu = _result_data.get("res_matu", []) self.sync_res_matu = _result_data.get("res_matu", [])
self.sync_errors = _result_data.get("errors", []) self.sync_errors = _result_data.get("errors", [])
self.sync_done = True self.sync_done = True
app_log("Resultats charges — sync terminee OK") app_log("Résultats chargés — sync terminée OK")
else: 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: push vers Escada ───────────────────────────────────────────
@_background @_background
async def push_escada(self): async def push_escada(self):
async with self: async with self:
user = self.username or "?"
self.is_pushing = True self.is_pushing = True
self.op_log = "Envoi vers Escadaweb..." self.op_log = "Envoi vers Escadaweb"
self.push_done = False self.push_done = False
self.push_ok = 0 self.push_ok = 0
self.push_errors = [] self.push_errors = []
app_log("Push Escada demarre") app_log(f"Push Escada démarré par {user}")
extra: list[str] = [] extra: list[str] = []
cmd = [sys.executable, str(_PUSH_SCRIPT), *extra] cmd = [sys.executable, str(_PUSH_SCRIPT), *extra]
lines: list[str] = [] lines: list[str] = []
@ -706,7 +717,7 @@ class EscadaState(AuthState):
app_log(f" Erreur parse PUSH_DONE : {_e}", debug=True) app_log(f" Erreur parse PUSH_DONE : {_e}", debug=True)
if push_done: 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: else:
app_log(f"Push : PUSH_DONE non trouve (code={_rc}, lignes={len(lines)})") 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.is_pushing = False
self._reload_pending() self._reload_pending()
except Exception as _e: except Exception as _e:
app_log(f"Erreur mise a jour etat push : {_e}") app_log(f"Erreur mise à jour état push : {_e}")
try: try:
async with self: async with self:
self.is_pushing = False self.is_pushing = False
@ -910,7 +921,7 @@ def _sync_progress() -> rx.Component:
size="3", font_weight="600", color="#1565c0", size="3", font_weight="600", color="#1565c0",
), ),
rx.text( rx.text(
"Telechargement depuis escadaweb.vs.ch (1-3 min)", "Téléchargement depuis escadaweb.vs.ch (1-3 min)",
size="2", color="#555", size="2", color="#555",
), ),
spacing="0", spacing="0",
@ -935,7 +946,7 @@ def _sync_progress() -> rx.Component:
), ),
), ),
# Phase 2 : import en base # Phase 2 : import
rx.cond( rx.cond(
EscadaState.import_in_progress, EscadaState.import_in_progress,
rx.box( rx.box(
@ -943,11 +954,11 @@ def _sync_progress() -> rx.Component:
rx.spinner(size="3"), rx.spinner(size="3"),
rx.vstack( rx.vstack(
rx.text( rx.text(
"Import des donnees en cours...", "Import des données en cours…",
size="3", font_weight="600", color="#e65100", size="3", font_weight="600", color="#e65100",
), ),
rx.text( rx.text(
"Insertion en base de donnees (~30s)", "Insertion dans la DB (~30s)",
size="2", color="#555", size="2", color="#555",
), ),
spacing="0", spacing="0",
@ -969,7 +980,7 @@ def _sync_progress() -> rx.Component:
rx.vstack( rx.vstack(
rx.callout.root( rx.callout.root(
rx.callout.icon(rx.icon("check", size=16)), 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", color_scheme="green",
variant="soft", variant="soft",
size="1", size="1",
@ -1061,8 +1072,7 @@ def escada_page() -> rx.Component:
rx.vstack( rx.vstack(
rx.heading("Synchronisation Escada", size="7"), rx.heading("Synchronisation Escada", size="7"),
rx.text( rx.text(
"Telecharge absences, BN, notes et fiches depuis escadaweb.vs.ch " "Télécharge absences, BN, notes et données apprentis depuis Escadaweb.",
"et les importe directement en base.",
size="2", color="#666", size="2", color="#666",
), ),
@ -1106,7 +1116,7 @@ def escada_page() -> rx.Component:
~EscadaState.has_classes, ~EscadaState.has_classes,
rx.box( rx.box(
rx.text( 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", size="2", color="#555",
), ),
padding="0.75rem", padding="0.75rem",
@ -1133,7 +1143,7 @@ def escada_page() -> rx.Component:
rx.flex( rx.flex(
rx.checkbox(checked=EscadaState.sync_bn, rx.checkbox(checked=EscadaState.sync_bn,
on_change=EscadaState.set_sync_bn, size="2"), 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", gap="0.4rem", align="center",
), ),
rx.flex( rx.flex(
@ -1145,7 +1155,7 @@ def escada_page() -> rx.Component:
rx.flex( rx.flex(
rx.checkbox(checked=EscadaState.sync_fiches, rx.checkbox(checked=EscadaState.sync_fiches,
on_change=EscadaState.set_sync_fiches, size="2"), 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="0.4rem", align="center",
), ),
gap="1rem", gap="1rem",
@ -1155,16 +1165,30 @@ def escada_page() -> rx.Component:
rx.cond( rx.cond(
EscadaState.sync_abs, EscadaState.sync_abs,
rx.flex( rx.flex(
rx.icon(
"triangle-alert",
size=14,
color="#b45309",
),
rx.checkbox( rx.checkbox(
checked=EscadaState.force_abs, checked=EscadaState.force_abs,
on_change=EscadaState.set_force_abs, on_change=EscadaState.set_force_abs,
size="2", size="2",
color_scheme="amber",
), ),
rx.text( rx.text(
"Forcer la reimportation des absences existantes", "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="#555", 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, EscadaState.push_ok > 0,
rx.text( rx.text(
EscadaState.push_ok, EscadaState.push_ok,
" changement(s) envoye(s) avec succes.", " changement(s) envoyé(s) avec succès.",
size="2", color="#2e7d32", font_weight="600", size="2", color="#2e7d32", font_weight="600",
), ),
), ),

View file

@ -21,6 +21,7 @@ from src.db import (
from src.stats import nb_blocs_absences from src.stats import nb_blocs_absences
from src.parser_bn import sem_short_label from src.parser_bn import sem_short_label
from src.email_sender import build_template_vars, render_template from src.email_sender import build_template_vars, render_template
from src.logger import app_log
MOIS_FR = [ MOIS_FR = [
"janvier", "fevrier", "mars", "avril", "mai", "juin", "janvier", "fevrier", "mars", "avril", "mai", "juin",
@ -313,7 +314,7 @@ def _absence_pdf_apprenti(sess, apprenti) -> bytes:
total_periodes = total_e + total_n total_periodes = total_e + total_n
footer_label = ( footer_label = (
f"{len(blocs)} absence(s) | " f"{len(blocs)} absence(s) | "
f"{total_periodes} periode(s) | " f"{total_periodes} période(s) | "
f"{total_e} excusee(s) | " f"{total_e} excusee(s) | "
f"{total_n} non excusee(s)" f"{total_n} non excusee(s)"
) )
@ -381,6 +382,15 @@ class FicheState(AuthState):
selected_label: str = "" selected_label: str = ""
selected_id: int = 0 selected_id: int = 0
has_apprentis: bool = False 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 ───────────────────────────────────────────────────────────────── # ── KPIs ─────────────────────────────────────────────────────────────────
kpi_total: int = 0 kpi_total: int = 0
@ -455,16 +465,23 @@ class FicheState(AuthState):
email_error: str = "" email_error: str = ""
# ── Setters (edit periods) ──────────────────────────────────────────────── # ── Setters (edit periods) ────────────────────────────────────────────────
def set_edit_p1(self, v: str): self.edit_p1 = v # Note: rx.segmented_control passe str | list[str] — on coerce.
def set_edit_p2(self, v: str): self.edit_p2 = v @staticmethod
def set_edit_p3(self, v: str): self.edit_p3 = v def _coerce_period(v) -> str:
def set_edit_p4(self, v: str): self.edit_p4 = v if isinstance(v, list):
def set_edit_p5(self, v: str): self.edit_p5 = v return v[0] if v else "present"
def set_edit_p6(self, v: str): self.edit_p6 = v return v or "present"
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_p1(self, v): self.edit_p1 = self._coerce_period(v)
def set_edit_p9(self, v: str): self.edit_p9 = v def set_edit_p2(self, v): self.edit_p2 = self._coerce_period(v)
def set_edit_p10(self, v: str): self.edit_p10 = 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) ─────────────────────────────────────────────────────── # ── Setters (email) ───────────────────────────────────────────────────────
def set_email_dest(self, v: str): self.email_dest = v def set_email_dest(self, v: str): self.email_dest = v
@ -506,8 +523,18 @@ class FicheState(AuthState):
except ValueError: except ValueError:
pass pass
self.edit_date = "" self.edit_date = ""
self.apprenti_select_open = False
self.apprenti_search = ""
self._reload(reset_email=True) 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): def navigate_to(self, apprenti_id: int):
if apprenti_id in self.apprenti_ids: if apprenti_id in self.apprenti_ids:
idx = self.apprenti_ids.index(apprenti_id) idx = self.apprenti_ids.index(apprenti_id)
@ -577,6 +604,12 @@ class FicheState(AuthState):
return return
sess = get_session() sess = get_session()
d = date.fromisoformat(self.edit_date) 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( existing = sess.execute(
select(Absence).where( select(Absence).where(
Absence.apprenti_id == self.selected_id, 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, 7: self.edit_p7, 8: self.edit_p8, 9: self.edit_p9,
10: self.edit_p10, 10: self.edit_p10,
} }
d_str = d.strftime("%d.%m.%Y")
for p, choice in choices.items(): for p, choice in choices.items():
ab = pm.get(p) ab = pm.get(p)
if choice == "present": if choice == "present":
if ab: if ab:
upsert_escada_pending(sess, self.selected_id, d, p, "clear") upsert_escada_pending(sess, self.selected_id, d, p, "clear")
sess.delete(ab) sess.delete(ab)
app_log(
f"[abs] {user} : {appr_label}{d_str} P{p} : "
f"{ab.type_origine} → présent (suppression)"
)
else: else:
type_o = "E" if choice == "excusee" else "N" type_o = "E" if choice == "excusee" else "N"
statut = "excusee" if choice == "excusee" else "a_traiter" statut = "excusee" if choice == "excusee" else "a_traiter"
if ab: if ab:
if ab.statut != statut: if ab.statut != statut:
old_type = ab.type_origine
ab.type_origine = type_o ab.type_origine = type_o
ab.statut = statut ab.statut = statut
ab.updated_by = self.username ab.updated_by = self.username
upsert_escada_pending(sess, self.selected_id, d, p, type_o) 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: else:
sess.add(Absence( sess.add(Absence(
apprenti_id=self.selected_id, apprenti_id=self.selected_id,
@ -613,6 +656,10 @@ class FicheState(AuthState):
updated_by=self.username, import_id=None, updated_by=self.username, import_id=None,
)) ))
upsert_escada_pending(sess, self.selected_id, d, p, type_o) 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() sess.commit()
self.edit_date = "" self.edit_date = ""
self._reload(reset_email=False) self._reload(reset_email=False)
@ -621,6 +668,13 @@ class FicheState(AuthState):
def excuse_day(self, date_str: str): def excuse_day(self, date_str: str):
sess = get_session() sess = get_session()
d = date.fromisoformat(date_str) 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( absences = sess.execute(
select(Absence).where( select(Absence).where(
Absence.apprenti_id == self.selected_id, Absence.apprenti_id == self.selected_id,
@ -629,10 +683,15 @@ class FicheState(AuthState):
) )
).scalars().all() ).scalars().all()
for ab in absences: for ab in absences:
old_type = ab.type_origine
ab.statut = "excusee" ab.statut = "excusee"
ab.type_origine = "E" ab.type_origine = "E"
ab.updated_by = self.username ab.updated_by = self.username
upsert_escada_pending(sess, self.selected_id, d, ab.periode, "E") 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() sess.commit()
if self.edit_date == date_str: if self.edit_date == date_str:
self.edit_date = "" self.edit_date = ""
@ -991,6 +1050,75 @@ class FicheState(AuthState):
# ── UI components ───────────────────────────────────────────────────────────── # ── 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: def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component:
return rx.box( return rx.box(
rx.text(label, size="1", color="#666"), rx.text(label, size="1", color="#666"),
@ -1064,16 +1192,20 @@ def _period_select(p_num: int, val, setter) -> rx.Component:
return rx.hstack( return rx.hstack(
rx.text(f"P{p_num}", size="2", weight="medium", color="#555", rx.text(f"P{p_num}", size="2", weight="medium", color="#555",
min_width="28px", text_align="right"), min_width="28px", text_align="right"),
rx.select.root( rx.segmented_control.root(
rx.select.trigger(width="170px"), rx.segmented_control.item("Présent", value="present"),
rx.select.content( rx.segmented_control.item("E", value="excusee"),
rx.select.item("Présent", value="present"), rx.segmented_control.item("N", value="non_excusee"),
rx.select.item("E — Excusée", value="excusee"),
rx.select.item("N — Non excusée", value="non_excusee"),
),
value=val, value=val,
on_change=setter, on_change=setter,
size="1", size="1",
color_scheme=rx.match(
val,
("excusee", "orange"),
("non_excusee", "red"),
"gray",
),
radius="medium",
), ),
spacing="2", spacing="2",
align="center", align="center",
@ -1316,13 +1448,8 @@ def fiche_page() -> rx.Component:
FicheState.has_apprentis, FicheState.has_apprentis,
rx.vstack( rx.vstack(
# ── Sélecteur apprenti ──────────────────────────────────── # ── Sélecteur apprenti (recherche intégrée) ───────────────
rx.select( _apprenti_searchable_select(),
FicheState.apprenti_labels,
value=FicheState.selected_label,
on_change=FicheState.handle_select,
width="100%",
),
# ── KPI cards ───────────────────────────────────────────── # ── KPI cards ─────────────────────────────────────────────
rx.flex( rx.flex(
@ -1542,7 +1669,7 @@ def fiche_page() -> rx.Component:
rx.hstack( rx.hstack(
rx.icon("clock", size=15, color="#b45309"), rx.icon("clock", size=15, color="#b45309"),
rx.text( rx.text(
"Absences à traiter", "Valider toutes les absences d'une journée",
size="2", weight="bold", color="#92400e", size="2", weight="bold", color="#92400e",
), ),
spacing="2", align="center", spacing="2", align="center",
@ -1592,7 +1719,7 @@ def fiche_page() -> rx.Component:
), ),
rx.box( rx.box(
rx.text( rx.text(
"Aucun apprenti en base. Faites d'abord un import.", "Aucun apprenti. Faites d'abord un import.",
size="2", color="#666", size="2", color="#666",
), ),
padding="1rem", padding="1rem",

View file

@ -26,7 +26,7 @@ _ADMIN_PAGES = [
("Cron", "/cron", "alarm-clock"), ("Cron", "/cron", "alarm-clock"),
("Logs", "/logs", "file-text"), ("Logs", "/logs", "file-text"),
("Utilisateurs", "/users", "user-cog"), ("Utilisateurs", "/users", "user-cog"),
("Parametres", "/params", "settings"), ("Paramètres", "/params", "settings"),
] ]

3733
src/app.py

File diff suppressed because it is too large Load diff

View file

@ -37,7 +37,7 @@ def import_pdf(
imported_by: str = "system", imported_by: str = "system",
force: bool = False, force: bool = False,
) -> ImportResult: ) -> 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é. Déduplication : (apprenti_id, date, periode) déjà présent ignoré.
Si force=True, réinitialise le statut de toutes les absences existantes Si force=True, réinitialise le statut de toutes les absences existantes