update cron
This commit is contained in:
parent
6d1b7c8044
commit
ef6072112b
20 changed files with 1921 additions and 356 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ credentials:
|
|||
password: $2b$12$kigcAqfs9VIySuVHxenU6uTyk/8ef7DrzybCFCzw.iZOZTpzxVsOi
|
||||
role: admin
|
||||
smtp_password: 17acdfd671d8ab
|
||||
theme: bleu
|
||||
totp_secret: H6QDWOPHK4GBT447VCKI6VDKEEUVFQZY
|
||||
test:
|
||||
allowed_classes:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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%",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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%",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
319
eptm_dashboard/pages/sanction.py
Normal file
319
eptm_dashboard/pages/sanction.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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%",
|
||||
|
|
|
|||
|
|
@ -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
426
scripts/pull_notices.py
Executable 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
56
src/db.py
56
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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue