update cron

This commit is contained in:
Julien Balet 2026-05-11 14:45:42 +02:00
parent 6d1b7c8044
commit ef6072112b
20 changed files with 1921 additions and 356 deletions

View file

@ -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 <body>.
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,

View file

@ -11,6 +11,7 @@ credentials:
password: $2b$12$kigcAqfs9VIySuVHxenU6uTyk/8ef7DrzybCFCzw.iZOZTpzxVsOi
role: admin
smtp_password: 17acdfd671d8ab
theme: bleu
totp_secret: H6QDWOPHK4GBT447VCKI6VDKEEUVFQZY
test:
allowed_classes:

View file

@ -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"
}

View file

@ -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)

View file

@ -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",
),

View file

@ -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",

View file

@ -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 = []
@ -240,6 +250,7 @@ class CronState(AuthState):
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()
@ -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,16 +662,23 @@ 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",
rx.text("Données concernées", size="2", font_weight="600",
margin_top="0.5rem"),
rx.flex(
rx.hstack(
@ -666,6 +687,16 @@ def _form_task_picker() -> rx.Component:
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"),
@ -684,17 +715,22 @@ def _form_task_picker() -> rx.Component:
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%",
)

View file

@ -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"
@ -60,7 +61,9 @@ class EscadaState(AuthState):
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)
@ -127,7 +137,9 @@ class EscadaState(AuthState):
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_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
@ -409,7 +443,9 @@ class EscadaState(AuthState):
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
@ -427,6 +463,7 @@ class EscadaState(AuthState):
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", [])
# Pas encore sync_done=True : on attend le pull notices
if not _will_pull_notices:
self.sync_done = True
app_log("Résultats chargés — sync terminée OK")
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.icon("triangle-alert", size=14, color="#b45309"),
rx.text(
"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="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(
"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.",
"Absences",
size="2",
color="#92400e",
color=rx.cond(
EscadaState.sync_abs, "#92400e", "#cbd5e1",
),
font_weight="600",
),
gap="0.5rem",
align="center",
padding="0.5rem 0.75rem",
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",
flex_wrap="wrap",
),
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(

View file

@ -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",
),

View file

@ -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%",

View file

@ -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),

View file

@ -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
# 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.add_notice,
on_change=RetenueState.set_add_notice,
checked=RetenueState.create_anyway,
on_change=RetenueState.set_create_anyway,
size="2",
color_scheme="amber",
),
rx.text(
"Ajouter automatiquement une notice sur Escada",
size="2", color="var(--gray-12)",
"Créer quand même une nouvelle notice",
size="2", color="#78350f", weight="medium",
),
gap="0.5rem", align="center",
padding="0.5rem 0.65rem",
background_color="#f8f9fa",
border="1px solid #e5e7eb",
gap="0.5rem", align="center", margin_top="0.4rem",
),
padding="0.6rem 0.75rem",
background_color="#fef3c7",
border="1px solid #fcd34d",
border_radius="6px",
),
# Actions : télécharger
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",
),
),
# 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%",
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)",
),
rx.vstack(
_form(),
_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",
rx.flex(
rx.dialog.close(
rx.button("Fermer", variant="soft", color_scheme="gray"),
),
gap="0.5rem", justify="end", margin_top="1rem",
),
spacing="4",
width="100%",
max_width="780px",
)
max_width="720px",
max_height="90vh",
overflow_y="auto",
),
open=RetenueState.modal_open,
on_open_change=RetenueState.set_modal_open,
)

View file

@ -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,
)

View file

@ -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 = [

View file

@ -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 <html> 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 <html> 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 = ""

View file

@ -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%",

View file

@ -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

426
scripts/pull_notices.py Executable file
View file

@ -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()

View file

@ -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()
@ -451,8 +337,6 @@ if __name__ == "__main__":
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")
args = ap.parse_args()
@ -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)

View file

@ -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),