diff --git a/assets/responsive.css b/assets/responsive.css index 5406068..3fbf2be 100644 --- a/assets/responsive.css +++ b/assets/responsive.css @@ -37,6 +37,47 @@ --quote-font-family: var(--default-font-family); } +/* ── Brand tokens (thèmes utilisateur) ─────────────────────────────────────── + Tokens utilisés par l'app pour les couleurs de marque. Chaque thème surcharge + ces variables via [data-theme="..."] sur . + Les couleurs sémantiques des notes (rouge<4 / orange<5 / vert>=5) restent + hardcodées dans fiche.py / classe.py et NE doivent PAS utiliser ces tokens. */ +:root { + --brand-primary: #dc000e; /* EPTM red, theme-color meta */ + --brand-primary-dark: #c62828; /* KPI rouges, sidebar active */ + --brand-primary-tint: rgba(220, 0, 14, 0.18); /* sidebar active bg */ + --brand-primary-light: #ff4a54; /* sidebar active text */ + --brand-accent: #1565c0; /* liens, infos, sélection */ + --brand-accent-soft: #e3f2fd; /* fond pâle pour bannières d'info */ +} + +[data-theme="bleu"] { + --brand-primary: #1565c0; + --brand-primary-dark: #0d47a1; + --brand-primary-tint: rgba(21, 101, 192, 0.18); + --brand-primary-light: #42a5f5; + --brand-accent: #1976d2; + --brand-accent-soft: #e3f2fd; +} + +[data-theme="indigo"] { + --brand-primary: #3f51b5; + --brand-primary-dark: #283593; + --brand-primary-tint: rgba(63, 81, 181, 0.18); + --brand-primary-light: #7986cb; + --brand-accent: #5c6bc0; + --brand-accent-soft: #e8eaf6; +} + +[data-theme="vert"] { + --brand-primary: #2e7d32; + --brand-primary-dark: #1b5e20; + --brand-primary-tint: rgba(46, 125, 50, 0.18); + --brand-primary-light: #66bb6a; + --brand-accent: #00695c; + --brand-accent-soft: #e8f5e9; +} + body { font-family: var(--default-font-family); /* Activations Inter : cv11 = 1 sans empattement, ss01 = a/g modernes, diff --git a/data/auth.yaml b/data/auth.yaml index dc531db..852d755 100644 --- a/data/auth.yaml +++ b/data/auth.yaml @@ -11,6 +11,7 @@ credentials: password: $2b$12$kigcAqfs9VIySuVHxenU6uTyk/8ef7DrzybCFCzw.iZOZTpzxVsOi role: admin smtp_password: 17acdfd671d8ab + theme: bleu totp_secret: H6QDWOPHK4GBT447VCKI6VDKEEUVFQZY test: allowed_classes: diff --git a/data/class_href_cache.json b/data/class_href_cache.json index 1c021cd..74d9f0f 100644 --- a/data/class_href_cache.json +++ b/data/class_href_cache.json @@ -32,15 +32,15 @@ "MP1-TASV 4D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3bc53c77-bda8-43a4-ab47-08c2eb84a917", "MP1-TASV 4E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7673335b-a2aa-42de-9cab-6b95ffc90d7c", "Z-IT Test 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f0707cc3-4511-403b-a2a6-d79f2da4fd99", - "AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=9f1ccf7f-d9fe-4618-bd54-b623c1f86e2b", - "AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=6ca387b9-988c-47fb-a172-4922653ccef7", - "AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=45e09633-a21f-4859-a994-a0cd643428de", - "AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f328bcb8-aa88-4383-b18f-11a829f6f755", - "EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=aecd7352-f131-4395-a530-4a4551ab83c1", - "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=2087d329-f44e-4a42-9621-b75a8f935a08", - "EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=cddbebeb-d574-45dc-b0b1-a17ed4baf2cf", - "EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=053894a2-174b-4716-9ca5-26ade1d21891", - "MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=db1e76bd-c224-4cc3-bc80-0f5881a11550", - "MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=0a2cf951-9097-4207-9560-1a72562c01a8", - "MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=1c431c09-25de-4640-b887-930d028bfbb5" + "AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=47666e48-95f2-4607-b1c6-fa1bd72c79a2", + "AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=ffa9ae4e-4531-428b-88ad-7dd3684bdc8f", + "AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a3c8ce5a-9636-44ed-8bff-d52def0a72a1", + "AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=391befbf-cf01-4eed-b18c-23c4a86e8d75", + "EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=cdbc227f-c8b5-498f-b8c8-9ef8bdc18e91", + "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=876e70ab-fdfa-40ad-bc73-9fa05a08135c", + "EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=bc53830f-a121-4fc6-a88b-5495f5ba3d28", + "EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=cbaceaff-133a-4d64-930f-3fb79ecbc795", + "MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=ad7c4d6b-ddb9-4414-b218-d98249fe559d", + "MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=c4609f4e-5176-4ad8-b115-2a789f1d82de", + "MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b17daa90-89fa-4a10-a5aa-b2433c503aac" } \ No newline at end of file diff --git a/eptm_dashboard/eptm_dashboard.py b/eptm_dashboard/eptm_dashboard.py index bfc5109..3e893af 100644 --- a/eptm_dashboard/eptm_dashboard.py +++ b/eptm_dashboard/eptm_dashboard.py @@ -13,11 +13,16 @@ from .pages.purge import purge_page, PurgeState from .pages.doc import doc_page, DocState from .pages.profile import profile_page, ProfileState from .pages.password_set import password_set_page, PasswordSetState -from .pages.retenue import retenue_page, RetenueState +# RetenueState et SanctionState sont utilisés via modal dans /fiche +from .pages.retenue import RetenueState +from .pages.sanction import SanctionState TITLE = "EPTM Dashboard" app = rx.App( + # Note: theme=... est configuré dans rxconfig.py via RadixThemesPlugin + # (force appearance="light", ignore dark mode OS). Les thèmes user sont + # gérés via tokens CSS dans responsive.css. stylesheets=["/responsive.css"], head_components=[ rx.el.link(rel="icon", type="image/png", href="/favicon.png"), @@ -38,6 +43,21 @@ app = rx.App( type="font/woff2", crossorigin="anonymous", ), + # Applique le thème stocké en localStorage avant le premier render — + # évite un flash au défaut EPTM puis bascule. + rx.el.script( + """ + (function() { + try { + var t = localStorage.getItem('theme'); + if (t && t !== 'eptm') { + document.documentElement.setAttribute('data-theme', t); + document.body && document.body.setAttribute('data-theme', t); + } + } catch(e) {} + })(); + """ + ), ], ) @@ -59,6 +79,5 @@ app.add_page(params_page, route="/params", on_load=[AuthState.check_auth, app.add_page(purge_page, route="/purge", on_load=[AuthState.check_auth, PurgeState.load_data], title=TITLE) app.add_page(doc_page, route="/doc", on_load=[AuthState.check_auth, DocState.load_data], title=TITLE) app.add_page(profile_page, route="/profile", on_load=[AuthState.check_auth, ProfileState.load_data], title=TITLE) -app.add_page(retenue_page, route="/retenue", on_load=[AuthState.check_auth, RetenueState.load_data], title=TITLE) # Page publique (pas de check_auth — accessible via lien email) app.add_page(password_set_page, route="/password-set", on_load=PasswordSetState.load_data, title=TITLE) diff --git a/eptm_dashboard/pages/accueil.py b/eptm_dashboard/pages/accueil.py index 2bb5702..56f841a 100644 --- a/eptm_dashboard/pages/accueil.py +++ b/eptm_dashboard/pages/accueil.py @@ -307,10 +307,10 @@ def accueil_page() -> rx.Component: rx.heading("Notes insuffisantes (BN / Matu < 4.0)", size="5"), rx.box( rx.flex( - rx.icon("info", size=16, color="#1565c0"), + rx.icon("info", size=16, color="var(--brand-accent)"), rx.text( "Migration en cours — disponible prochainement.", - color="#1565c0", size="2", + color="var(--brand-accent)", size="2", ), gap="0.5rem", align="center", ), diff --git a/eptm_dashboard/pages/classe.py b/eptm_dashboard/pages/classe.py index 376fc42..3f8013d 100644 --- a/eptm_dashboard/pages/classe.py +++ b/eptm_dashboard/pages/classe.py @@ -709,10 +709,10 @@ def _apprenti_card(item) -> rx.Component: rx.flex( _kpi_mini("Total", item["total"]), _kpi_mini("Excusees", item["excusees"], "#2e7d32"), - _kpi_mini("Non excusees", item["non_exc"], "#c62828"), + _kpi_mini("Non excusees", item["non_exc"], "var(--brand-primary-dark)"), rx.cond( item["quota_atteint"], - _kpi_mini("Absences", item["blocs"], "#c62828"), + _kpi_mini("Absences", item["blocs"], "var(--brand-primary-dark)"), _kpi_mini("Absences", item["blocs"]), ), gap="0.5rem", diff --git a/eptm_dashboard/pages/cron.py b/eptm_dashboard/pages/cron.py index 3e0e3a5..fabe3b7 100644 --- a/eptm_dashboard/pages/cron.py +++ b/eptm_dashboard/pages/cron.py @@ -31,6 +31,16 @@ _DAY_LABELS = { "FRI": "Ven", "SAT": "Sam", "SUN": "Dim", } +# Libellés des task_kinds. Le choix _quoi traiter_ (Absences, BN+Matu, Notes, +# Fiches, Notices) est porté par des cases à cocher séparées, pas par le +# task_kind lui-même. +_TASK_KINDS = ["push", "sync", "push_then_sync"] +_TASK_LABELS = { + "push": "Push (envoyer vers Escada)", + "sync": "Sync (télécharger depuis Escada)", + "push_then_sync": "Push puis Sync", +} + # ── State ───────────────────────────────────────────────────────────────────── @@ -54,6 +64,7 @@ class CronState(AuthState): f_sync_bn: bool = True f_sync_notes: bool = True f_sync_fiches: bool = False + f_sync_notices: bool = False f_force_abs: bool = False f_classes_all: bool = True f_classes: list[str] = [] @@ -82,9 +93,7 @@ class CronState(AuthState): "enabled": job.enabled, "schedule_desc": desc, "task_kind": job.task_kind, - "task_label": {"push": "Push", "sync": "Sync", - "push_then_sync": "Push + Sync", - "push_notices": "Push notices"}.get(job.task_kind, job.task_kind), + "task_label": _TASK_LABELS.get(job.task_kind, job.task_kind), "last_run_at": job.last_run_at.strftime("%d.%m.%Y %H:%M") if job.last_run_at else "", "last_status": job.last_status, "last_message": job.last_message[:120] if job.last_message else "", @@ -191,6 +200,7 @@ class CronState(AuthState): self.f_sync_bn = True self.f_sync_notes = True self.f_sync_fiches = False + self.f_sync_notices = False self.f_force_abs = False self.f_classes_all = True self.f_classes = [] @@ -235,12 +245,13 @@ class CronState(AuthState): self.f_time_mm = "00" self.f_days = ["MON", "TUE", "WED", "THU", "FRI"] - self.f_task_kind = job.task_kind - self.f_sync_abs = job.sync_abs - self.f_sync_bn = job.sync_bn - self.f_sync_notes = job.sync_notes - self.f_sync_fiches = job.sync_fiches - self.f_force_abs = job.force_abs + self.f_task_kind = job.task_kind + self.f_sync_abs = job.sync_abs + self.f_sync_bn = job.sync_bn + self.f_sync_notes = job.sync_notes + self.f_sync_fiches = job.sync_fiches + self.f_sync_notices = bool(getattr(job, "sync_notices", False)) + self.f_force_abs = job.force_abs classes_raw = (job.classes_json or "ALL").strip() if classes_raw == "ALL": @@ -290,6 +301,7 @@ class CronState(AuthState): def set_f_sync_bn(self, v: bool): self.f_sync_bn = v def set_f_sync_notes(self, v: bool): self.f_sync_notes = v def set_f_sync_fiches(self, v: bool): self.f_sync_fiches = v + def set_f_sync_notices(self, v: bool): self.f_sync_notices = v def set_f_force_abs(self, v: bool): self.f_force_abs = v def set_f_classes_all(self, v: bool): self.f_classes_all = v def toggle_f_class(self, c: str): @@ -366,6 +378,7 @@ class CronState(AuthState): sync_bn=self.f_sync_bn, sync_notes=self.f_sync_notes, sync_fiches=self.f_sync_fiches, + sync_notices=self.f_sync_notices, force_abs=self.f_force_abs, classes_json=classes_json, notify_on=self.f_notify_on, @@ -389,6 +402,7 @@ class CronState(AuthState): job.sync_bn = self.f_sync_bn job.sync_notes = self.f_sync_notes job.sync_fiches = self.f_sync_fiches + job.sync_notices = self.f_sync_notices job.force_abs = self.f_force_abs job.classes_json = classes_json job.notify_on = self.f_notify_on @@ -648,53 +662,75 @@ def _form_schedule_picker() -> rx.Component: def _form_task_picker() -> rx.Component: return rx.vstack( rx.text("Tâche", size="2", font_weight="600"), - rx.radio( - ["push", "sync", "push_then_sync", "push_notices"], + rx.radio_group.root( + rx.vstack( + *[ + rx.flex( + rx.radio_group.item(value=k), + rx.text(_TASK_LABELS[k], size="2"), + gap="0.5rem", align="center", + ) + for k in _TASK_KINDS + ], + spacing="2", + ), value=CronState.f_task_kind, on_change=CronState.set_f_task_kind, - direction="column", ), - rx.cond( - (CronState.f_task_kind != "push") & (CronState.f_task_kind != "push_notices"), - rx.vstack( - rx.text("Données à synchroniser", size="2", font_weight="600", - margin_top="0.5rem"), - rx.flex( - rx.hstack( - rx.checkbox(checked=CronState.f_sync_abs, - on_change=CronState.set_f_sync_abs, size="2"), - rx.text("Absences", size="2"), - spacing="2", align="center", - ), - rx.hstack( - rx.checkbox(checked=CronState.f_sync_bn, - on_change=CronState.set_f_sync_bn, size="2"), - rx.text("BN + Matu", size="2"), - spacing="2", align="center", - ), - rx.hstack( - rx.checkbox(checked=CronState.f_sync_notes, - on_change=CronState.set_f_sync_notes, size="2"), - rx.text("Notes d'examen", size="2"), - spacing="2", align="center", - ), - rx.hstack( - rx.checkbox(checked=CronState.f_sync_fiches, - on_change=CronState.set_f_sync_fiches, size="2"), - rx.text("Fiches apprentis", size="2"), - spacing="2", align="center", - ), - gap="0.5rem 1.25rem", - flex_wrap="wrap", + rx.vstack( + rx.text("Données concernées", size="2", font_weight="600", + margin_top="0.5rem"), + rx.flex( + rx.hstack( + rx.checkbox(checked=CronState.f_sync_abs, + on_change=CronState.set_f_sync_abs, size="2"), + rx.text("Absences", size="2"), + spacing="2", align="center", ), + rx.hstack( + rx.checkbox(checked=CronState.f_sync_notices, + on_change=CronState.set_f_sync_notices, size="2"), + rx.text("Notices", size="2"), + spacing="2", align="center", + ), + # BN+Matu / Notes / Fiches : pertinent uniquement pour sync. + rx.cond( + CronState.f_task_kind != "push", + rx.flex( + rx.hstack( + rx.checkbox(checked=CronState.f_sync_bn, + on_change=CronState.set_f_sync_bn, size="2"), + rx.text("BN + Matu", size="2"), + spacing="2", align="center", + ), + rx.hstack( + rx.checkbox(checked=CronState.f_sync_notes, + on_change=CronState.set_f_sync_notes, size="2"), + rx.text("Notes d'examen", size="2"), + spacing="2", align="center", + ), + rx.hstack( + rx.checkbox(checked=CronState.f_sync_fiches, + on_change=CronState.set_f_sync_fiches, size="2"), + rx.text("Fiches apprentis", size="2"), + spacing="2", align="center", + ), + gap="0.5rem 1.25rem", flex_wrap="wrap", + ), + ), + gap="0.5rem 1.25rem", + flex_wrap="wrap", + ), + rx.cond( + CronState.f_task_kind != "push", rx.hstack( rx.checkbox(checked=CronState.f_force_abs, on_change=CronState.set_f_force_abs, size="2"), rx.text("Forcer le retéléchargement des PDFs absences", size="2"), spacing="2", align="center", ), - spacing="2", ), + spacing="2", ), spacing="2", width="100%", ) diff --git a/eptm_dashboard/pages/escada.py b/eptm_dashboard/pages/escada.py index f496c5b..17294e6 100644 --- a/eptm_dashboard/pages/escada.py +++ b/eptm_dashboard/pages/escada.py @@ -46,6 +46,7 @@ CLASSES_CACHE = DATA_DIR / "esacada_classes.json" _SYNC_SCRIPT = _ROOT / "scripts" / "sync_esacada.py" _PUSH_SCRIPT = _ROOT / "scripts" / "push_to_escada.py" _PUSH_NOTICES_SCRIPT = _ROOT / "scripts" / "push_notices.py" +_PULL_NOTICES_SCRIPT = _ROOT / "scripts" / "pull_notices.py" _SYNC_RESULT_FILE = DATA_DIR / "sync_last_result.json" _SYNC_ALL_DONE_FILE = DATA_DIR / "sync_all_done.json" @@ -56,11 +57,13 @@ class EscadaState(AuthState): classes_cache: list[str] = [] class_checked: dict[str, bool] = {} - sync_abs: bool = True - sync_bn: bool = True - sync_notes: bool = True - sync_fiches: bool = False - force_abs: bool = False + sync_abs: bool = True + sync_bn: bool = True + sync_notes: bool = True + sync_fiches: bool = False + sync_notices: bool = False + force_abs: bool = False + force_notices: bool = False is_refreshing: bool = False is_syncing: bool = False @@ -91,6 +94,13 @@ class EscadaState(AuthState): notices_push_done: bool = False notices_push_errors: list[str] = [] + # Pull notices (depuis Escada vers DB) + is_pulling_notices: bool = False + notices_pull_done: bool = False + notices_pull_imported: int = 0 + notices_pull_ok: int = 0 + notices_pull_errors: list[str] = [] + @rx.var def selected_count(self) -> int: return sum(1 for v in self.class_checked.values() if v) @@ -125,9 +135,11 @@ class EscadaState(AuthState): def set_sync_abs(self, v: bool): self.sync_abs = v def set_sync_bn(self, v: bool): self.sync_bn = v - def set_sync_notes(self, v: bool): self.sync_notes = v - def set_sync_fiches(self, v: bool): self.sync_fiches = v - def set_force_abs(self, v: bool): self.force_abs = v + def set_sync_notes(self, v: bool): self.sync_notes = v + def set_sync_fiches(self, v: bool): self.sync_fiches = v + def set_sync_notices(self, v: bool): self.sync_notices = v + def set_force_abs(self, v: bool): self.force_abs = v + def set_force_notices(self, v: bool): self.force_notices = v def _clear_results(self): self.sync_done = False @@ -256,6 +268,28 @@ class EscadaState(AuthState): for n in notices ] + def delete_notice(self, notice_id: int): + """Supprime une notice pending de la file d'attente.""" + sess = get_session() + label = "" + try: + n = sess.get(Notice, notice_id) + if n: + ap = n.apprenti + label = ( + f"{ap.nom} {ap.prenom}" if ap else f"id={notice_id}" + ) + sess.delete(n) + sess.commit() + self._reload_notices(sess) + self.notices_count = len(self.notices_data) + finally: + sess.close() + if label: + app_log(f"[notice] {self.username or '?'} : suppression manuelle pour {label}") + return rx.toast.success(f"Notice supprimée — {label}") + return rx.toast.info("Notice introuvable") + # ── Background: refresh classes ──────────────────────────────────────────── @_background @@ -404,13 +438,15 @@ class EscadaState(AuthState): @_background async def sync_escada(self): async with self: # SEUL async with self: de cette background task - selected = [c for c, v in self.class_checked.items() if v] - sync_abs = self.sync_abs - sync_bn = self.sync_bn - sync_notes = self.sync_notes - sync_fiches = self.sync_fiches - force_abs = self.force_abs - username = self.username or "escada" + selected = [c for c, v in self.class_checked.items() if v] + sync_abs = self.sync_abs + sync_bn = self.sync_bn + sync_notes = self.sync_notes + sync_fiches = self.sync_fiches + sync_notices = self.sync_notices + force_abs = self.force_abs + force_notices = self.force_notices + username = self.username or "escada" if not selected: return self.is_syncing = True @@ -423,10 +459,11 @@ class EscadaState(AuthState): self.sync_res_matu = [] _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") + 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") + if sync_notices: _types.append("notices") _types_label = ", ".join(_types) or "—" app_log( f"Sync Escada démarrée par {username} — " @@ -640,6 +677,9 @@ class EscadaState(AuthState): # ── État final — async with self #3 ────────────────────────────────────── app_log(f"Poll terminé — result_ready={_result_ready}") _uncancel() + # Le sync_done final est posé APRÈS le pull notices (si activé), pour + # que la UI affiche "Pull notices en cours" et pas "terminé" trop tôt. + _will_pull_notices = sync_notices and _result_ready async with self: self.import_in_progress = False if _result_ready: @@ -648,22 +688,155 @@ class EscadaState(AuthState): self.sync_res_notes = _result_data.get("res_notes", []) self.sync_res_matu = _result_data.get("res_matu", []) self.sync_errors = _result_data.get("errors", []) - self.sync_done = True - app_log("Résultats chargés — sync terminée OK") + # Pas encore sync_done=True : on attend le pull notices + if not _will_pull_notices: + self.sync_done = True + else: + self.is_pulling_notices = True + app_log("Résultats chargés — sync principal terminée OK") _nb_err = len(self.sync_errors) else: self.sync_errors = ["Import timeout — vérifiez les logs (> 15min)."] + self.sync_done = True # finalisation (échec) _nb_err = 1 - if _result_ready: + if _result_ready and not _will_pull_notices: if _nb_err == 0: yield rx.toast.success("Synchronisation Escada terminée") else: yield rx.toast.warning( f"Synchronisation terminée avec {_nb_err} erreur(s)" ) - else: + elif not _result_ready: yield rx.toast.error("Import timeout — vérifiez les logs (> 15min)") + # ── Étape supplémentaire : pull des notices ───────────────────────── + if sync_notices and _result_ready: + # Si forcer : supprime les notices pending (push queue) des apprentis + # des classes ciblées AVANT le pull. + if force_notices: + try: + from sqlalchemy import select as _sel, delete as _del + from src.db import get_session as _gs, Apprenti as _Ap, Notice as _Nt + _sess = _gs() + try: + _ap_ids = list(_sess.execute( + _sel(_Ap.id).where(_Ap.classe.in_(selected)) + ).scalars().all()) + if _ap_ids: + _n = _sess.execute( + _del(_Nt).where(_Nt.apprenti_id.in_(_ap_ids)) + ).rowcount or 0 + _sess.commit() + app_log( + f"[pull_notices] force=True → " + f"{_n} notice(s) pending supprimée(s) avant pull" + ) + finally: + _sess.close() + except Exception as _e: + app_log(f"[pull_notices] erreur purge force : {_e}") + + app_log(f"Pull notices Escada démarré (post-sync) — {len(selected)} classe(s)") + _notices_cmd = [sys.executable, str(_PULL_NOTICES_SCRIPT), *selected] + _notices_lines: list[str] = [] + + def _run_notices() -> None: + _fd, _tmp = tempfile.mkstemp(suffix="_pull_notices.log") + os.close(_fd) + try: + with open(_tmp, "wb") as _fout: + _proc = subprocess.Popen( + _notices_cmd, stdout=_fout, stderr=subprocess.STDOUT, + env={**os.environ, "PYTHONUNBUFFERED": "1"}, + start_new_session=True, + ) + _offset = 0 + _buf = b"" + while True: + _time.sleep(0.5) + try: + with open(_tmp, "rb") as _fin: + _fin.seek(_offset); _chunk = _fin.read(65536) + except Exception: + _chunk = b"" + if _chunk: + _buf += _chunk; _offset += len(_chunk) + while b"\n" in _buf: + _raw, _buf = _buf.split(b"\n", 1) + _ln = _raw.decode("utf-8", errors="replace").rstrip() + if _ln: + _notices_lines.append(_ln) + _log_sync_line(_ln, prefix="pull_notices") + if _proc.poll() is not None: + _proc.wait() + break + except Exception as _exc: + app_log(f"Erreur pull notices subprocess : {_exc}") + finally: + try: os.unlink(_tmp) + except Exception: pass + + _pool2 = _cf.ThreadPoolExecutor(max_workers=1) + _fut2 = _pool2.submit(_run_notices) + try: + while not _fut2.done(): + try: + await asyncio.sleep(1.0) + except asyncio.CancelledError: + _t = asyncio.current_task() + if _t is not None: + for _ in range(_t.cancelling()): + _t.uncancel() + try: + _fut2.result() + except Exception as _te: + app_log(f"[pull_notices] thread exception : {_te}") + finally: + _pool2.shutdown(wait=False) + + _nb_imported = 0 + _nb_ok = 0 + _notices_err: list[str] = [] + for _ln in _notices_lines: + if "PULL_NOTICES_DONE " in _ln: + try: + _p = json.loads(_ln[_ln.index("PULL_NOTICES_DONE ") + len("PULL_NOTICES_DONE "):]) + _nb_ok = _p.get("ok", 0) + _nb_imported = _p.get("imported", 0) + _notices_err = _p.get("err", []) + except Exception: + pass + + try: + _t = asyncio.current_task() + if _t is not None: + for _ in range(_t.cancelling()): + _t.uncancel() + async with self: + self.notices_pull_done = True + self.notices_pull_ok = _nb_ok + self.notices_pull_imported = _nb_imported + self.notices_pull_errors = _notices_err + # Le sync complet est maintenant terminé : on libère l'UI + self.is_pulling_notices = False + self.sync_done = True + except Exception: + pass + + app_log( + f"Pull notices terminé — {_nb_ok} apprenti(s), " + f"{_nb_imported} notice(s), {len(_notices_err)} erreur(s)" + ) + if _notices_err: + yield rx.toast.warning( + f"Notices : {_nb_imported} importée(s), {len(_notices_err)} erreur(s)" + ) + else: + yield rx.toast.success( + f"Synchronisation Escada terminée — {_nb_imported} notice(s) " + f"importée(s) sur {_nb_ok} apprenti(s)" + ) + # ── Background: push vers Escada ─────────────────────────────────────────── @_background @@ -914,6 +1087,136 @@ class EscadaState(AuthState): except Exception: pass + # ── Background: pull notices depuis Escada ──────────────────────────────── + + @_background + async def pull_notices(self): + async with self: + selected = [c for c, v in self.class_checked.items() if v] + user = self.username or "?" + if not selected: + return + self.is_pulling_notices = True + self.notices_pull_done = False + self.notices_pull_imported = 0 + self.notices_pull_ok = 0 + self.notices_pull_errors = [] + + app_log( + f"Pull notices Escada démarré par {user} — " + f"{len(selected)} classe(s) : {', '.join(selected)}" + ) + + cmd = [sys.executable, str(_PULL_NOTICES_SCRIPT), *selected] + lines: list[str] = [] + _rc_holder = [0] + + def _run() -> None: + _fd, _tmp = tempfile.mkstemp(suffix="_pull_notices.log") + os.close(_fd) + try: + with open(_tmp, "wb") as _fout: + _proc = subprocess.Popen( + cmd, stdout=_fout, stderr=subprocess.STDOUT, + env={**os.environ, "PYTHONUNBUFFERED": "1"}, + start_new_session=True, + ) + _offset, _buf = 0, b"" + while True: + _time.sleep(0.5) + try: + with open(_tmp, "rb") as _fin: + _fin.seek(_offset); _chunk = _fin.read(65536) + except Exception: + _chunk = b"" + if _chunk: + _buf += _chunk; _offset += len(_chunk) + while b"\n" in _buf: + _raw, _buf = _buf.split(b"\n", 1) + _ln = _raw.decode("utf-8", errors="replace").rstrip() + if _ln: + lines.append(_ln); _log_sync_line(_ln, prefix="pull_notices") + if _proc.poll() is not None: + _rc_holder[0] = _proc.wait() or 0 + break + except Exception as _exc: + app_log(f"Erreur pull notices subprocess : {_exc}") + finally: + try: os.unlink(_tmp) + except Exception: pass + + _pool = _cf.ThreadPoolExecutor(max_workers=1) + _fut = _pool.submit(_run) + try: + while not _fut.done(): + try: + await asyncio.sleep(1.0) + except asyncio.CancelledError: + _t = asyncio.current_task() + if _t is not None: + for _ in range(_t.cancelling()): + _t.uncancel() + try: + _fut.result() + except Exception as _te: + app_log(f"[pull_notices] thread exception : {_te}") + finally: + _pool.shutdown(wait=False) + + _rc = _rc_holder[0] + nb_ok = 0 + nb_imported = 0 + errors: list[str] = [] + done = False + for line in lines: + if "PULL_NOTICES_DONE " in line: + done = True + try: + p = json.loads(line[line.index("PULL_NOTICES_DONE ") + len("PULL_NOTICES_DONE "):]) + nb_ok = p.get("ok", 0) + nb_imported = p.get("imported", 0) + errors = p.get("err", []) + except Exception as _e: + app_log(f" Erreur parse PULL_NOTICES_DONE : {_e}", debug=True) + + if done: + app_log( + f"Pull notices terminé — {nb_ok} apprenti(s), " + f"{nb_imported} notice(s), {len(errors)} erreur(s)" + ) + else: + app_log(f"Pull notices : PULL_NOTICES_DONE non trouvé (code={_rc})") + + try: + _t = asyncio.current_task() + if _t is not None: + for _ in range(_t.cancelling()): + _t.uncancel() + async with self: + self.notices_pull_done = done + self.notices_pull_ok = nb_ok + self.notices_pull_imported = nb_imported + self.notices_pull_errors = errors + self.is_pulling_notices = False + if done: + if errors: + yield rx.toast.warning( + f"Pull notices : {nb_imported} importée(s), {len(errors)} erreur(s)" + ) + else: + yield rx.toast.success( + f"Pull notices terminé — {nb_imported} notice(s) sur {nb_ok} apprenti(s)" + ) + else: + yield rx.toast.error("Pull notices échoué — vérifiez les logs") + except Exception as _e: + app_log(f"Erreur mise à jour état pull notices : {_e}") + try: + async with self: + self.is_pulling_notices = False + except Exception: + pass + # ── UI helpers ──────────────────────────────────────────────────────────────── @@ -1088,6 +1391,47 @@ def _notice_row(item) -> rx.Component: rx.table.cell( rx.badge(item["source"], color_scheme="blue", variant="soft", size="1"), ), + rx.table.cell( + rx.alert_dialog.root( + rx.alert_dialog.trigger( + rx.icon_button( + rx.icon("trash-2", size=12), + color_scheme="red", + variant="ghost", + size="1", + ), + ), + rx.alert_dialog.content( + rx.alert_dialog.title("Supprimer cette notice ?"), + rx.alert_dialog.description( + rx.vstack( + rx.text( + rx.text.strong(item["nom"], " ", item["prenom"]), + " — ", + item["date"], + size="2", + ), + rx.text(item["titre"], size="1", color="var(--gray-11)"), + spacing="1", + ), + ), + rx.flex( + rx.alert_dialog.cancel( + rx.button("Annuler", variant="soft", color_scheme="gray"), + ), + rx.alert_dialog.action( + rx.button( + "Supprimer", + color_scheme="red", + on_click=EscadaState.delete_notice(item["id"]), + ), + ), + spacing="3", justify="end", margin_top="1rem", + ), + max_width="420px", + ), + ), + ), ) @@ -1103,7 +1447,7 @@ def _sync_progress() -> rx.Component: rx.vstack( rx.text( "Synchronisation Escadaweb en cours...", - size="3", font_weight="600", color="#1565c0", + size="3", font_weight="600", color="var(--brand-accent)", ), rx.text( "Téléchargement depuis escadaweb.vs.ch (1-3 min)", @@ -1159,6 +1503,34 @@ def _sync_progress() -> rx.Component: ), ), + # Phase 3 : pull notices (uniquement si option Notices cochée) + rx.cond( + EscadaState.is_pulling_notices, + rx.box( + rx.hstack( + rx.spinner(size="3"), + rx.vstack( + rx.text( + "Récupération des notices Escada en cours…", + size="3", font_weight="600", color="#0891b2", + ), + rx.text( + "Scrape des notices de chaque apprenti (peut prendre plusieurs minutes)", + size="2", color="#555", + ), + spacing="0", + ), + align="center", + spacing="3", + ), + padding="1rem", + background_color="#ecfeff", + border_radius="8px", + border="1px solid #67e8f9", + width="100%", + ), + ), + # Résultats rx.cond( EscadaState.sync_done, @@ -1228,7 +1600,7 @@ def _sync_progress() -> rx.Component: rx.cond( ~EscadaState.import_in_progress, rx.callout.root( - rx.callout.icon(rx.icon("alert-circle", size=16)), + rx.callout.icon(rx.icon("triangle-alert", size=16)), rx.callout.text( rx.foreach( EscadaState.sync_errors, @@ -1343,38 +1715,75 @@ def escada_page() -> rx.Component: rx.text("Données apprentis", size="2"), gap="0.4rem", align="center", ), + rx.flex( + rx.checkbox(checked=EscadaState.sync_notices, + on_change=EscadaState.set_sync_notices, size="2"), + rx.text("Notices", size="2"), + gap="0.4rem", align="center", + ), gap="1rem", flex_wrap="wrap", ), - rx.cond( - EscadaState.sync_abs, + # Force re-importation — cases à cocher pour Absences / Notices + rx.box( 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.icon("triangle-alert", size=14, color="#b45309"), rx.text( - "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", + "Lors de l'import, si des modifications sont en " + "attente (absences, notices) elles ne seront ni " + "écrasées, ni mises à jour. Cocher les cases " + "ci-dessous pour forcer l'import et supprimer " + "les modifications en attente.", + size="2", color="#92400e", font_weight="500", ), - gap="0.5rem", - align="center", - padding="0.5rem 0.75rem", - background_color="#fef3c7", - border="1px solid #fcd34d", - border_radius="6px", - flex_wrap="wrap", + gap="0.5rem", align="start", + margin_bottom="0.5rem", ), + rx.flex( + rx.flex( + rx.checkbox( + checked=EscadaState.force_abs, + on_change=EscadaState.set_force_abs, + size="2", + color_scheme="amber", + disabled=~EscadaState.sync_abs, + ), + rx.text( + "Absences", + size="2", + color=rx.cond( + EscadaState.sync_abs, "#92400e", "#cbd5e1", + ), + font_weight="600", + ), + gap="0.4rem", align="center", + ), + rx.flex( + rx.checkbox( + checked=EscadaState.force_notices, + on_change=EscadaState.set_force_notices, + size="2", + color_scheme="amber", + disabled=~EscadaState.sync_notices, + ), + rx.text( + "Notices", + size="2", + color=rx.cond( + EscadaState.sync_notices, "#92400e", "#cbd5e1", + ), + font_weight="600", + ), + gap="0.4rem", align="center", + ), + gap="1.5rem", flex_wrap="wrap", + ), + padding="0.75rem", + background_color="#fef3c7", + border="1px solid #fcd34d", + border_radius="6px", + width="100%", ), # Bouton Synchroniser @@ -1423,7 +1832,7 @@ def escada_page() -> rx.Component: # ── Section push vers Escada ─────────────────────────────────────── rx.box( rx.text( - "Pousser vers Escada", + "Pousser les absences en attente sur Escada", size="3", font_weight="700", color="#37474f", margin_bottom="0.75rem", ), @@ -1476,7 +1885,7 @@ def escada_page() -> rx.Component: rx.text("Pousser vers Escada"), ), on_click=EscadaState.push_escada, - disabled=EscadaState.is_busy, + disabled=EscadaState.is_busy | (EscadaState.pending_count == 0), color_scheme="red", size="2", ), @@ -1523,7 +1932,7 @@ def escada_page() -> rx.Component: # ── Section notices ─────────────────────────────────────────────── rx.box( rx.text( - "Notices en attente", + "Pousser les notices en attente sur Escada", size="3", font_weight="700", color="#37474f", margin_bottom="0.75rem", ), @@ -1545,6 +1954,7 @@ def escada_page() -> rx.Component: rx.table.column_header_cell("Date"), rx.table.column_header_cell("Titre"), rx.table.column_header_cell("Source"), + rx.table.column_header_cell("", width="40px"), ) ), rx.table.body( diff --git a/eptm_dashboard/pages/fiche.py b/eptm_dashboard/pages/fiche.py index 3a665cb..1b58cad 100644 --- a/eptm_dashboard/pages/fiche.py +++ b/eptm_dashboard/pages/fiche.py @@ -16,6 +16,7 @@ from ..sidebar import layout from src.db import ( get_session, Apprenti, Absence, ApprentiFiche, NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu, + ApprentiNotice, upsert_escada_pending, ) from src.stats import nb_blocs_absences @@ -24,6 +25,8 @@ from src.email_sender import build_template_vars, render_template from src.logger import app_log from src.user_access import get_allowed_classes, is_class_allowed from ..components import empty_state +from .retenue import RetenueState, retenue_modal +from .sanction import SanctionState, sanction_modal MOIS_FR = [ "janvier", "fevrier", "mars", "avril", "mai", "juin", @@ -459,6 +462,10 @@ class FicheState(AuthState): has_pdf_bn: bool = False has_pdf_notes: bool = False + # ── Notices Escada ──────────────────────────────────────────────────────── + has_notices: bool = False + notices_data: list[dict] = [] + # ── Email ───────────────────────────────────────────────────────────────── smtp_ok: bool = False email_dest: str = "apprenti" @@ -1028,6 +1035,25 @@ class FicheState(AuthState): self.has_notes = False self.notes_html = "" + # ── Notices Escada ────────────────────────────────────────────────── + notices_list = sess.execute( + select(ApprentiNotice) + .where(ApprentiNotice.apprenti_id == self.selected_id) + .order_by(ApprentiNotice.date_event.desc()) + ).scalars().all() + self.has_notices = len(notices_list) > 0 + self.notices_data = [ + { + "date": n.date_event.strftime("%d.%m.%Y") if n.date_event else "", + "type": n.type_notice or "", + "auteur": n.auteur or "", + "titre": n.titre or "", + "remarque": n.remarque or "", + "matiere": n.matiere or "", + } + for n in notices_list + ] + pdf_dir = DATA_DIR / "pdfs" self.has_pdf_bn = bool(self.bn_pdf_fichier) and (pdf_dir / self.bn_pdf_fichier).exists() apprenti = sess.get(Apprenti, self.selected_id) @@ -1212,7 +1238,7 @@ def _cal_day_cell(d) -> rx.Component: size="1", font_weight=rx.cond(d["is_today"], "700", "400"), color=rx.cond( - is_selected, "#1565c0", + is_selected, "var(--brand-accent)", rx.cond( d["has_non_exc"], "#c62828", rx.cond(d["has_abs"], "#2e7d32", "#333"), @@ -1233,7 +1259,7 @@ def _cal_day_cell(d) -> rx.Component: ), ), border=rx.cond( - is_selected, "2px solid #1565c0", + is_selected, "2px solid var(--brand-accent)", rx.cond(d["is_today"], "2px solid #90caf9", "1px solid #eee"), ), display="flex", @@ -1279,7 +1305,7 @@ def _edit_panel() -> rx.Component: return rx.box( rx.vstack( rx.hstack( - rx.icon("pencil", size=15, color="#1565c0"), + rx.icon("pencil", size=15, color="var(--brand-accent)"), rx.text( "Édition du ", FicheState.edit_date_label, size="3", weight="bold", color="#37474f", @@ -1338,6 +1364,83 @@ def _edit_panel() -> rx.Component: ) +def _actions_row() -> rx.Component: + """Bandeau d'actions sous les KPIs : exports PDF + création d'avis.""" + return rx.box( + rx.flex( + # Exports PDF (avec icône download partout) + rx.button( + rx.icon("download", size=13), + "PDF absences", + on_click=FicheState.download_abs_pdf, + variant="outline", color_scheme="gray", size="2", + ), + rx.cond( + FicheState.has_pdf_bn, + rx.button( + rx.icon("download", size=13), + "PDF bulletin", + on_click=FicheState.download_bn_pdf, + variant="outline", color_scheme="blue", size="2", + ), + ), + rx.cond( + FicheState.has_pdf_notes, + rx.button( + rx.icon("download", size=13), + "PDF notes", + on_click=FicheState.download_notes_pdf, + variant="outline", color_scheme="violet", size="2", + ), + ), + # Séparateur visuel + rx.box( + width="1px", + background_color="var(--gray-6)", + margin_x="0.25rem", + align_self="stretch", + ), + # Création d'avis + rx.button( + rx.icon("file-warning", size=14), + "Créer un avis de retenue", + on_click=RetenueState.preload_apprenti( + FicheState.selected_id, FicheState.selected_label, + ), + color_scheme="orange", variant="soft", size="2", + ), + rx.button( + rx.icon("triangle-alert", size=14), + "Créer un avis de sanction", + on_click=SanctionState.preload_apprenti( + FicheState.selected_id, FicheState.selected_label, + ), + color_scheme="red", variant="soft", size="2", + ), + gap="0.5rem", + flex_wrap="wrap", + align="center", + width="100%", + ), + padding="0.75rem 1rem", + background_color="white", + border_radius="8px", + border="1px solid #e0e0e0", + width="100%", + ) + + +def _notice_row(item) -> rx.Component: + return rx.table.row( + rx.table.cell(item["date"], white_space="nowrap"), + rx.table.cell(rx.text(item["type"], size="1")), + rx.table.cell(rx.text(item["auteur"], size="1", color="#666")), + rx.table.cell(rx.text(item["titre"], size="1", weight="medium")), + rx.table.cell(rx.text(item["remarque"], size="1", color="#444")), + rx.table.cell(rx.text(item["matiere"], size="1", color="#666")), + ) + + def _pending_btn(item: dict) -> rx.Component: return rx.button( rx.icon("check", size=13), @@ -1512,6 +1615,9 @@ _DOW = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"] def fiche_page() -> rx.Component: return layout( rx.vstack( + # Modals (rendus une fois, contrôlés par leur state respectif) + retenue_modal(), + sanction_modal(), rx.heading("Fiche apprenti", size="7"), rx.cond( @@ -1524,7 +1630,7 @@ def fiche_page() -> rx.Component: # ── KPI cards ───────────────────────────────────────────── rx.flex( _kpi_card("Périodes d'absence", FicheState.kpi_total), - _kpi_card("Périodes à excuser", FicheState.kpi_non_excusees, "#c62828"), + _kpi_card("Périodes à excuser", FicheState.kpi_non_excusees, "var(--brand-primary-dark)"), rx.box( rx.text("Absences", size="1", color="#666"), rx.text( @@ -1553,6 +1659,9 @@ def fiche_page() -> rx.Component: gap="1rem", flex_wrap="wrap", width="100%", ), + # ── Actions (PDF exports + créations d'avis) ─────────────── + _actions_row(), + # ── Fiche détaillée Escada ──────────────────────────────── rx.box( rx.cond( @@ -1610,6 +1719,7 @@ def fiche_page() -> rx.Component: rx.tabs.list( rx.tabs.trigger("Cours professionnels", value="bn"), rx.tabs.trigger("Notes d'examen", value="notes"), + rx.tabs.trigger("Notices", value="notices"), ), rx.tabs.content( rx.cond( @@ -1642,6 +1752,35 @@ def fiche_page() -> rx.Component: ), value="notes", width="100%", padding_top="1rem", ), + rx.tabs.content( + rx.cond( + FicheState.has_notices, + rx.box( + rx.table.root( + rx.table.header( + rx.table.row( + rx.table.column_header_cell("Date"), + rx.table.column_header_cell("Type"), + rx.table.column_header_cell("Auteur"), + rx.table.column_header_cell("Titre"), + rx.table.column_header_cell("Remarques"), + rx.table.column_header_cell("Matière"), + ), + ), + rx.table.body( + rx.foreach(FicheState.notices_data, _notice_row), + ), + size="1", width="100%", + ), + width="100%", overflow_x="auto", + ), + rx.text( + "Aucune notice. Récupère-les depuis Escada via la page Escada (bouton « Récupérer les notices »).", + size="2", color="#666", + ), + ), + value="notices", width="100%", padding_top="1rem", + ), default_value="bn", width="100%", ), padding="1rem", @@ -1651,32 +1790,6 @@ def fiche_page() -> rx.Component: width="100%", ), - # ── Export PDF ──────────────────────────────────────────── - rx.flex( - rx.button( - rx.icon("download", size=13), "PDF absences", - on_click=FicheState.download_abs_pdf, - variant="outline", color_scheme="gray", size="1", - ), - rx.cond( - FicheState.has_pdf_bn, - rx.button( - rx.icon("file-text", size=13), "PDF bulletin", - on_click=FicheState.download_bn_pdf, - variant="outline", color_scheme="blue", size="1", - ), - ), - rx.cond( - FicheState.has_pdf_notes, - rx.button( - rx.icon("file-text", size=13), "PDF notes", - on_click=FicheState.download_notes_pdf, - variant="outline", color_scheme="violet", size="1", - ), - ), - flex_wrap="wrap", gap="0.5rem", - ), - # ── Calendrier mensuel ──────────────────────────────────── rx.cond( FicheState.kpi_total > 0, @@ -1719,7 +1832,7 @@ def fiche_page() -> rx.Component: border_radius="2px", border="1px solid #eee"), rx.text("Excusée", size="1", color="#666"), rx.box(width="12px", height="12px", background_color="#dbeafe", - border_radius="2px", border="2px solid #1565c0"), + border_radius="2px", border="2px solid var(--brand-accent)"), rx.text("Sélectionné", size="1", color="#666"), spacing="2", align="center", margin_top="0.5rem", ), diff --git a/eptm_dashboard/pages/profile.py b/eptm_dashboard/pages/profile.py index 079ed69..93b8ec7 100644 --- a/eptm_dashboard/pages/profile.py +++ b/eptm_dashboard/pages/profile.py @@ -400,6 +400,71 @@ def _password_section() -> rx.Component: ) +_THEMES = [ + ("eptm", "EPTM (rouge)", "#dc000e"), + ("bleu", "Bleu corporate", "#1565c0"), + ("indigo", "Indigo nuit", "#3f51b5"), + ("vert", "Vert académique","#2e7d32"), +] + + +def _theme_swatch(key: str, label: str, color: str) -> rx.Component: + is_active = ProfileState.theme == key + return rx.box( + rx.vstack( + rx.box( + background_color=color, + width="100%", + height="48px", + border_radius="6px", + border=rx.cond(is_active, "2px solid var(--gray-12)", "1px solid var(--gray-5)"), + ), + rx.hstack( + rx.text(label, size="2", weight=rx.cond(is_active, "bold", "regular")), + rx.spacer(), + rx.cond( + is_active, + rx.icon("check", size=15, color="var(--green-10)"), + rx.fragment(), + ), + width="100%", align="center", + ), + spacing="2", width="100%", + ), + on_click=ProfileState.set_theme(key), + padding="0.6rem", + border_radius="8px", + border=rx.cond(is_active, "1.5px solid var(--gray-12)", "1px solid var(--gray-4)"), + background_color=rx.cond(is_active, "var(--gray-2)", "white"), + cursor="pointer", + _hover={"border_color": "var(--gray-8)"}, + flex="1", min_width="140px", + ) + + +def _theme_section() -> rx.Component: + return rx.box( + rx.vstack( + rx.text("Thème de couleur", size="3", weight="bold"), + rx.text( + "Personnalise les couleurs de marque (sidebar, KPI, liens, boutons). " + "Les couleurs de notes (rouge < 4, orange < 5, vert ≥ 5) restent inchangées.", + size="1", color="var(--gray-11)", + ), + rx.flex( + *[_theme_swatch(k, l, c) for k, l, c in _THEMES], + gap="0.75rem", flex_wrap="wrap", width="100%", + ), + spacing="3", width="100%", + ), + padding="1.25rem", + background_color="white", + border_radius="8px", + border="1px solid #e0e0e0", + width="100%", + ) + + def _totp_section() -> rx.Component: return rx.box( rx.vstack( @@ -460,6 +525,7 @@ def profile_page() -> rx.Component: _avatar_section(), _info_section(), _password_section(), + _theme_section(), _totp_section(), spacing="4", width="100%", diff --git a/eptm_dashboard/pages/purge.py b/eptm_dashboard/pages/purge.py index 2c18179..64a5a67 100644 --- a/eptm_dashboard/pages/purge.py +++ b/eptm_dashboard/pages/purge.py @@ -430,8 +430,8 @@ def _preview_panel() -> rx.Component: size="2", weight="bold", color="#37474f", ), rx.flex( - _kpi("Apprentis", PurgeState.pv_apprentis, "#c62828"), - _kpi("Absences", PurgeState.pv_absences, "#c62828"), + _kpi("Apprentis", PurgeState.pv_apprentis, "var(--brand-primary-dark)"), + _kpi("Absences", PurgeState.pv_absences, "var(--brand-primary-dark)"), _kpi("Pendings", PurgeState.pv_pendings, "#b45309"), _kpi("BN", PurgeState.pv_bn), _kpi("Matu", PurgeState.pv_matu), diff --git a/eptm_dashboard/pages/retenue.py b/eptm_dashboard/pages/retenue.py index 7adfc65..30388f8 100644 --- a/eptm_dashboard/pages/retenue.py +++ b/eptm_dashboard/pages/retenue.py @@ -44,7 +44,10 @@ def _load_settings() -> dict: class RetenueState(AuthState): - # Sélecteur apprenti + # Modal control (utilisé depuis /fiche) + modal_open: bool = False + + # Sélecteur apprenti (présent pour le modal, en read-only) apprenti_labels: list[str] = [] apprenti_ids: list[int] = [] selected_label: str = "" @@ -77,8 +80,10 @@ class RetenueState(AuthState): email_dest: str = "apprenti" email_custom: str = "" - # Option : créer une notice Escada à la génération - add_notice: bool = False + # Détection notice existante (pending) pour cet apprenti à la date du jour + has_existing_notice: bool = False + existing_notice_label: str = "" + create_anyway: bool = False # États form_error: str = "" @@ -116,7 +121,65 @@ class RetenueState(AuthState): def set_profession(self, v: str): self.sel_profession = v def set_email_dest(self, v: str): self.email_dest = v def set_email_custom(self, v: str): self.email_custom = v - def set_add_notice(self, v: bool): self.add_notice = v + def set_create_anyway(self, v: bool): self.create_anyway = v + def set_modal_open(self, v: bool): + self.modal_open = v + if not v: + # Reset partiel à la fermeture + self.form_error = "" + + def preload_apprenti(self, apprenti_id: int, label: str): + """Pré-remplit l'apprenti depuis la fiche et ouvre le modal.""" + self.selected_id = apprenti_id + self.selected_label = label + # Reset des autres champs + self.case = "devoir" + self.branche = "" + self.remarque = "" + self.form_error = "" + self.email_dest = "apprenti" + self.email_custom = "" + self.create_anyway = False + # Dates par défaut = aujourd'hui + today = _date.today().isoformat() + self.retenue_date = today + self.probleme_date = today + # Charger les données apprenti (profession, emails) + cache branches + self._load_apprenti() + sess = get_session() + try: + self._load_branches(sess) + self._detect_existing_notice(sess, apprenti_id) + finally: + sess.close() + # Ouvrir le modal + self.modal_open = True + + def _detect_existing_notice(self, sess, apprenti_id: int): + """Détecte si une Notice pending existe déjà aujourd'hui pour cet apprenti.""" + today = _date.today() + existing = sess.execute( + select(Notice) + .where( + Notice.apprenti_id == apprenti_id, + Notice.date_event == today, + Notice.status == "pending", + ) + .order_by(Notice.created_at.desc()) + ).scalars().first() + if existing: + self.has_existing_notice = True + self.existing_notice_label = ( + f"{existing.titre or '(sans titre)'} — " + f"créée le {existing.created_at.strftime('%d.%m.%Y %H:%M')}" + ) + else: + self.has_existing_notice = False + self.existing_notice_label = "" + + def close_after_action(self): + """Appelée après un téléchargement / envoi pour fermer le modal.""" + self.modal_open = False def load_data(self): if not self.authenticated: @@ -251,9 +314,19 @@ class RetenueState(AuthState): return f"{label} en {self.branche.strip()}" return label - def _create_notice_if_requested(self): - """Crée une Notice en DB si la checkbox add_notice est cochée.""" - if not self.add_notice or not self.selected_id: + def _create_notice(self): + """Crée une Notice en DB (push queue Escada). + + Si une notice pending existe déjà pour cet apprenti aujourd'hui et que + l'utilisateur n'a pas coché « Créer quand même », on saute la création. + """ + if not self.selected_id: + return + if self.has_existing_notice and not self.create_anyway: + app_log( + f"[notice] {self.username or '?'} : notice doublon évitée pour " + f"{self.selected_label} (existante : {self.existing_notice_label})" + ) return sess = get_session() try: @@ -333,8 +406,12 @@ class RetenueState(AuthState): f"[retenue] {self.username or '?'} : avis téléchargé pour " f"{self.selected_label} (case={self.case})" ) - self._create_notice_if_requested() - return rx.download(data=data, filename=self._filename()) + self._create_notice() + self.modal_open = False + return [ + rx.download(data=data, filename=self._filename()), + rx.toast.success("Avis téléchargé — notice ajoutée à la file Escada"), + ] def send_email_action(self): data = self._build_pdf() @@ -381,8 +458,11 @@ class RetenueState(AuthState): f"[retenue] {self.username or '?'} : avis envoyé à {to} pour " f"{self.selected_label}" ) - self._create_notice_if_requested() - return rx.toast.success(f"Avis envoyé à {to}") + self._create_notice() + self.modal_open = False + return rx.toast.success( + f"Avis envoyé à {to} — notice ajoutée à la file Escada" + ) # ── UI ──────────────────────────────────────────────────────────────────────── @@ -539,7 +619,7 @@ def _profession_warning() -> rx.Component: "Profession non définie pour ", RetenueState.sel_classe, ". Renseigne-la ci-dessous, ou ajoute la correspondance dans ", - rx.link("Paramètres", href="/params", color="#1565c0"), + rx.link("Paramètres", href="/params", color="var(--brand-accent)"), " pour qu'elle soit pré-remplie automatiquement.", ), color_scheme="amber", variant="soft", size="1", @@ -550,11 +630,17 @@ def _profession_warning() -> rx.Component: def _form() -> rx.Component: return rx.vstack( - # Apprenti - rx.vstack( - rx.text("Apprenti", size="2", weight="medium", color="var(--gray-11)"), - _apprenti_selector(), - spacing="1", width="100%", + # Bannière apprenti (read-only, pré-rempli depuis la fiche) + rx.box( + rx.flex( + rx.icon("user", size=16, color="var(--brand-accent)"), + rx.text(RetenueState.selected_label, size="2", weight="medium", color="#37474f"), + gap="0.5rem", align="center", + ), + padding="0.5rem 0.75rem", + background_color="#e3f2fd", + border_radius="6px", + border="1px solid #90caf9", ), _profession_warning(), # Profession (éditable) @@ -648,30 +734,60 @@ def _form() -> rx.Component: ), rx.fragment(), ), - # Option : créer une notice Escada - rx.flex( - rx.checkbox( - checked=RetenueState.add_notice, - on_change=RetenueState.set_add_notice, - size="2", + # Bandeau d'info notice Escada (jaune si doublon détecté, bleu sinon) + rx.cond( + RetenueState.has_existing_notice, + rx.box( + rx.flex( + rx.icon("triangle-alert", size=14, color="#b45309"), + rx.text( + "Une notice est déjà en attente pour cet apprenti aujourd'hui : ", + rx.text.strong(RetenueState.existing_notice_label), + ". Par défaut, aucune nouvelle notice ne sera créée.", + size="1", color="#78350f", + ), + gap="0.4rem", align="start", + ), + rx.flex( + rx.checkbox( + checked=RetenueState.create_anyway, + on_change=RetenueState.set_create_anyway, + size="2", + color_scheme="amber", + ), + rx.text( + "Créer quand même une nouvelle notice", + size="2", color="#78350f", weight="medium", + ), + gap="0.5rem", align="center", margin_top="0.4rem", + ), + padding="0.6rem 0.75rem", + background_color="#fef3c7", + border="1px solid #fcd34d", + border_radius="6px", ), - rx.text( - "Ajouter automatiquement une notice sur Escada", - size="2", color="var(--gray-12)", + rx.flex( + rx.icon("info", size=14, color="var(--brand-accent)"), + rx.text( + "Une notice sera ajoutée à la file d'attente Escada lors du téléchargement " + "ou de l'envoi par email. Choisis une seule de ces deux actions.", + size="1", color="var(--brand-accent)", + ), + gap="0.4rem", align="start", + padding="0.5rem 0.65rem", + background_color="#e3f2fd", + border="1px solid #90caf9", + border_radius="6px", ), - gap="0.5rem", align="center", - padding="0.5rem 0.65rem", - background_color="#f8f9fa", - border="1px solid #e5e7eb", - border_radius="6px", ), - # Actions : télécharger + # Bouton Télécharger rx.button( rx.icon("file-down", size=16), "Télécharger l'avis", on_click=RetenueState.download_pdf, color_scheme="red", size="2", disabled=RetenueState.selected_id == 0, + width="100%", ), spacing="4", width="100%", @@ -745,34 +861,34 @@ def _email_section() -> rx.Component: ) -def retenue_page() -> rx.Component: - return layout( - rx.vstack( - rx.heading("Avis de retenue", size="6"), - rx.cond( - RetenueState.has_apprentis, - rx.vstack( - rx.box( - _form(), - padding="1.25rem", - background_color="white", - border_radius="8px", - border="1px solid #e0e0e0", - width="100%", - ), - _email_section(), - spacing="4", width="100%", - ), - empty_state( - icon="users", - title="Aucun apprenti", - description="Importe les classes depuis Escadaweb pour générer des avis.", - action_label="Lancer un import", - action_href="/escada", - ), +def retenue_modal() -> rx.Component: + """Modal réutilisable pour créer un avis de retenue. + + L'apprenti doit être pré-rempli via `RetenueState.preload_apprenti(id, label)` + avant l'ouverture. L'état `modal_open` contrôle l'affichage. + """ + return rx.dialog.root( + rx.dialog.content( + rx.dialog.title("Créer un avis de retenue"), + rx.dialog.description( + "Renseigne les informations et télécharge ou envoie l'avis par email.", + size="2", color="var(--gray-11)", ), - spacing="4", - width="100%", - max_width="780px", - ) + rx.vstack( + _form(), + _email_section(), + spacing="4", width="100%", + ), + rx.flex( + rx.dialog.close( + rx.button("Fermer", variant="soft", color_scheme="gray"), + ), + gap="0.5rem", justify="end", margin_top="1rem", + ), + max_width="720px", + max_height="90vh", + overflow_y="auto", + ), + open=RetenueState.modal_open, + on_open_change=RetenueState.set_modal_open, ) diff --git a/eptm_dashboard/pages/sanction.py b/eptm_dashboard/pages/sanction.py new file mode 100644 index 0000000..b02e0a9 --- /dev/null +++ b/eptm_dashboard/pages/sanction.py @@ -0,0 +1,319 @@ +"""Modal et state pour la création d'un avis de sanction depuis la fiche apprenti. + +Le PDF est généré automatiquement depuis le template AcroForm +(`data/templates/GF_FO_Avis_de_sanction.pdf`) et les valeurs par défaut +configurées dans Paramètres (texte_sanction, chef_section). +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +import reflex as rx + +_ROOT = Path(__file__).resolve().parent.parent.parent +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + +from src.db import get_session, Apprenti, ApprentiFiche # noqa: E402 +from src.sanction_pdf import generate_avis_pdf # noqa: E402 +from src.email_sender import send_email # noqa: E402 +from src.user_access import is_class_allowed # noqa: E402 +from src.logger import app_log # noqa: E402 + +from ..state import AuthState + +DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data"))) +_SETTINGS_FILE = DATA_DIR / "settings.json" + + +def _load_settings() -> dict: + if _SETTINGS_FILE.exists(): + try: + return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8")) + except Exception: + return {} + return {} + + +# ── State ───────────────────────────────────────────────────────────────────── + + +class SanctionState(AuthState): + modal_open: bool = False + + # Apprenti pré-rempli depuis la fiche + selected_id: int = 0 + selected_label: str = "" + sel_classe: str = "" + sel_fiche_email_appr: str = "" + sel_fiche_email_form: str = "" + + # Email + email_dest: str = "apprenti" + email_custom: str = "" + + form_error: str = "" + + def set_modal_open(self, v: bool): + self.modal_open = v + if not v: + self.form_error = "" + + def set_email_dest(self, v: str): self.email_dest = v + def set_email_custom(self, v: str): self.email_custom = v + + def preload_apprenti(self, apprenti_id: int, label: str): + self.selected_id = apprenti_id + self.selected_label = label + self.form_error = "" + self.email_dest = "apprenti" + self.email_custom = "" + sess = get_session() + try: + ap = sess.get(Apprenti, apprenti_id) + if ap: + self.sel_classe = ap.classe + fiche = ap.fiche + if fiche: + self.sel_fiche_email_appr = fiche.email or "" + self.sel_fiche_email_form = fiche.formateur_email or "" + else: + self.sel_fiche_email_appr = "" + self.sel_fiche_email_form = "" + finally: + sess.close() + self.modal_open = True + + def _build_pdf(self) -> bytes | None: + if not self.selected_id: + self.form_error = "Aucun apprenti sélectionné." + return None + if not is_class_allowed(self.username, self.sel_classe): + self.form_error = "Accès refusé pour cette classe." + return None + self.form_error = "" + sess = get_session() + try: + return generate_avis_pdf( + sess, self.selected_id, + prof_name=self.name or self.username, + ) + finally: + sess.close() + + def _filename(self) -> str: + sess = get_session() + try: + ap = sess.get(Apprenti, self.selected_id) + if not ap: + return "Avis_sanction.pdf" + safe_nom = "".join(c if c.isalnum() else "_" for c in ap.nom) + safe_prenom = "".join(c if c.isalnum() else "_" for c in ap.prenom) + return f"Avis_sanction_{safe_nom}_{safe_prenom}.pdf" + finally: + sess.close() + + def download_pdf(self): + data = self._build_pdf() + if data is None: + return rx.toast.error(self.form_error or "Impossible de générer le PDF.") + app_log( + f"[sanction] {self.username or '?'} : avis téléchargé pour " + f"{self.selected_label}" + ) + self.modal_open = False + return [ + rx.download(data=data, filename=self._filename()), + rx.toast.success("Avis de sanction téléchargé"), + ] + + def send_email_action(self): + data = self._build_pdf() + if data is None: + return rx.toast.error(self.form_error or "Impossible de générer le PDF.") + + if self.email_dest == "apprenti": + to = self.sel_fiche_email_appr + elif self.email_dest == "formateur": + to = self.sel_fiche_email_form + else: + to = self.email_custom.strip() + if not to or "@" not in to: + return rx.toast.error("Adresse email invalide ou manquante.") + + s = _load_settings() + smtp_host = s.get("smtp_host") + smtp_port = int(s.get("smtp_port") or 587) + smtp_login = s.get("smtp_login") + smtp_password = s.get("smtp_password") + smtp_sender = s.get("smtp_sender") + if not (smtp_host and smtp_login and smtp_password and smtp_sender): + return rx.toast.error("Configuration SMTP incomplète (Paramètres).") + + subject = f"Avis de sanction — {self.selected_label}" + body = ( + f"Bonjour,\n\nVeuillez trouver en pièce jointe l'avis de sanction " + f"concernant {self.selected_label}.\n\nCordialement,\n" + f"{self.name or self.username}\n" + ) + try: + send_email( + smtp_host=smtp_host, smtp_port=smtp_port, + smtp_login=smtp_login, smtp_password=smtp_password, + smtp_sender=smtp_sender, + to_email=to, subject=subject, body=body, + attachments=[(data, self._filename())], + ) + except Exception as e: + return rx.toast.error(f"Échec d'envoi : {e}") + app_log( + f"[sanction] {self.username or '?'} : avis envoyé à {to} pour " + f"{self.selected_label}" + ) + self.modal_open = False + return rx.toast.success(f"Avis de sanction envoyé à {to}") + + +# ── UI ──────────────────────────────────────────────────────────────────────── + +def _email_section() -> rx.Component: + return rx.box( + rx.vstack( + rx.flex( + rx.icon("mail", size=16, color="#37474f"), + rx.text("Envoyer par email", size="3", weight="bold", color="#37474f"), + gap="0.5rem", align="center", + ), + rx.divider(), + rx.text("Destinataire", size="2", weight="medium", color="var(--gray-11)"), + rx.radio_group.root( + rx.vstack( + rx.radio_group.item( + rx.cond( + SanctionState.sel_fiche_email_appr != "", + rx.text("Apprenti — ", SanctionState.sel_fiche_email_appr, size="2"), + rx.text("Apprenti (email inconnu)", size="2", color="var(--gray-9)"), + ), + value="apprenti", + disabled=SanctionState.sel_fiche_email_appr == "", + ), + rx.radio_group.item( + rx.cond( + SanctionState.sel_fiche_email_form != "", + rx.text("Formateur — ", SanctionState.sel_fiche_email_form, size="2"), + rx.text("Formateur (email inconnu)", size="2", color="var(--gray-9)"), + ), + value="formateur", + disabled=SanctionState.sel_fiche_email_form == "", + ), + rx.radio_group.item( + rx.text("Autre adresse", size="2"), + value="autre", + ), + spacing="2", + ), + value=SanctionState.email_dest, + on_change=SanctionState.set_email_dest, + ), + rx.cond( + SanctionState.email_dest == "autre", + rx.input( + placeholder="email@domaine.ch", + value=SanctionState.email_custom, + on_change=SanctionState.set_email_custom, + type="email", + width="100%", + ), + rx.fragment(), + ), + rx.button( + rx.icon("send", size=16), + "Envoyer par email", + on_click=SanctionState.send_email_action, + color_scheme="blue", size="2", + disabled=SanctionState.selected_id == 0, + ), + spacing="3", width="100%", + ), + padding="1.25rem", + background_color="white", + border_radius="8px", + border="1px solid #e0e0e0", + width="100%", + ) + + +def sanction_modal() -> rx.Component: + """Modal pour créer un avis de sanction. + + L'avis de sanction n'a pas de champ à remplir côté UI : tout est pré-rempli + automatiquement (texte de description et chef de section depuis Paramètres, + adresse/entreprise depuis la fiche apprenti). L'utilisateur télécharge ou + envoie l'avis par email. + """ + return rx.dialog.root( + rx.dialog.content( + rx.dialog.title("Créer un avis de sanction"), + rx.dialog.description( + "Génère l'avis de sanction officiel à partir du template EPTM.", + size="2", color="var(--gray-11)", + ), + rx.vstack( + # Bannière apprenti + rx.box( + rx.flex( + rx.icon("user", size=16, color="#c62828"), + rx.text(SanctionState.selected_label, size="2", weight="medium", color="#37474f"), + gap="0.5rem", align="center", + ), + padding="0.5rem 0.75rem", + background_color="#fff5f5", + border_radius="6px", + border="1px solid #ffcdd2", + ), + rx.callout.root( + rx.callout.icon(rx.icon("info", size=16)), + rx.callout.text( + "L'avis utilise le texte par défaut configuré dans ", + rx.link("Paramètres", href="/params", color="var(--brand-accent)"), + " (motif et chef de section). L'adresse et le nom de l'entreprise " + "proviennent de la fiche apprenti Escada.", + ), + color_scheme="blue", variant="soft", size="1", + ), + rx.cond( + SanctionState.form_error != "", + rx.callout.root( + rx.callout.icon(rx.icon("triangle-alert", size=16)), + rx.callout.text(SanctionState.form_error), + color_scheme="red", variant="soft", size="1", + ), + rx.fragment(), + ), + rx.button( + rx.icon("file-down", size=16), + "Télécharger l'avis de sanction", + on_click=SanctionState.download_pdf, + color_scheme="red", size="2", + disabled=SanctionState.selected_id == 0, + ), + _email_section(), + spacing="3", width="100%", + ), + rx.flex( + rx.dialog.close( + rx.button("Fermer", variant="soft", color_scheme="gray"), + ), + gap="0.5rem", justify="end", margin_top="1rem", + ), + max_width="640px", + max_height="90vh", + overflow_y="auto", + ), + open=SanctionState.modal_open, + on_open_change=SanctionState.set_modal_open, + ) diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py index ee15e55..87664a3 100644 --- a/eptm_dashboard/sidebar.py +++ b/eptm_dashboard/sidebar.py @@ -10,21 +10,21 @@ FULL_W = "240px" RAIL_W = "68px" TOPBAR_H = "56px" -# EPTM brand palette (logo: noir #000 + rouge #e00010) +# Sidebar palette : couleurs neutres locales + tokens de marque (cf. responsive.css). _BG = "#f8f9fa" # sidebar background (light) _BORDER = "#e5e7eb" # subtle separator _TEXT = "#4b5563" # inactive text _TEXT_MUTED = "#9ca3af" # muted labels -_ACTIVE_BG = "rgba(220, 0, 14, 0.18)" # EPTM red tint -_ACTIVE_CLR = "#ff4a54" # bright red on dark bg _HOVER_BG = "#f3f4f6" _USER_BG = "#f3f4f6" # slightly darker user section +# Tokens dynamiques (changent selon le thème user) +_ACTIVE_BG = "var(--brand-primary-tint)" +_ACTIVE_CLR = "var(--brand-primary-light)" _PAGES = [ ("Tableau de bord", "/accueil", "layout-dashboard"), ("Apprentis", "/fiche", "user"), ("Classes", "/classe", "users"), - ("Avis de retenue", "/retenue", "file-warning"), ] _ADMIN_PAGES = [ diff --git a/eptm_dashboard/state.py b/eptm_dashboard/state.py index 77f1a9f..6b2ab2a 100644 --- a/eptm_dashboard/state.py +++ b/eptm_dashboard/state.py @@ -43,6 +43,9 @@ class AuthState(rx.State): name: str = rx.LocalStorage("", sync=True) role: str = rx.LocalStorage("user", sync=True) photo_url: str = rx.LocalStorage("", sync=True) + # Thème de couleur de l'interface : "eptm" (défaut), "bleu", "indigo", "vert". + # Appliqué via data-theme sur côté client. + theme: str = rx.LocalStorage("eptm", sync=True) # In-memory only (login form, transient UI state) login_user: str = "" @@ -119,6 +122,39 @@ class AuthState(rx.State): self._clear_session() return rx.redirect("/login") self.photo_url = users[self.username].get("avatar_url", "") + # Re-synchronise le thème depuis auth.yaml (au cas où changé sur un autre device). + stored_theme = users[self.username].get("theme") or "eptm" + if stored_theme != self.theme: + self.theme = stored_theme + return self._apply_theme_script(self.theme) + + @staticmethod + def _apply_theme_script(theme: str): + """Script JS qui set data-theme sur immédiatement (sans attendre re-render).""" + safe = "".join(c for c in (theme or "eptm") if c.isalnum() or c in "-_") + if not safe or safe == "eptm": + return rx.call_script( + "document.documentElement.removeAttribute('data-theme');" + "document.body && document.body.removeAttribute('data-theme');" + ) + return rx.call_script( + f"document.documentElement.setAttribute('data-theme', '{safe}');" + f"document.body && document.body.setAttribute('data-theme', '{safe}');" + ) + + def set_theme(self, value: str): + """Change le thème de couleur (persiste en LocalStorage et auth.yaml).""" + if value not in ("eptm", "bleu", "indigo", "vert"): + value = "eptm" + self.theme = value + # Persister dans auth.yaml pour synchronisation multi-device. + if self.username: + cfg = _load_auth_full() + users = cfg.get("credentials", {}).get("usernames", {}) + if self.username in users: + users[self.username]["theme"] = value + _save_auth_full(cfg) + return self._apply_theme_script(value) def handle_login(self, form_data: dict | None = None): self.login_error = "" @@ -208,8 +244,9 @@ class AuthState(rx.State): self.name = user.get("name", self.totp_pending_user) self.role = user.get("role", "user") self.photo_url = user.get("avatar_url", "") + self.theme = user.get("theme") or "eptm" self._reset_totp_flow() - return rx.redirect("/accueil") + return [self._apply_theme_script(self.theme), rx.redirect("/accueil")] def cancel_totp(self): """Annule le flow 2FA et revient à l'étape password.""" @@ -232,6 +269,7 @@ class AuthState(rx.State): self.name = "" self.role = "user" self.photo_url = "" + self.theme = "eptm" self.login_user = "" self.login_pass = "" self.login_error = "" diff --git a/rxconfig.py b/rxconfig.py index 6ac7538..54ff459 100644 --- a/rxconfig.py +++ b/rxconfig.py @@ -11,7 +11,9 @@ config = rx.Config( plugins=[ rx.plugins.RadixThemesPlugin( theme=rx.theme( - appearance="inherit", + # Force le mode clair (ignore dark mode OS). Les thèmes de + # couleur user sont gérés via tokens CSS dans responsive.css. + appearance="light", accent_color="red", radius="medium", scaling="95%", diff --git a/scripts/cron_tick.py b/scripts/cron_tick.py index edaa1fd..4a1b858 100755 --- a/scripts/cron_tick.py +++ b/scripts/cron_tick.py @@ -53,6 +53,7 @@ except Exception: SCRIPT_SYNC = _ROOT / "scripts" / "sync_esacada.py" SCRIPT_PUSH = _ROOT / "scripts" / "push_to_escada.py" SCRIPT_PUSH_NOTICES = _ROOT / "scripts" / "push_notices.py" +SCRIPT_PULL_NOTICES = _ROOT / "scripts" / "pull_notices.py" DATA_DIR = _ROOT / "data" # Marqueur écrit par run_imports.py à la fin des imports en DB @@ -179,6 +180,30 @@ def _build_push_cmd(job: CronJob) -> list[str]: return [sys.executable, str(SCRIPT_PUSH)] +def _job_classes(job: CronJob) -> list[str]: + """Résout la liste de classes du job (ALL → toutes les classes en DB).""" + if (job.classes_json or "").strip().upper() == "ALL": + from sqlalchemy import text as _text + sess = get_session() + try: + rows = sess.execute(_text( + "SELECT DISTINCT classe FROM apprentis WHERE classe IS NOT NULL " + "AND classe <> '' ORDER BY classe" + )).all() + return [r[0] for r in rows] + finally: + sess.close() + try: + data = json.loads(job.classes_json or "[]") + return [c for c in data if isinstance(c, str) and c.strip()] + except Exception: + return [] + + +def _build_pull_notices_cmd(job: CronJob) -> list[str]: + return [sys.executable, str(SCRIPT_PULL_NOTICES), *_job_classes(job)] + + def _wait_for_run_imports(log_fp, mtime_before: float) -> tuple[bool, str, dict]: """Après que sync_esacada.py a fini, run_imports.py tourne en sous-process détaché. Attend que sync_last_result.json soit mis à jour, puis log les @@ -318,30 +343,47 @@ def run_job(job: CronJob, sess) -> None: fp.write(f"\n=== Job #{job.id} '{job.name}' — démarré {started.isoformat(timespec='seconds')} ===\n") fp.write(f"task_kind={job.task_kind} classes={job.classes_json}\n") + # task_kind ∈ {push, sync, push_then_sync}. + # Les flags sync_abs / sync_bn / sync_notes / sync_fiches / sync_notices + # déterminent quels scripts sont exécutés à chaque étape. + sync_any_abs_bn = ( + job.sync_abs or job.sync_bn or job.sync_notes or job.sync_fiches + ) + push_step: list[tuple[str, list[str]]] = [] + if job.sync_abs: + push_step.append(("Push absences", _build_push_cmd(job))) + if job.sync_notices: + push_step.append(("Push notices", [sys.executable, str(SCRIPT_PUSH_NOTICES)])) + sync_step: list[tuple[str, list[str]]] = [] + if sync_any_abs_bn: + sync_step.append(("Sync absences", _build_sync_cmd(job))) + if job.sync_notices: + sync_step.append(("Sync notices", _build_pull_notices_cmd(job))) + steps: list[tuple[str, list[str]]] = [] if job.task_kind == "push": - steps = [("Push Escada", _build_push_cmd(job))] + steps = push_step elif job.task_kind == "sync": - steps = [("Sync Escada", _build_sync_cmd(job))] + steps = sync_step elif job.task_kind == "push_then_sync": - steps = [ - ("Push Escada", _build_push_cmd(job)), - ("Sync Escada", _build_sync_cmd(job)), - ] - elif job.task_kind == "push_notices": - steps = [("Push notices", [sys.executable, str(SCRIPT_PUSH_NOTICES)])] + steps = push_step + sync_step else: fp.write(f"[error] task_kind inconnu : {job.task_kind}\n") overall_rc = 99 final_msg = f"task_kind invalide : {job.task_kind}" + if not steps and overall_rc == 0: + fp.write("[warn] aucune donnée sélectionnée — rien à faire\n") + final_msg = "Aucune donnée sélectionnée (Absences/Notices/etc.)" + for title, cmd in steps: - # Capturer mtime du marqueur run_imports AVANT le sync - # (utilisé après pour détecter la fin de run_imports.py) - is_sync = title.startswith("Sync") + # Capturer mtime du marqueur run_imports AVANT le sync absences + # (run_imports.py est uniquement déclenché par sync_esacada.py, + # pas par pull_notices.py). + is_sync_abs = title == "Sync absences" mtime_before = ( RUN_IMPORTS_RESULT.stat().st_mtime - if is_sync and RUN_IMPORTS_RESULT.exists() else 0.0 + if is_sync_abs and RUN_IMPORTS_RESULT.exists() else 0.0 ) rc, pid = _run_step(cmd, fp, title) @@ -351,8 +393,8 @@ def run_job(job: CronJob, sess) -> None: final_msg = f"{title} a échoué (code {rc})" break - # Si c'était une étape sync, attendre que run_imports termine - if is_sync: + # Si c'était l'étape sync absences, attendre que run_imports termine + if is_sync_abs: imports_ok, imports_msg, imports_result = _wait_for_run_imports(fp, mtime_before) if not imports_ok: overall_rc = 2 diff --git a/scripts/pull_notices.py b/scripts/pull_notices.py new file mode 100755 index 0000000..f4d6649 --- /dev/null +++ b/scripts/pull_notices.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +"""Pull des notices depuis Escadaweb pour les apprentis des classes données. + +Usage : python pull_notices.py CLASSE1 CLASSE2 ... + +Pour chaque classe : + 1. Navigue vers la liste Élèves (ViewLernende) + 2. Pour chaque apprenti de la classe : + - Clic "Notices" dans sa ligne + - Scrape la grille (pagination gérée) + - Wipe + insert les notices dans ApprentiNotice + - Retour à la liste Élèves + 3. Passe à la classe suivante + +Sortie standard (parsable) : + PULL_NOTICES_DONE {"ok": N_apprentis_ok, "imported": N_notices, "err": [...]} +""" + +from __future__ import annotations + +import json +import re +import sys +import traceback +from datetime import date, datetime +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + +from sqlalchemy import select, delete # noqa: E402 +from playwright.sync_api import Page, TimeoutError as PWTimeout # noqa: E402 + +from src.db import get_session, Apprenti, ApprentiNotice # noqa: E402 +from src.logger import app_log # noqa: E402 + +from scripts.sync_esacada import ( # noqa: E402 + _launch_context, _ensure_logged_in, _go_to_students_page, _log, + CLASSES_URL, +) + + +_DATE_RE = re.compile(r"(\d{2})\.(\d{2})\.(\d{4})") + + +def _parse_date(s: str) -> date | None: + if not s: + return None + m = _DATE_RE.search(s) + if not m: + return None + try: + return date(int(m.group(3)), int(m.group(2)), int(m.group(1))) + except Exception: + return None + + +def _scrape_notices_grid(page: Page) -> list[dict]: + """Scrape toutes les pages de la grille des notices. + + Ordre des colonnes attendu (basé sur la structure DevExpress observée) : + 0 Editer (icône) | 1 Date | 2 Type | 3 Auteur | 4 Titre | 5 Remarques + 6 Visible classe (checkbox) | 7 Matière | 8-11 visibilités (ignorées) + """ + notices: list[dict] = [] + seen_rows: set[str] = set() # éviter de re-scraper après navigation pagination + + current_pg = 1 + while True: + try: + page.wait_for_selector( + "table[id$='gridNotizen_DXMainTable']", + state="attached", timeout=10_000, + ) + except PWTimeout: + _log(f" [notices p={current_pg}] grille non chargée") + break + + # Récupérer toutes les lignes de données via JS pour fiabilité + rows_data = page.evaluate("""() => { + const out = []; + const rows = document.querySelectorAll("tr[id*='gridNotizen_DXDataRow']"); + for (const tr of rows) { + const cells = tr.querySelectorAll(":scope > td"); + const texts = Array.from(cells).map(td => (td.innerText || td.textContent || '').trim()); + // Détection checkbox "Visible classe" : présence d'une image cochée + const cb = cells[6] ? cells[6].querySelector("img") : null; + const vis = cb ? !(cb.src || '').toLowerCase().includes('unchecked') : null; + out.push({texts, visible: vis, id: tr.id}); + } + return out; + }""") + + added = 0 + for row in rows_data: + if row["id"] in seen_rows: + continue + seen_rows.add(row["id"]) + t = row["texts"] + # Index défensif (cas où le DOM diffère légèrement) + def col(i: int) -> str: + return t[i] if i < len(t) else "" + notices.append({ + "date": _parse_date(col(1)), + "type": col(2) or None, + "auteur": col(3) or None, + "titre": col(4) or None, + "remarque": col(5) or None, + "matiere": col(7) or None, + "visible_classe": row.get("visible"), + }) + added += 1 + _log(f" [notices p={current_pg}] +{added} ligne(s)") + + # Pagination : aller à la page suivante si dispo + try: + next_link = page.locator( + f"a.dxp-num:has-text('{current_pg + 1}')" + ).first + if next_link.count() == 0: + break + next_link.click() + page.wait_for_load_state("networkidle", timeout=10_000) + page.wait_for_timeout(400) + current_pg += 1 + except Exception: + break + + return notices + + +def _student_rows(page: Page) -> list[dict]: + """Liste des lignes Élèves avec nom, prénom, et drapeau "a des notices". + + Structure de la grille Lernende (cellules) : + [0] Detail expand + [1] Notes link icon + [2] Edit button + [3] **Nom** + [4] **Prénom** + [5] Entreprise + [6] MP / [7] Disp. CG / [8] Abs. excu / [9] Abs. non excu / [10] Remarque + [11] Compensation / [12] Documents + [13] **Notices link** (icône : note_pinned = vide, note_text = avec) + [14] History / [15] Tasks + + Format : [{row_id, nom, prenom, has_notices, notices_href}]. + Gère la pagination. + """ + out: list[dict] = [] + seen: set[str] = set() + current_pg = 1 + while True: + rows = page.evaluate("""() => { + const out = []; + const trs = document.querySelectorAll("tr[id*='GridLernende_DXDataRow']"); + for (const tr of trs) { + const cells = tr.querySelectorAll(":scope > td"); + const txt = (i) => cells[i] ? (cells[i].innerText || cells[i].textContent || '').trim() : ''; + const nom = txt(3); + const prenom = txt(4); + // Lien Notices = cellule 13 (peut varier si Escada change l'ordre) + let hasNotices = false; + let noticesHref = null; + // Cherche dans toute la ligne le lien Notices via son title + const noticeLink = tr.querySelector("a[title='Notices']"); + if (noticeLink) { + noticesHref = noticeLink.getAttribute('href'); + const img = noticeLink.querySelector('img'); + if (img) { + const src = (img.getAttribute('src') || '').toLowerCase(); + hasNotices = src.includes('note_text'); + } + } + out.push({ + id: tr.id, + nom: nom, + prenom: prenom, + has_notices: hasNotices, + notices_href: noticesHref, + }); + } + return out; + }""") + added = 0 + for r in rows: + if r["id"] in seen: + continue + seen.add(r["id"]) + out.append({ + "row_id": r["id"], + "nom": r["nom"], + "prenom": r["prenom"], + "has_notices": r["has_notices"], + "notices_href": r["notices_href"], + }) + added += 1 + _log(f" [élèves p={current_pg}] +{added}") + # Page suivante ? + try: + next_link = page.locator( + f"a.dxp-num:has-text('{current_pg + 1}')" + ).first + if next_link.count() == 0: + break + next_link.click() + page.wait_for_load_state("networkidle", timeout=10_000) + page.wait_for_timeout(400) + current_pg += 1 + except Exception: + break + return out + + +def _pull_one_row( + page: Page, sess, row: dict, classe: str, students_url: str, + db_apprentis: list, +) -> tuple[int, str | None, Apprenti | None]: + """Pour une ligne Élève avec notices, scrape la grille et insert en DB. + + `row` est le dict produit par `_student_rows` : {row_id, nom, prenom, has_notices, notices_href} + + Retourne (nb_importées, err, apprenti_match). + """ + nom = (row.get("nom") or "").strip() + prenom = (row.get("prenom") or "").strip() + + # 1. Recherche match dans la liste DB de la classe (avant navigation). + # Plusieurs stratégies en cascade pour tolérer les différences de + # découpage nom/prénom (ex: "Loureiro" + "de Menezes Tiago" en DB vs + # "Loureiro de Menezes" + "Tiago" sur Escada). + import unicodedata + def _norm(s: str) -> str: + nfkd = unicodedata.normalize("NFKD", s or "") + return " ".join( + nfkd.encode("ascii", "ignore").decode("ascii").lower().split() + ) + + full_escada = _norm(f"{nom} {prenom}") + + match: Apprenti | None = None + # Stratégie A : match nom strict + premier mot du prénom + for a in db_apprentis: + db_nom = (a.nom or "").strip() + db_pre1 = (a.prenom or "").strip().split(maxsplit=1)[0] if a.prenom else "" + if db_nom == nom and prenom and ( + prenom.startswith(db_pre1) or db_pre1.startswith(prenom.split(maxsplit=1)[0]) + ): + match = a + break + # Stratégie B : match nom strict seul + if not match: + for a in db_apprentis: + if (a.nom or "").strip() == nom: + match = a + break + # Stratégie C : match par nom complet normalisé (sans accents, casse insensible) + if not match and full_escada: + for a in db_apprentis: + full_db = _norm(f"{a.nom} {a.prenom}") + if full_db == full_escada: + match = a + break + + if not match: + return 0, f"apprenti '{nom} {prenom}' non trouvé en DB pour {classe}", None + + # 2. Navigation vers la page Notices : on utilise href si dispo (plus rapide), + # sinon clic sur le lien Notices de la ligne. + href = row.get("notices_href") + try: + if href: + # href peut être relatif (ex: "ViewNotizen.aspx?id=...") — on résout via JS + target_url = page.evaluate( + "(h) => new URL(h, document.baseURI).href", href + ) + page.goto(target_url) + else: + page.locator(f"#{row['row_id']}").get_by_role("link", name="Notices").first.click() + page.wait_for_load_state("networkidle", timeout=15_000) + except Exception as e: + return 0, f"navigation Notices : {e}", match + + # 3. Scrape grille + try: + notices = _scrape_notices_grid(page) + except Exception as e: + try: + page.goto(students_url) + page.wait_for_load_state("networkidle", timeout=10_000) + except Exception: + pass + return 0, f"scrape grille : {e}", match + + # 4. Insert (le wipe global a déjà été fait au début de la classe) + try: + for n in notices: + if not n["date"]: + continue + sess.add(ApprentiNotice( + apprenti_id = match.id, + date_event = n["date"], + type_notice = n.get("type"), + auteur = n.get("auteur"), + titre = n.get("titre"), + remarque = n.get("remarque"), + matiere = n.get("matiere"), + visible_classe = n.get("visible_classe"), + imported_at = datetime.now(), + )) + sess.commit() + except Exception as e: + sess.rollback() + return 0, f"DB insert : {e}", match + + # 5. Retour à la liste élèves + try: + page.goto(students_url) + page.wait_for_load_state("networkidle", timeout=15_000) + except Exception: + pass + + return len(notices), None, match + + +def main(): + if len(sys.argv) < 2: + print("Usage : pull_notices.py CLASSE1 [CLASSE2 ...]", file=sys.stderr) + sys.exit(2) + + target_classes = [c for c in sys.argv[1:] if c.strip()] + sess = get_session() + ok_count = 0 + total_imported = 0 + errors: list[str] = [] + + try: + app_log(f"[pull_notices] démarrage — {len(target_classes)} classe(s)") + pw, ctx, page = _launch_context() + try: + page.goto(CLASSES_URL) + _ensure_logged_in(page) + + for classe in target_classes: + _log(f"[pull_notices] classe={classe}") + + # 1. Wipe global des notices existantes pour les apprentis de cette classe + db_apprentis = sess.execute( + select(Apprenti).where(Apprenti.classe == classe) + ).scalars().all() + if db_apprentis: + appr_ids = [a.id for a in db_apprentis] + sess.execute( + delete(ApprentiNotice).where(ApprentiNotice.apprenti_id.in_(appr_ids)) + ) + sess.commit() + _log(f" [{classe}] wipe ApprentiNotice : {len(appr_ids)} apprenti(s)") + + # 2. Navigue vers la liste Élèves + try: + students_page = _go_to_students_page(page, classe) + except Exception as e: + students_page = None + _log(f" ERR navigation : {e}") + if not students_page: + errors.append(f"classe '{classe}' : page Élèves introuvable") + continue + students_url = students_page.url + + # 3. Liste des lignes (avec drapeau has_notices) + try: + rows = _student_rows(students_page) + except Exception as e: + errors.append(f"classe '{classe}' : scrape liste élèves : {e}") + continue + nb_with = sum(1 for r in rows if r["has_notices"]) + _log(f" [{classe}] {len(rows)} élève(s), {nb_with} avec notice(s)") + + # 4. Pour chaque ligne ayant des notices : pull + for r in rows: + label = f"{r.get('nom','?')} {r.get('prenom','?')}" + if not r["has_notices"]: + continue + try: + n, err, match = _pull_one_row( + students_page, sess, r, classe, students_url, db_apprentis, + ) + if err: + errors.append(f"{label} ({classe}) : {err}") + try: + students_page.goto(students_url) + students_page.wait_for_load_state("networkidle", timeout=10_000) + except Exception: + break + else: + ok_count += 1 + total_imported += n + _log(f" OK {label} : {n} notice(s)") + except Exception as e: + errors.append(f"{label} ({classe}) : {e}") + _log(f" EX {label} : {e}\n{traceback.format_exc()}") + finally: + try: ctx.close() + except Exception: pass + try: pw.stop() + except Exception: pass + finally: + sess.close() + print( + 'PULL_NOTICES_DONE ' + + json.dumps({ + "ok": ok_count, + "imported": total_imported, + "err": errors, + }, ensure_ascii=False), + flush=True, + ) + app_log( + f"[pull_notices] terminé — {ok_count} apprenti(s) OK, " + f"{total_imported} notice(s) importée(s), {len(errors)} erreur(s)" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/push_to_escada.py b/scripts/push_to_escada.py index 190e143..2df579e 100644 --- a/scripts/push_to_escada.py +++ b/scripts/push_to_escada.py @@ -4,12 +4,10 @@ Usage : python scripts/push_to_escada.py # tous les changements en attente python scripts/push_to_escada.py --test # test limité à Poidevin Alexandre / EM-AU 1 python scripts/push_to_escada.py --count # affiche le nombre de changements en attente - python scripts/push_to_escada.py --no-pull # ne pas récupérer le serveur avant push """ from __future__ import annotations import json -import subprocess import sys from datetime import date from pathlib import Path @@ -25,7 +23,7 @@ if hasattr(sys.stderr, "reconfigure"): from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout -from src.db import Absence, Apprenti, EscadaPending, get_engine, init_db, upsert_escada_pending +from src.db import Absence, Apprenti, EscadaPending, get_engine, init_db from sqlalchemy.orm import sessionmaker, Session from sqlalchemy import select @@ -36,10 +34,6 @@ from scripts.sync_esacada import ( _go_to_absence_page, _cache_load, ) -# ── Coordonnées du serveur ──────────────────────────────────────────────────── -_SSH_HOST = "julbal@20.199.136.37" -_SSH_REMOTE = "/opt/absences" - # ── Interaction avec la page d'absences ─────────────────────────────────────── @@ -227,90 +221,6 @@ def _save(page) -> bool: return False -# ── Synchronisation avec le serveur ────────────────────────────────────────── - -def _pull_from_server(session: Session) -> dict[tuple, int]: - """SSH → serveur, exporte EscadaPending en JSON, upsert en local. - - Retourne un mapping (nom, prenom, classe, date_iso, periode) → server_id - pour permettre le nettoyage côté serveur après push réussi. - """ - _log("PULL Récupération des modifications en attente depuis le serveur…") - cmd = ( - f'ssh {_SSH_HOST} ' - f'"cd {_SSH_REMOTE} && .venv/bin/python scripts/export_pending.py"' - ) - try: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=30, shell=True - ) - if result.returncode != 0: - _log(f" WARN SSH export_pending échoué : {result.stderr.strip()}") - return {} - raw = result.stdout.strip() - if not raw: - _log(" INFO Aucune modification en attente sur le serveur.") - return {} - entries = json.loads(raw) - except Exception as e: - _log(f" WARN Impossible de récupérer depuis le serveur : {e}") - return {} - - if not entries: - _log(" INFO Aucune modification en attente sur le serveur.") - return {} - - _log(f" {len(entries)} entrée(s) récupérée(s) du serveur") - - server_id_map: dict[tuple, int] = {} - for entry in entries: - ap = session.execute( - select(Apprenti).where( - Apprenti.nom == entry["nom"], - Apprenti.prenom == entry["prenom"], - Apprenti.classe == entry["classe"], - ) - ).scalar_one_or_none() - if ap is None: - _log( - f" WARN apprenti introuvable localement : " - f"{entry['nom']} {entry['prenom']} / {entry['classe']}" - ) - continue - - d = date.fromisoformat(entry["date"]) - upsert_escada_pending(session, ap.id, d, entry["periode"], entry["action"]) - - key = (entry["nom"], entry["prenom"], entry["classe"], - entry["date"], entry["periode"]) - server_id_map[key] = entry["id"] - - session.commit() - _log(f" {len(server_id_map)} entrée(s) fusionnée(s) dans la DB locale") - return server_id_map - - -def _clear_server_pending(server_ids: list[int]) -> None: - """SSH → serveur pour supprimer les EscadaPending par IDs.""" - if not server_ids: - return - ids_str = " ".join(str(i) for i in server_ids) - cmd = ( - f'ssh {_SSH_HOST} ' - f'"cd {_SSH_REMOTE} && .venv/bin/python scripts/clear_pending.py {ids_str}"' - ) - try: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=30, shell=True - ) - if result.returncode != 0: - _log(f" WARN SSH clear_pending échoué : {result.stderr.strip()}") - else: - _log(f" OK serveur nettoyé ({result.stdout.strip()})") - except Exception as e: - _log(f" WARN Impossible de nettoyer le serveur : {e}") - - # ── Commande principale ─────────────────────────────────────────────────────── def cmd_count(session: Session) -> None: @@ -322,22 +232,13 @@ def cmd_count(session: Session) -> None: _log(f" {ap.classe} | {ap.nom} {ap.prenom} | {ep.date} P{ep.periode} → {ep.action}") -def cmd_push(session: Session, test_mode: bool = False, no_pull: bool = False, debug: bool = False) -> None: +def cmd_push(session: Session, test_mode: bool = False, debug: bool = False) -> None: """Pousse tous les changements en attente vers Escada. - 1. Pull depuis le serveur (sauf --no-pull). - 2. Lecture des EscadaPending locaux. - 3. Navigation Playwright + mise à jour des dropdowns. - 4. Nettoyage côté serveur pour les entrées syncées avec succès. + 1. Lecture des EscadaPending locaux. + 2. Navigation Playwright + mise à jour des dropdowns. """ - # ── 1. Pull depuis le serveur ───────────────────────────────────────────── - server_id_map: dict[tuple, int] = {} - if not no_pull: - server_id_map = _pull_from_server(session) - else: - _log("INFO --no-pull : synchronisation serveur ignorée") - - # ── 2. Lecture des EscadaPending locaux ─────────────────────────────────── + # ── 1. Lecture des EscadaPending locaux ─────────────────────────────────── q = select(EscadaPending).join(Apprenti, EscadaPending.apprenti_id == Apprenti.id) if test_mode: _log("INFO Mode test : Poidevin Alexandre / EM-AU 1 uniquement") @@ -364,8 +265,6 @@ def cmd_push(session: Session, test_mode: bool = False, no_pull: bool = False, d _ensure_logged_in(page) results = {"ok": [], "err": []} - # EscadaPending IDs locaux syncés avec succès → pour retrouver les server_ids - synced_eps: list[EscadaPending] = [] for i, ((classe, target_date), entries) in enumerate(sorted(groups.items()), 1): _log(f"PROGRESS {i}/{len(groups)} {classe} {target_date}") @@ -418,25 +317,12 @@ def cmd_push(session: Session, test_mode: bool = False, no_pull: bool = False, d session.commit() _log(f"OK {classe} {target_date} : {len(synced_ids)} changement(s) sauvegardé(s)") results["ok"].extend(synced_ids) - synced_eps.extend(synced_ep_objs) else: _log(f"ERR {classe} {target_date} : sauvegarde échouée") results["err"].append(f"{classe} {target_date} : enregistrement échoué") _log(f"PUSH_DONE {json.dumps({'ok': len(results['ok']), 'err': results['err']}, ensure_ascii=False)}") - # ── 4. Nettoyage côté serveur ───────────────────────────────────────── - if server_id_map and synced_eps: - server_ids_to_clear: list[int] = [] - for ep in synced_eps: - ap = ep.apprenti - key = (ap.nom, ap.prenom, ap.classe, ep.date.isoformat(), ep.periode) - srv_id = server_id_map.get(key) - if srv_id is not None: - server_ids_to_clear.append(srv_id) - if server_ids_to_clear: - _clear_server_pending(server_ids_to_clear) - finally: ctx.close() pw.stop() @@ -449,11 +335,9 @@ if __name__ == "__main__": ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - ap.add_argument("--test", action="store_true", help="Limite au test Poidevin Alexandre") - ap.add_argument("--count", action="store_true", help="Affiche les changements en attente") - ap.add_argument("--no-pull", action="store_true", help="Ne pas récupérer les données du serveur avant push") - ap.add_argument("--pull-only", action="store_true", help="Récupère depuis le serveur sans pousser vers Escada") - ap.add_argument("--debug", action="store_true", help="Pause interactive après ouverture de la page absences") + ap.add_argument("--test", action="store_true", help="Limite au test Poidevin Alexandre") + ap.add_argument("--count", action="store_true", help="Affiche les changements en attente") + ap.add_argument("--debug", action="store_true", help="Pause interactive après ouverture de la page absences") args = ap.parse_args() engine = init_db() @@ -461,7 +345,5 @@ if __name__ == "__main__": with Session_() as sess: if args.count: cmd_count(sess) - elif args.pull_only: - _pull_from_server(sess) else: - cmd_push(sess, test_mode=args.test, no_pull=args.no_pull, debug=args.debug) + cmd_push(sess, test_mode=args.test, debug=args.debug) diff --git a/src/db.py b/src/db.py index 1bf7a4c..4c1422c 100644 --- a/src/db.py +++ b/src/db.py @@ -233,6 +233,28 @@ class Notice(Base): apprenti: Mapped["Apprenti"] = relationship() +class ApprentiNotice(Base): + """Notices scrapées depuis Escada (read-only côté app, pas re-poussées). + + Stratégie : à chaque pull, on supprime toutes les ApprentiNotice de + l'apprenti puis on ré-insère depuis Escada (full replace). + """ + __tablename__ = "apprenti_notices" + + id: Mapped[int] = mapped_column(primary_key=True) + apprenti_id: Mapped[int] = mapped_column(ForeignKey("apprentis.id")) + date_event: Mapped[date] + type_notice: Mapped[Optional[str]] = mapped_column(String, nullable=True) + auteur: Mapped[Optional[str]] = mapped_column(String, nullable=True) + titre: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + remarque: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + matiere: Mapped[Optional[str]] = mapped_column(String, nullable=True) + visible_classe: Mapped[Optional[bool]] = mapped_column(nullable=True) + imported_at: Mapped[datetime] = mapped_column(default=datetime.now) + + apprenti: Mapped["Apprenti"] = relationship() + + class SanctionExport(Base): __tablename__ = "sanctions_export" @@ -261,13 +283,18 @@ class CronJob(Base): schedule_value: Mapped[str] = mapped_column(default="03:00") # task_kind ∈ {"push", "sync", "push_then_sync"} + # Les sous-options sync_* déterminent _sur quoi_ le push/sync agit : + # push : push_to_escada.py si sync_abs, et/ou push_notices.py si sync_notices + # sync : sync_esacada.py si une de {sync_abs, sync_bn, sync_notes, sync_fiches}, + # et/ou pull_notices.py si sync_notices task_kind: Mapped[str] = mapped_column(default="push_then_sync") - # Sous-options pour task sync + # Sous-options : quelles données traiter sync_abs: Mapped[bool] = mapped_column(default=True) sync_bn: Mapped[bool] = mapped_column(default=True) sync_notes: Mapped[bool] = mapped_column(default=True) sync_fiches: Mapped[bool] = mapped_column(default=False) + sync_notices: Mapped[bool] = mapped_column(default=False) force_abs: Mapped[bool] = mapped_column(default=False) # Liste de classes en JSON, ou "ALL" pour toutes @@ -314,6 +341,33 @@ def init_db(engine=None): "ALTER TABLE sanctions_export ADD COLUMN nb_absences INTEGER", "ALTER TABLE cron_jobs ADD COLUMN notify_level TEXT DEFAULT 'normal'", "ALTER TABLE apprenti_fiches ADD COLUMN profession TEXT", + "ALTER TABLE cron_jobs ADD COLUMN sync_notices BOOLEAN DEFAULT 0", + # Migration cron task_kind — schéma 3 valeurs + checkbox sync_notices. + # Étape A : pour les rows qui ciblaient les notices, on flag sync_notices=1 + # et on désactive les autres data flags (avant de perdre l'info en B). + """UPDATE cron_jobs SET + sync_notices = 1, + sync_abs = 0, + sync_bn = 0, + sync_notes = 0, + sync_fiches = 0 + WHERE task_kind IN ('notices_push','notices_sync','notices_push_then_sync','push_notices')""", + # Étape B : on normalise task_kind sur les 3 valeurs canoniques. + "UPDATE cron_jobs SET task_kind='push' WHERE task_kind IN ('absences_push','notices_push')", + "UPDATE cron_jobs SET task_kind='sync' WHERE task_kind IN ('absences_sync','notices_sync')", + "UPDATE cron_jobs SET task_kind='push_then_sync' WHERE task_kind IN ('absences_push_then_sync','notices_push_then_sync','push_notices')", + """CREATE TABLE IF NOT EXISTS apprenti_notices ( + id INTEGER PRIMARY KEY, + apprenti_id INTEGER NOT NULL REFERENCES apprentis(id), + date_event DATE NOT NULL, + type_notice TEXT, + auteur TEXT, + titre TEXT, + remarque TEXT, + matiere TEXT, + visible_classe BOOLEAN, + imported_at DATETIME NOT NULL + )""", """CREATE TABLE IF NOT EXISTS notices ( id INTEGER PRIMARY KEY, apprenti_id INTEGER NOT NULL REFERENCES apprentis(id),