retenue: avis PDF + notices Escada + mapping profession
- nouvelle page /retenue : sélection apprenti, date retenue, date du
problème, motif (3 cases mutex), branche (autocomplete + saisie libre
depuis NotesExamen), remarque. Génération PDF basée sur le template
AcroForm officiel, séparation des 3 widgets Date partagés en 3 champs
distincts pour ne remplir que celui de la case cochée. Téléchargement
ou envoi par email (3 destinataires).
- profession : nouveau champ ApprentiFiche.profession, dérivé du préfixe
de classe via mapping configurable dans Paramètres
("AUTOMAT" → "Automaticien CFC" par défaut). Section dédiée avec
classes orphelines détectées automatiquement.
- notices Escada : nouvelle table Notice (apprenti, titre, remarque,
date, status). Checkbox "Ajouter automatiquement une notice sur
Escada" sur /retenue qui crée une entrée pending. Bloc dédié sur
/escada listant les pending, bouton "Pousser les notices" qui lance
scripts/push_notices.py (Playwright : navigation Classes → Élèves →
Notices → Ajouter, fill date / titre / remarque, vérification post-save,
suppression DB si OK, marquage failed sinon). Nouveau task_kind "push_notices"
dans le cron pour exécution planifiée.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6a69f36e83
commit
6d1b7c8044
13 changed files with 1876 additions and 5 deletions
BIN
data/templates/GF_FO_Avis_de_retenue.pdf
Normal file
BIN
data/templates/GF_FO_Avis_de_retenue.pdf
Normal file
Binary file not shown.
|
|
@ -13,6 +13,7 @@ from .pages.purge import purge_page, PurgeState
|
||||||
from .pages.doc import doc_page, DocState
|
from .pages.doc import doc_page, DocState
|
||||||
from .pages.profile import profile_page, ProfileState
|
from .pages.profile import profile_page, ProfileState
|
||||||
from .pages.password_set import password_set_page, PasswordSetState
|
from .pages.password_set import password_set_page, PasswordSetState
|
||||||
|
from .pages.retenue import retenue_page, RetenueState
|
||||||
|
|
||||||
TITLE = "EPTM Dashboard"
|
TITLE = "EPTM Dashboard"
|
||||||
|
|
||||||
|
|
@ -58,5 +59,6 @@ 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(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(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(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)
|
# 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)
|
app.add_page(password_set_page, route="/password-set", on_load=PasswordSetState.load_data, title=TITLE)
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,8 @@ class CronState(AuthState):
|
||||||
"schedule_desc": desc,
|
"schedule_desc": desc,
|
||||||
"task_kind": job.task_kind,
|
"task_kind": job.task_kind,
|
||||||
"task_label": {"push": "Push", "sync": "Sync",
|
"task_label": {"push": "Push", "sync": "Sync",
|
||||||
"push_then_sync": "Push + Sync"}.get(job.task_kind, job.task_kind),
|
"push_then_sync": "Push + Sync",
|
||||||
|
"push_notices": "Push notices"}.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_run_at": job.last_run_at.strftime("%d.%m.%Y %H:%M") if job.last_run_at else "",
|
||||||
"last_status": job.last_status,
|
"last_status": job.last_status,
|
||||||
"last_message": job.last_message[:120] if job.last_message else "",
|
"last_message": job.last_message[:120] if job.last_message else "",
|
||||||
|
|
@ -648,13 +649,13 @@ def _form_task_picker() -> rx.Component:
|
||||||
return rx.vstack(
|
return rx.vstack(
|
||||||
rx.text("Tâche", size="2", font_weight="600"),
|
rx.text("Tâche", size="2", font_weight="600"),
|
||||||
rx.radio(
|
rx.radio(
|
||||||
["push", "sync", "push_then_sync"],
|
["push", "sync", "push_then_sync", "push_notices"],
|
||||||
value=CronState.f_task_kind,
|
value=CronState.f_task_kind,
|
||||||
on_change=CronState.set_f_task_kind,
|
on_change=CronState.set_f_task_kind,
|
||||||
direction="column",
|
direction="column",
|
||||||
),
|
),
|
||||||
rx.cond(
|
rx.cond(
|
||||||
CronState.f_task_kind != "push",
|
(CronState.f_task_kind != "push") & (CronState.f_task_kind != "push_notices"),
|
||||||
rx.vstack(
|
rx.vstack(
|
||||||
rx.text("Données à synchroniser", size="2", font_weight="600",
|
rx.text("Données à synchroniser", size="2", font_weight="600",
|
||||||
margin_top="0.5rem"),
|
margin_top="0.5rem"),
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ def _background(fn):
|
||||||
|
|
||||||
from ..state import AuthState
|
from ..state import AuthState
|
||||||
from ..sidebar import layout
|
from ..sidebar import layout
|
||||||
from src.db import get_session, Apprenti, EscadaPending
|
from src.db import get_session, Apprenti, EscadaPending, Notice
|
||||||
from src.logger import app_log
|
from src.logger import app_log
|
||||||
|
|
||||||
_RE_SYNC_PROD = re.compile(r"^\[\d{2}:\d{2}:\d{2}\] ([^ ].*)$")
|
_RE_SYNC_PROD = re.compile(r"^\[\d{2}:\d{2}:\d{2}\] ([^ ].*)$")
|
||||||
|
|
@ -45,6 +45,7 @@ DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
|
||||||
CLASSES_CACHE = DATA_DIR / "esacada_classes.json"
|
CLASSES_CACHE = DATA_DIR / "esacada_classes.json"
|
||||||
_SYNC_SCRIPT = _ROOT / "scripts" / "sync_esacada.py"
|
_SYNC_SCRIPT = _ROOT / "scripts" / "sync_esacada.py"
|
||||||
_PUSH_SCRIPT = _ROOT / "scripts" / "push_to_escada.py"
|
_PUSH_SCRIPT = _ROOT / "scripts" / "push_to_escada.py"
|
||||||
|
_PUSH_NOTICES_SCRIPT = _ROOT / "scripts" / "push_notices.py"
|
||||||
_SYNC_RESULT_FILE = DATA_DIR / "sync_last_result.json"
|
_SYNC_RESULT_FILE = DATA_DIR / "sync_last_result.json"
|
||||||
_SYNC_ALL_DONE_FILE = DATA_DIR / "sync_all_done.json"
|
_SYNC_ALL_DONE_FILE = DATA_DIR / "sync_all_done.json"
|
||||||
|
|
||||||
|
|
@ -77,11 +78,19 @@ class EscadaState(AuthState):
|
||||||
|
|
||||||
pending_count: int = 0
|
pending_count: int = 0
|
||||||
pending_data: list[dict] = []
|
pending_data: list[dict] = []
|
||||||
|
notices_count: int = 0
|
||||||
|
notices_data: list[dict] = []
|
||||||
|
|
||||||
push_done: bool = False
|
push_done: bool = False
|
||||||
push_ok: int = 0
|
push_ok: int = 0
|
||||||
push_errors: list[str] = []
|
push_errors: list[str] = []
|
||||||
|
|
||||||
|
# Push notices
|
||||||
|
is_pushing_notices: bool = False
|
||||||
|
notices_push_ok: int = 0
|
||||||
|
notices_push_done: bool = False
|
||||||
|
notices_push_errors: list[str] = []
|
||||||
|
|
||||||
@rx.var
|
@rx.var
|
||||||
def selected_count(self) -> int:
|
def selected_count(self) -> int:
|
||||||
return sum(1 for v in self.class_checked.values() if v)
|
return sum(1 for v in self.class_checked.values() if v)
|
||||||
|
|
@ -221,9 +230,32 @@ class EscadaState(AuthState):
|
||||||
}
|
}
|
||||||
for ep in pending
|
for ep in pending
|
||||||
]
|
]
|
||||||
|
self._reload_notices(sess)
|
||||||
finally:
|
finally:
|
||||||
sess.close()
|
sess.close()
|
||||||
|
|
||||||
|
def _reload_notices(self, sess):
|
||||||
|
notices = sess.execute(
|
||||||
|
select(Notice)
|
||||||
|
.options(joinedload(Notice.apprenti))
|
||||||
|
.join(Apprenti, Notice.apprenti_id == Apprenti.id)
|
||||||
|
.where(Notice.status == "pending")
|
||||||
|
.order_by(Apprenti.classe, Notice.date_event, Apprenti.nom)
|
||||||
|
).scalars().all()
|
||||||
|
self.notices_count = len(notices)
|
||||||
|
self.notices_data = [
|
||||||
|
{
|
||||||
|
"id": n.id,
|
||||||
|
"classe": n.apprenti.classe,
|
||||||
|
"nom": n.apprenti.nom,
|
||||||
|
"prenom": n.apprenti.prenom,
|
||||||
|
"date": n.date_event.strftime("%d.%m.%Y"),
|
||||||
|
"titre": (n.titre or "")[:80] + ("…" if len(n.titre or "") > 80 else ""),
|
||||||
|
"source": n.source,
|
||||||
|
}
|
||||||
|
for n in notices
|
||||||
|
]
|
||||||
|
|
||||||
# ── Background: refresh classes ────────────────────────────────────────────
|
# ── Background: refresh classes ────────────────────────────────────────────
|
||||||
|
|
||||||
@_background
|
@_background
|
||||||
|
|
@ -767,6 +799,121 @@ class EscadaState(AuthState):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# ── Background: push notices vers Escada ──────────────────────────────────
|
||||||
|
|
||||||
|
@_background
|
||||||
|
async def push_notices(self):
|
||||||
|
async with self:
|
||||||
|
user = self.username or "?"
|
||||||
|
self.is_pushing_notices = True
|
||||||
|
self.notices_push_done = False
|
||||||
|
self.notices_push_ok = 0
|
||||||
|
self.notices_push_errors = []
|
||||||
|
|
||||||
|
app_log(f"Push notices Escada démarré par {user}")
|
||||||
|
cmd = [sys.executable, str(_PUSH_NOTICES_SCRIPT)]
|
||||||
|
lines: list[str] = []
|
||||||
|
_rc_holder = [0]
|
||||||
|
|
||||||
|
def _run() -> None:
|
||||||
|
_fd, _tmp = tempfile.mkstemp(suffix="_push_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="push_notices")
|
||||||
|
if _proc.poll() is not None:
|
||||||
|
_rc_holder[0] = _proc.wait() or 0
|
||||||
|
break
|
||||||
|
except Exception as _exc:
|
||||||
|
app_log(f"Erreur push 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"[push_notices] thread exception : {_te}")
|
||||||
|
finally:
|
||||||
|
_pool.shutdown(wait=False)
|
||||||
|
|
||||||
|
_rc = _rc_holder[0]
|
||||||
|
nb_ok = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
done = False
|
||||||
|
for line in lines:
|
||||||
|
if "PUSH_NOTICES_DONE " in line:
|
||||||
|
done = True
|
||||||
|
try:
|
||||||
|
p = json.loads(line[line.index("PUSH_NOTICES_DONE ") + len("PUSH_NOTICES_DONE "):])
|
||||||
|
nb_ok = p.get("ok", 0)
|
||||||
|
errors = p.get("err", [])
|
||||||
|
except Exception as _e:
|
||||||
|
app_log(f" Erreur parse PUSH_NOTICES_DONE : {_e}", debug=True)
|
||||||
|
|
||||||
|
if done:
|
||||||
|
app_log(f"Push notices terminé — ok:{nb_ok} erreurs:{len(errors)}")
|
||||||
|
else:
|
||||||
|
app_log(f"Push notices : PUSH_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_push_done = done
|
||||||
|
self.notices_push_ok = nb_ok
|
||||||
|
self.notices_push_errors = errors
|
||||||
|
self.is_pushing_notices = False
|
||||||
|
self._reload_pending()
|
||||||
|
if done:
|
||||||
|
if errors:
|
||||||
|
yield rx.toast.warning(
|
||||||
|
f"Push notices : {nb_ok} OK, {len(errors)} erreur(s)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
yield rx.toast.success(f"Push notices terminé — {nb_ok} envoyée(s)")
|
||||||
|
else:
|
||||||
|
yield rx.toast.error("Push notices échoué — vérifiez les logs")
|
||||||
|
except Exception as _e:
|
||||||
|
app_log(f"Erreur mise à jour état push notices : {_e}")
|
||||||
|
try:
|
||||||
|
async with self:
|
||||||
|
self.is_pushing_notices = False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ── UI helpers ────────────────────────────────────────────────────────────────
|
# ── UI helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -932,6 +1079,18 @@ def _pending_row(item) -> rx.Component:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notice_row(item) -> rx.Component:
|
||||||
|
return rx.table.row(
|
||||||
|
rx.table.cell(item["classe"]),
|
||||||
|
rx.table.cell(rx.text(item["nom"], " ", item["prenom"])),
|
||||||
|
rx.table.cell(item["date"]),
|
||||||
|
rx.table.cell(rx.text(item["titre"], size="1")),
|
||||||
|
rx.table.cell(
|
||||||
|
rx.badge(item["source"], color_scheme="blue", variant="soft", size="1"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sync_progress() -> rx.Component:
|
def _sync_progress() -> rx.Component:
|
||||||
"""Indicateurs de progression — remplace l'ancien op_log dans la section sync."""
|
"""Indicateurs de progression — remplace l'ancien op_log dans la section sync."""
|
||||||
return rx.vstack(
|
return rx.vstack(
|
||||||
|
|
@ -1361,6 +1520,103 @@ def escada_page() -> rx.Component:
|
||||||
width="100%",
|
width="100%",
|
||||||
),
|
),
|
||||||
|
|
||||||
|
# ── Section notices ───────────────────────────────────────────────
|
||||||
|
rx.box(
|
||||||
|
rx.text(
|
||||||
|
"Notices en attente",
|
||||||
|
size="3", font_weight="700", color="#37474f",
|
||||||
|
margin_bottom="0.75rem",
|
||||||
|
),
|
||||||
|
rx.cond(
|
||||||
|
EscadaState.notices_count == 0,
|
||||||
|
rx.text("Aucune notice en attente.", size="2", color="#666"),
|
||||||
|
rx.vstack(
|
||||||
|
rx.text(
|
||||||
|
EscadaState.notices_count,
|
||||||
|
" notice(s) en attente d'envoi vers Escada.",
|
||||||
|
size="2", color="#e65100", font_weight="600",
|
||||||
|
),
|
||||||
|
rx.box(
|
||||||
|
rx.table.root(
|
||||||
|
rx.table.header(
|
||||||
|
rx.table.row(
|
||||||
|
rx.table.column_header_cell("Classe"),
|
||||||
|
rx.table.column_header_cell("Apprenti"),
|
||||||
|
rx.table.column_header_cell("Date"),
|
||||||
|
rx.table.column_header_cell("Titre"),
|
||||||
|
rx.table.column_header_cell("Source"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
rx.table.body(
|
||||||
|
rx.foreach(EscadaState.notices_data, _notice_row),
|
||||||
|
),
|
||||||
|
width="100%",
|
||||||
|
size="1",
|
||||||
|
),
|
||||||
|
overflow_x="auto",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
spacing="2",
|
||||||
|
width="100%",
|
||||||
|
margin_bottom="0.75rem",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rx.flex(
|
||||||
|
rx.button(
|
||||||
|
rx.cond(
|
||||||
|
EscadaState.is_pushing_notices,
|
||||||
|
rx.spinner(size="2"),
|
||||||
|
rx.icon("send", size=14),
|
||||||
|
),
|
||||||
|
rx.cond(
|
||||||
|
EscadaState.is_pushing_notices,
|
||||||
|
rx.text("Envoi en cours..."),
|
||||||
|
rx.text("Pousser les notices"),
|
||||||
|
),
|
||||||
|
on_click=EscadaState.push_notices,
|
||||||
|
disabled=(
|
||||||
|
EscadaState.is_pushing_notices
|
||||||
|
| (EscadaState.notices_count == 0)
|
||||||
|
),
|
||||||
|
color_scheme="blue",
|
||||||
|
size="2",
|
||||||
|
),
|
||||||
|
gap="1rem", align="center", flex_wrap="wrap",
|
||||||
|
margin_top="0.75rem",
|
||||||
|
),
|
||||||
|
rx.cond(
|
||||||
|
EscadaState.notices_push_done,
|
||||||
|
rx.vstack(
|
||||||
|
rx.cond(
|
||||||
|
EscadaState.notices_push_ok > 0,
|
||||||
|
rx.text(
|
||||||
|
EscadaState.notices_push_ok,
|
||||||
|
" notice(s) envoyée(s).",
|
||||||
|
size="2", color="#2e7d32", font_weight="600",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rx.cond(
|
||||||
|
EscadaState.notices_push_errors.length() > 0,
|
||||||
|
rx.vstack(
|
||||||
|
rx.foreach(
|
||||||
|
EscadaState.notices_push_errors,
|
||||||
|
lambda e: rx.text("• ", e, size="2", color="#c62828"),
|
||||||
|
),
|
||||||
|
spacing="1",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
spacing="2",
|
||||||
|
margin_top="0.75rem",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding="1.25rem",
|
||||||
|
background_color="white",
|
||||||
|
border_radius="8px",
|
||||||
|
border="1px solid #e0e0e0",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
|
||||||
spacing="4",
|
spacing="4",
|
||||||
width="100%",
|
width="100%",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import reflex as rx
|
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.profession import load_mapping, save_mapping, find_unmapped_classes, refresh_all_professions # noqa: E402
|
||||||
|
from src.db import get_session # noqa: E402
|
||||||
|
|
||||||
from ..sidebar import layout
|
from ..sidebar import layout
|
||||||
from ..state import AuthState
|
from ..state import AuthState
|
||||||
|
|
||||||
|
|
@ -67,6 +75,14 @@ class ParamsState(AuthState):
|
||||||
app_base_url: str = ""
|
app_base_url: str = ""
|
||||||
save_ok_app: bool = False
|
save_ok_app: bool = False
|
||||||
|
|
||||||
|
# ── Profession mapping ────────────────────────────────────────────────────
|
||||||
|
prof_mapping: list[dict] = []
|
||||||
|
prof_unmapped: list[str] = []
|
||||||
|
new_prefix: str = ""
|
||||||
|
new_profession: str = ""
|
||||||
|
save_ok_prof: bool = False
|
||||||
|
refresh_msg: str = ""
|
||||||
|
|
||||||
# ── Setters ───────────────────────────────────────────────────────────────
|
# ── Setters ───────────────────────────────────────────────────────────────
|
||||||
def set_texte_sanction(self, v: str): self.texte_sanction = v
|
def set_texte_sanction(self, v: str): self.texte_sanction = v
|
||||||
def set_chef_section(self, v: str): self.chef_section = v
|
def set_chef_section(self, v: str): self.chef_section = v
|
||||||
|
|
@ -81,6 +97,8 @@ class ParamsState(AuthState):
|
||||||
def set_email_subject(self, v: str): self.email_subject = v
|
def set_email_subject(self, v: str): self.email_subject = v
|
||||||
def set_email_body(self, v: str): self.email_body = v
|
def set_email_body(self, v: str): self.email_body = v
|
||||||
def set_app_base_url(self, v: str): self.app_base_url = v
|
def set_app_base_url(self, v: str): self.app_base_url = v
|
||||||
|
def set_new_prefix(self, v: str): self.new_prefix = v
|
||||||
|
def set_new_profession(self, v: str): self.new_profession = v
|
||||||
|
|
||||||
def load_data(self):
|
def load_data(self):
|
||||||
if not self.authenticated:
|
if not self.authenticated:
|
||||||
|
|
@ -104,6 +122,17 @@ class ParamsState(AuthState):
|
||||||
self.save_ok_escada = False
|
self.save_ok_escada = False
|
||||||
self.save_ok_template = False
|
self.save_ok_template = False
|
||||||
self.save_ok_app = False
|
self.save_ok_app = False
|
||||||
|
self._reload_prof_mapping()
|
||||||
|
|
||||||
|
def _reload_prof_mapping(self):
|
||||||
|
self.prof_mapping = load_mapping()
|
||||||
|
sess = get_session()
|
||||||
|
try:
|
||||||
|
self.prof_unmapped = find_unmapped_classes(sess)
|
||||||
|
finally:
|
||||||
|
sess.close()
|
||||||
|
self.save_ok_prof = False
|
||||||
|
self.refresh_msg = ""
|
||||||
|
|
||||||
def save_sanctions(self):
|
def save_sanctions(self):
|
||||||
s = _read_settings()
|
s = _read_settings()
|
||||||
|
|
@ -164,6 +193,46 @@ class ParamsState(AuthState):
|
||||||
self.save_ok_escada = False
|
self.save_ok_escada = False
|
||||||
self.save_ok_template = False
|
self.save_ok_template = False
|
||||||
|
|
||||||
|
# ── Profession mapping ───────────────────────────────────────────────────
|
||||||
|
def add_mapping(self):
|
||||||
|
prefix = self.new_prefix.strip()
|
||||||
|
prof = self.new_profession.strip()
|
||||||
|
if not prefix or not prof:
|
||||||
|
return
|
||||||
|
cur = list(self.prof_mapping)
|
||||||
|
# Si le préfixe existe déjà, on met juste à jour la profession
|
||||||
|
for m in cur:
|
||||||
|
if m.get("prefix") == prefix:
|
||||||
|
m["profession"] = prof
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
cur.append({"prefix": prefix, "profession": prof})
|
||||||
|
save_mapping(cur)
|
||||||
|
self.new_prefix = ""
|
||||||
|
self.new_profession = ""
|
||||||
|
self._reload_prof_mapping()
|
||||||
|
self.save_ok_prof = True
|
||||||
|
|
||||||
|
def remove_mapping(self, prefix: str):
|
||||||
|
cur = [m for m in self.prof_mapping if m.get("prefix") != prefix]
|
||||||
|
save_mapping(cur)
|
||||||
|
self._reload_prof_mapping()
|
||||||
|
self.save_ok_prof = True
|
||||||
|
|
||||||
|
def quick_add_prefix(self, prefix: str):
|
||||||
|
"""Pré-remplit le formulaire avec une classe orpheline."""
|
||||||
|
self.new_prefix = prefix
|
||||||
|
self.new_profession = ""
|
||||||
|
|
||||||
|
def apply_mapping_to_db(self):
|
||||||
|
"""Recalcule la profession pour tous les apprentis avec le mapping actuel."""
|
||||||
|
sess = get_session()
|
||||||
|
try:
|
||||||
|
n = refresh_all_professions(sess)
|
||||||
|
finally:
|
||||||
|
sess.close()
|
||||||
|
self.refresh_msg = f"{n} fiche(s) mise(s) à jour."
|
||||||
|
|
||||||
|
|
||||||
# ── UI helpers ────────────────────────────────────────────────────────────────
|
# ── UI helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -445,6 +514,127 @@ def _section_template() -> rx.Component:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping_row(m: rx.Var) -> rx.Component:
|
||||||
|
return rx.flex(
|
||||||
|
rx.box(
|
||||||
|
rx.text("Préfixe", size="1", color="var(--gray-10)"),
|
||||||
|
rx.text(m["prefix"], size="2", weight="medium"),
|
||||||
|
flex="1", min_width="120px",
|
||||||
|
),
|
||||||
|
rx.box(
|
||||||
|
rx.text("Profession", size="1", color="var(--gray-10)"),
|
||||||
|
rx.text(m["profession"], size="2"),
|
||||||
|
flex="2", min_width="200px",
|
||||||
|
),
|
||||||
|
rx.button(
|
||||||
|
rx.icon("trash-2", size=14),
|
||||||
|
on_click=ParamsState.remove_mapping(m["prefix"]),
|
||||||
|
color_scheme="red", variant="ghost", size="1",
|
||||||
|
),
|
||||||
|
gap="0.75rem", align="center", flex_wrap="wrap",
|
||||||
|
padding="0.4rem 0.6rem",
|
||||||
|
border="1px solid var(--gray-5)",
|
||||||
|
border_radius="6px",
|
||||||
|
background_color="white",
|
||||||
|
width="100%",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unmapped_chip(classe: rx.Var) -> rx.Component:
|
||||||
|
return rx.button(
|
||||||
|
rx.icon("plus", size=12),
|
||||||
|
classe,
|
||||||
|
on_click=ParamsState.quick_add_prefix(classe),
|
||||||
|
color_scheme="amber", variant="soft", size="1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _section_profession() -> rx.Component:
|
||||||
|
return _section(
|
||||||
|
"Correspondances classe → profession",
|
||||||
|
rx.text(
|
||||||
|
"Lors de l'import des données apprentis, la profession est dérivée "
|
||||||
|
"du préfixe de la classe (ex. classe « AUTOMAT 1 » → profession "
|
||||||
|
"« Automaticien CFC »). Utilisée notamment dans les avis de retenue.",
|
||||||
|
size="1", color="var(--gray-11)",
|
||||||
|
),
|
||||||
|
# Tableau des correspondances
|
||||||
|
rx.cond(
|
||||||
|
ParamsState.prof_mapping.length() > 0,
|
||||||
|
rx.vstack(
|
||||||
|
rx.foreach(ParamsState.prof_mapping, _mapping_row),
|
||||||
|
spacing="2", width="100%",
|
||||||
|
),
|
||||||
|
rx.text("Aucune correspondance configurée.", size="2", color="var(--gray-10)"),
|
||||||
|
),
|
||||||
|
# Classes orphelines
|
||||||
|
rx.cond(
|
||||||
|
ParamsState.prof_unmapped.length() > 0,
|
||||||
|
rx.box(
|
||||||
|
rx.text(
|
||||||
|
"Classes sans correspondance (clique pour ajouter) :",
|
||||||
|
size="2", weight="medium", color="#92400e", margin_bottom="0.4rem",
|
||||||
|
),
|
||||||
|
rx.flex(
|
||||||
|
rx.foreach(ParamsState.prof_unmapped, _unmapped_chip),
|
||||||
|
gap="0.35rem", flex_wrap="wrap",
|
||||||
|
),
|
||||||
|
padding="0.75rem",
|
||||||
|
background_color="#fef3c7",
|
||||||
|
border="1px solid #fcd34d",
|
||||||
|
border_radius="6px",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
rx.fragment(),
|
||||||
|
),
|
||||||
|
# Ajout d'une nouvelle correspondance
|
||||||
|
rx.divider(),
|
||||||
|
rx.text("Ajouter / modifier une correspondance", size="2", weight="medium"),
|
||||||
|
rx.flex(
|
||||||
|
_field(
|
||||||
|
"Préfixe de classe",
|
||||||
|
rx.input(
|
||||||
|
value=ParamsState.new_prefix,
|
||||||
|
on_change=ParamsState.set_new_prefix,
|
||||||
|
placeholder="ex. AUTOMAT",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_field(
|
||||||
|
"Profession",
|
||||||
|
rx.input(
|
||||||
|
value=ParamsState.new_profession,
|
||||||
|
on_change=ParamsState.set_new_profession,
|
||||||
|
placeholder="ex. Automaticien CFC",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
gap="0.75rem", flex_wrap="wrap", width="100%",
|
||||||
|
),
|
||||||
|
rx.flex(
|
||||||
|
rx.button(
|
||||||
|
rx.icon("plus", size=16),
|
||||||
|
"Ajouter / mettre à jour",
|
||||||
|
on_click=ParamsState.add_mapping,
|
||||||
|
color_scheme="blue", size="2",
|
||||||
|
),
|
||||||
|
rx.button(
|
||||||
|
rx.icon("refresh-cw", size=14),
|
||||||
|
"Appliquer aux fiches existantes",
|
||||||
|
on_click=ParamsState.apply_mapping_to_db,
|
||||||
|
color_scheme="gray", variant="soft", size="2",
|
||||||
|
),
|
||||||
|
_save_ok_callout(ParamsState.save_ok_prof),
|
||||||
|
rx.cond(
|
||||||
|
ParamsState.refresh_msg != "",
|
||||||
|
rx.text(ParamsState.refresh_msg, size="1", color="#15803d"),
|
||||||
|
rx.fragment(),
|
||||||
|
),
|
||||||
|
gap="0.5rem", align="center", flex_wrap="wrap",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _section_app() -> rx.Component:
|
def _section_app() -> rx.Component:
|
||||||
return _section(
|
return _section(
|
||||||
"Application",
|
"Application",
|
||||||
|
|
@ -482,6 +672,7 @@ def params_page() -> rx.Component:
|
||||||
rx.vstack(
|
rx.vstack(
|
||||||
rx.heading("Paramètres", size="7"),
|
rx.heading("Paramètres", size="7"),
|
||||||
_section_app(),
|
_section_app(),
|
||||||
|
_section_profession(),
|
||||||
_section_sanction(),
|
_section_sanction(),
|
||||||
_section_smtp(),
|
_section_smtp(),
|
||||||
_section_escada(),
|
_section_escada(),
|
||||||
|
|
|
||||||
778
eptm_dashboard/pages/retenue.py
Normal file
778
eptm_dashboard/pages/retenue.py
Normal file
|
|
@ -0,0 +1,778 @@
|
||||||
|
"""Page /retenue — génération et envoi d'avis de retenue."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import date as _date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import reflex as rx
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
_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, NotesExamen, Notice # noqa: E402
|
||||||
|
from src.user_access import get_allowed_classes, is_class_allowed # noqa: E402
|
||||||
|
from src.profession import resolve_profession # noqa: E402
|
||||||
|
from src.retenue_pdf import generate_retenue_pdf # noqa: E402
|
||||||
|
from src.email_sender import send_email # noqa: E402
|
||||||
|
from src.logger import app_log # noqa: E402
|
||||||
|
|
||||||
|
from ..state import AuthState
|
||||||
|
from ..sidebar import layout
|
||||||
|
from ..components import empty_state
|
||||||
|
|
||||||
|
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 RetenueState(AuthState):
|
||||||
|
# Sélecteur apprenti
|
||||||
|
apprenti_labels: list[str] = []
|
||||||
|
apprenti_ids: list[int] = []
|
||||||
|
selected_label: str = ""
|
||||||
|
selected_id: int = 0
|
||||||
|
has_apprentis: bool = False
|
||||||
|
apprenti_search: str = ""
|
||||||
|
apprenti_select_open: bool = False
|
||||||
|
|
||||||
|
# Données de l'apprenti sélectionné
|
||||||
|
sel_classe: str = ""
|
||||||
|
sel_profession: str = ""
|
||||||
|
sel_fiche_email_appr: str = ""
|
||||||
|
sel_fiche_email_form: str = ""
|
||||||
|
sel_fiche_email_entr: str = ""
|
||||||
|
sel_fiche_nom_entr: str = ""
|
||||||
|
|
||||||
|
# Cache des branches (récupérées des notes d'examen)
|
||||||
|
branches_cache: list[str] = []
|
||||||
|
branche_search: str = ""
|
||||||
|
branche_open: bool = False
|
||||||
|
|
||||||
|
# Formulaire
|
||||||
|
retenue_date: str = "" # ISO date "YYYY-MM-DD"
|
||||||
|
probleme_date: str = ""
|
||||||
|
case: str = "devoir" # "devoir" | "comportement" | "retard"
|
||||||
|
branche: str = ""
|
||||||
|
remarque: str = ""
|
||||||
|
|
||||||
|
# Email
|
||||||
|
email_dest: str = "apprenti"
|
||||||
|
email_custom: str = ""
|
||||||
|
|
||||||
|
# Option : créer une notice Escada à la génération
|
||||||
|
add_notice: bool = False
|
||||||
|
|
||||||
|
# États
|
||||||
|
form_error: str = ""
|
||||||
|
|
||||||
|
@rx.var
|
||||||
|
def filtered_apprenti_labels(self) -> list[str]:
|
||||||
|
q = self.apprenti_search.lower().strip()
|
||||||
|
if not q:
|
||||||
|
return self.apprenti_labels
|
||||||
|
return [l for l in self.apprenti_labels if q in l.lower()]
|
||||||
|
|
||||||
|
@rx.var
|
||||||
|
def filtered_branches(self) -> list[str]:
|
||||||
|
q = self.branche_search.lower().strip()
|
||||||
|
if not q:
|
||||||
|
return self.branches_cache
|
||||||
|
return [b for b in self.branches_cache if q in b.lower()]
|
||||||
|
|
||||||
|
# ── Setters ──────────────────────────────────────────────────────────────
|
||||||
|
def set_apprenti_search(self, v: str): self.apprenti_search = v
|
||||||
|
def set_apprenti_select_open(self, v: bool):
|
||||||
|
self.apprenti_select_open = v
|
||||||
|
if not v:
|
||||||
|
self.apprenti_search = ""
|
||||||
|
def set_branche_search(self, v: str): self.branche_search = v
|
||||||
|
def set_branche_open(self, v: bool):
|
||||||
|
self.branche_open = v
|
||||||
|
if not v:
|
||||||
|
self.branche_search = ""
|
||||||
|
def set_retenue_date(self, v: str): self.retenue_date = v
|
||||||
|
def set_probleme_date(self, v: str): self.probleme_date = v
|
||||||
|
def set_case(self, v: str): self.case = v
|
||||||
|
def set_branche(self, v: str): self.branche = v
|
||||||
|
def set_remarque(self, v: str): self.remarque = v
|
||||||
|
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 load_data(self):
|
||||||
|
if not self.authenticated:
|
||||||
|
return rx.redirect("/login")
|
||||||
|
sess = get_session()
|
||||||
|
try:
|
||||||
|
allowed = get_allowed_classes(self.username)
|
||||||
|
q = select(Apprenti).order_by(Apprenti.nom, Apprenti.prenom)
|
||||||
|
if allowed is not None:
|
||||||
|
q = q.where(Apprenti.classe.in_(allowed))
|
||||||
|
apprentis = sess.execute(q).scalars().all()
|
||||||
|
if not apprentis:
|
||||||
|
self.has_apprentis = False
|
||||||
|
self.apprenti_labels = []
|
||||||
|
self.apprenti_ids = []
|
||||||
|
return
|
||||||
|
self.has_apprentis = True
|
||||||
|
self.apprenti_labels = [
|
||||||
|
f"{a.nom} {a.prenom} ({a.classe})" for a in apprentis
|
||||||
|
]
|
||||||
|
self.apprenti_ids = [a.id for a in apprentis]
|
||||||
|
# Toujours partir d'une sélection vide à l'arrivée sur la page
|
||||||
|
self.selected_id = 0
|
||||||
|
self.selected_label = ""
|
||||||
|
self.sel_classe = ""
|
||||||
|
self.sel_profession = ""
|
||||||
|
self.sel_fiche_email_appr = ""
|
||||||
|
self.sel_fiche_email_form = ""
|
||||||
|
self.sel_fiche_email_entr = ""
|
||||||
|
self.sel_fiche_nom_entr = ""
|
||||||
|
self._load_branches(sess)
|
||||||
|
finally:
|
||||||
|
sess.close()
|
||||||
|
# Dates par défaut = aujourd'hui
|
||||||
|
today = _date.today().isoformat()
|
||||||
|
if not self.retenue_date:
|
||||||
|
self.retenue_date = today
|
||||||
|
if not self.probleme_date:
|
||||||
|
self.probleme_date = today
|
||||||
|
|
||||||
|
def _load_apprenti(self):
|
||||||
|
if not self.selected_id:
|
||||||
|
return
|
||||||
|
sess = get_session()
|
||||||
|
try:
|
||||||
|
ap = sess.get(Apprenti, self.selected_id)
|
||||||
|
if not ap:
|
||||||
|
return
|
||||||
|
self.sel_classe = ap.classe
|
||||||
|
fiche = ap.fiche
|
||||||
|
if fiche:
|
||||||
|
self.sel_profession = fiche.profession or resolve_profession(ap.classe)
|
||||||
|
self.sel_fiche_email_appr = fiche.email or ""
|
||||||
|
self.sel_fiche_email_form = fiche.formateur_email or ""
|
||||||
|
self.sel_fiche_email_entr = fiche.entreprise_email or ""
|
||||||
|
self.sel_fiche_nom_entr = fiche.entreprise_nom or ""
|
||||||
|
else:
|
||||||
|
self.sel_profession = resolve_profession(ap.classe)
|
||||||
|
self.sel_fiche_email_appr = ""
|
||||||
|
self.sel_fiche_email_form = ""
|
||||||
|
self.sel_fiche_email_entr = ""
|
||||||
|
self.sel_fiche_nom_entr = ""
|
||||||
|
finally:
|
||||||
|
sess.close()
|
||||||
|
|
||||||
|
def _load_branches(self, sess):
|
||||||
|
"""Construit le cache des branches uniques depuis NotesExamen."""
|
||||||
|
rows = sess.execute(select(NotesExamen.donnees_json)).scalars().all()
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in rows:
|
||||||
|
try:
|
||||||
|
d = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if isinstance(d, list):
|
||||||
|
for br in d:
|
||||||
|
name = (br.get("branche") or "").strip()
|
||||||
|
if name:
|
||||||
|
seen.add(name)
|
||||||
|
self.branches_cache = sorted(seen)
|
||||||
|
|
||||||
|
def handle_select_apprenti(self, label: str):
|
||||||
|
self.selected_label = label
|
||||||
|
try:
|
||||||
|
idx = self.apprenti_labels.index(label)
|
||||||
|
self.selected_id = self.apprenti_ids[idx]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
self.apprenti_select_open = False
|
||||||
|
self.apprenti_search = ""
|
||||||
|
self._load_apprenti()
|
||||||
|
|
||||||
|
def apprenti_search_keydown(self, key: str):
|
||||||
|
if key == "Enter":
|
||||||
|
results = self.filtered_apprenti_labels
|
||||||
|
if results:
|
||||||
|
return RetenueState.handle_select_apprenti(results[0])
|
||||||
|
elif key == "Escape":
|
||||||
|
self.apprenti_select_open = False
|
||||||
|
self.apprenti_search = ""
|
||||||
|
|
||||||
|
def select_branche(self, b: str):
|
||||||
|
self.branche = b
|
||||||
|
self.branche_open = False
|
||||||
|
self.branche_search = ""
|
||||||
|
|
||||||
|
def branche_keydown(self, key: str):
|
||||||
|
if key == "Enter":
|
||||||
|
# Si une seule branche filtrée : la sélectionne. Sinon prend la saisie libre.
|
||||||
|
results = self.filtered_branches
|
||||||
|
if len(results) == 1:
|
||||||
|
return RetenueState.select_branche(results[0])
|
||||||
|
elif self.branche_search:
|
||||||
|
self.branche = self.branche_search.strip()
|
||||||
|
self.branche_open = False
|
||||||
|
self.branche_search = ""
|
||||||
|
elif key == "Escape":
|
||||||
|
self.branche_open = False
|
||||||
|
self.branche_search = ""
|
||||||
|
|
||||||
|
# ── Actions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_CASE_LABELS = {
|
||||||
|
"devoir": "N'a pas remis ses tâches scolaires dans les délais",
|
||||||
|
"comportement": "A manifesté un comportement répréhensible",
|
||||||
|
"retard": "Est arrivé en retard aux cours",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_notice_titre(self) -> str:
|
||||||
|
label = self._CASE_LABELS.get(self.case, "")
|
||||||
|
if self.case == "devoir" and self.branche.strip():
|
||||||
|
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:
|
||||||
|
return
|
||||||
|
sess = get_session()
|
||||||
|
try:
|
||||||
|
sess.add(Notice(
|
||||||
|
apprenti_id=self.selected_id,
|
||||||
|
date_event=_date.today(),
|
||||||
|
titre=self._build_notice_titre(),
|
||||||
|
remarque=(self.remarque or "").strip() or None,
|
||||||
|
type_notice=None,
|
||||||
|
matiere=None,
|
||||||
|
source="retenue",
|
||||||
|
status="pending",
|
||||||
|
created_by=self.username or None,
|
||||||
|
))
|
||||||
|
sess.commit()
|
||||||
|
app_log(
|
||||||
|
f"[notice] {self.username or '?'} : création (retenue) pour "
|
||||||
|
f"{self.selected_label} — case={self.case}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
sess.rollback()
|
||||||
|
app_log(f"[notice] échec création : {e}")
|
||||||
|
finally:
|
||||||
|
sess.close()
|
||||||
|
|
||||||
|
def _build_pdf(self) -> Optional[bytes]:
|
||||||
|
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
|
||||||
|
if self.case == "devoir" and not self.branche.strip():
|
||||||
|
self.form_error = "Veuillez préciser la branche."
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
r_date = _date.fromisoformat(self.retenue_date)
|
||||||
|
p_date = _date.fromisoformat(self.probleme_date)
|
||||||
|
except Exception:
|
||||||
|
self.form_error = "Date invalide."
|
||||||
|
return None
|
||||||
|
self.form_error = ""
|
||||||
|
sess = get_session()
|
||||||
|
try:
|
||||||
|
return generate_retenue_pdf(
|
||||||
|
sess, self.selected_id,
|
||||||
|
profession=self.sel_profession,
|
||||||
|
retenue_date=r_date,
|
||||||
|
probleme_date=p_date,
|
||||||
|
case=self.case,
|
||||||
|
branche=self.branche.strip(),
|
||||||
|
remarque=self.remarque,
|
||||||
|
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_retenue.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_retenue_{safe_nom}_{safe_prenom}.pdf"
|
||||||
|
finally:
|
||||||
|
sess.close()
|
||||||
|
|
||||||
|
def download_pdf(self):
|
||||||
|
data = self._build_pdf()
|
||||||
|
if data is None:
|
||||||
|
if self.form_error:
|
||||||
|
return rx.toast.error(self.form_error)
|
||||||
|
return rx.toast.error("Impossible de générer le PDF.")
|
||||||
|
app_log(
|
||||||
|
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())
|
||||||
|
|
||||||
|
def send_email_action(self):
|
||||||
|
data = self._build_pdf()
|
||||||
|
if data is None:
|
||||||
|
if self.form_error:
|
||||||
|
return rx.toast.error(self.form_error)
|
||||||
|
return rx.toast.error("Impossible de générer le PDF.")
|
||||||
|
|
||||||
|
# Destinataire
|
||||||
|
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 retenue — {self.selected_label}"
|
||||||
|
body = (
|
||||||
|
f"Bonjour,\n\nVeuillez trouver en pièce jointe l'avis de retenue concernant "
|
||||||
|
f"{self.selected_label}.\n\nCordialement,\n{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"[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}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── UI ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _apprenti_option(label: rx.Var) -> rx.Component:
|
||||||
|
return rx.box(
|
||||||
|
rx.text(label, size="2"),
|
||||||
|
padding="0.45rem 0.75rem",
|
||||||
|
cursor="pointer",
|
||||||
|
on_click=RetenueState.handle_select_apprenti(label),
|
||||||
|
_hover={"background_color": "var(--gray-3)"},
|
||||||
|
width="100%",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apprenti_selector() -> rx.Component:
|
||||||
|
return rx.popover.root(
|
||||||
|
rx.popover.trigger(
|
||||||
|
rx.box(
|
||||||
|
rx.flex(
|
||||||
|
rx.cond(
|
||||||
|
RetenueState.selected_label != "",
|
||||||
|
rx.text(RetenueState.selected_label, size="2"),
|
||||||
|
rx.text("Sélectionner un apprenti…", size="2", color="var(--gray-9)"),
|
||||||
|
),
|
||||||
|
rx.spacer(),
|
||||||
|
rx.icon("chevron-down", size=18, color="var(--gray-9)"),
|
||||||
|
align="center",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
padding="0.5rem 0.75rem",
|
||||||
|
border="1px solid var(--gray-7)",
|
||||||
|
border_radius="6px",
|
||||||
|
background_color="white",
|
||||||
|
cursor="pointer",
|
||||||
|
width="100%",
|
||||||
|
custom_attrs={"data-shortcut": "apprenti-search"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rx.popover.content(
|
||||||
|
rx.vstack(
|
||||||
|
rx.input(
|
||||||
|
placeholder="Rechercher un apprenti…",
|
||||||
|
value=RetenueState.apprenti_search,
|
||||||
|
on_change=RetenueState.set_apprenti_search,
|
||||||
|
on_key_down=RetenueState.apprenti_search_keydown,
|
||||||
|
size="2",
|
||||||
|
width="100%",
|
||||||
|
auto_focus=True,
|
||||||
|
),
|
||||||
|
rx.cond(
|
||||||
|
RetenueState.filtered_apprenti_labels.length() > 0,
|
||||||
|
rx.box(
|
||||||
|
rx.foreach(RetenueState.filtered_apprenti_labels, _apprenti_option),
|
||||||
|
max_height="280px",
|
||||||
|
overflow_y="auto",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
rx.box(
|
||||||
|
rx.text("Aucun résultat", size="2", color="var(--gray-9)"),
|
||||||
|
padding="0.5rem 0.75rem",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
spacing="2",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
min_width="320px",
|
||||||
|
max_width="500px",
|
||||||
|
padding="0.5rem",
|
||||||
|
),
|
||||||
|
open=RetenueState.apprenti_select_open,
|
||||||
|
on_open_change=RetenueState.set_apprenti_select_open,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _branche_option(b: rx.Var) -> rx.Component:
|
||||||
|
return rx.box(
|
||||||
|
rx.text(b, size="2"),
|
||||||
|
padding="0.45rem 0.75rem",
|
||||||
|
cursor="pointer",
|
||||||
|
on_click=RetenueState.select_branche(b),
|
||||||
|
_hover={"background_color": "var(--gray-3)"},
|
||||||
|
width="100%",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _branche_selector() -> rx.Component:
|
||||||
|
return rx.popover.root(
|
||||||
|
rx.popover.trigger(
|
||||||
|
rx.box(
|
||||||
|
rx.flex(
|
||||||
|
rx.cond(
|
||||||
|
RetenueState.branche != "",
|
||||||
|
rx.text(RetenueState.branche, size="2"),
|
||||||
|
rx.text("Choisir / taper une branche…", size="2", color="var(--gray-9)"),
|
||||||
|
),
|
||||||
|
rx.spacer(),
|
||||||
|
rx.icon("chevron-down", size=18, color="var(--gray-9)"),
|
||||||
|
align="center",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
padding="0.5rem 0.75rem",
|
||||||
|
border="1px solid var(--gray-7)",
|
||||||
|
border_radius="6px",
|
||||||
|
background_color="white",
|
||||||
|
cursor="pointer",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rx.popover.content(
|
||||||
|
rx.vstack(
|
||||||
|
rx.input(
|
||||||
|
placeholder="Rechercher ou saisir une branche libre…",
|
||||||
|
value=RetenueState.branche_search,
|
||||||
|
on_change=RetenueState.set_branche_search,
|
||||||
|
on_key_down=RetenueState.branche_keydown,
|
||||||
|
size="2",
|
||||||
|
width="100%",
|
||||||
|
auto_focus=True,
|
||||||
|
),
|
||||||
|
rx.cond(
|
||||||
|
RetenueState.filtered_branches.length() > 0,
|
||||||
|
rx.box(
|
||||||
|
rx.foreach(RetenueState.filtered_branches, _branche_option),
|
||||||
|
max_height="280px",
|
||||||
|
overflow_y="auto",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
rx.text(
|
||||||
|
"Appuyez sur Entrée pour valider votre saisie libre.",
|
||||||
|
size="1", color="var(--gray-9)",
|
||||||
|
padding="0.5rem 0.75rem",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
spacing="2",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
min_width="320px",
|
||||||
|
max_width="500px",
|
||||||
|
padding="0.5rem",
|
||||||
|
),
|
||||||
|
open=RetenueState.branche_open,
|
||||||
|
on_open_change=RetenueState.set_branche_open,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profession_warning() -> rx.Component:
|
||||||
|
# Affiché uniquement si un apprenti est sélectionné ET que sa profession est vide
|
||||||
|
return rx.cond(
|
||||||
|
(RetenueState.selected_id != 0) & (RetenueState.sel_profession == ""),
|
||||||
|
rx.callout.root(
|
||||||
|
rx.callout.icon(rx.icon("triangle-alert", size=16)),
|
||||||
|
rx.callout.text(
|
||||||
|
"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"),
|
||||||
|
" pour qu'elle soit pré-remplie automatiquement.",
|
||||||
|
),
|
||||||
|
color_scheme="amber", variant="soft", size="1",
|
||||||
|
),
|
||||||
|
rx.fragment(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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%",
|
||||||
|
),
|
||||||
|
_profession_warning(),
|
||||||
|
# Profession (éditable)
|
||||||
|
rx.vstack(
|
||||||
|
rx.text("Profession", size="2", weight="medium", color="var(--gray-11)"),
|
||||||
|
rx.input(
|
||||||
|
value=RetenueState.sel_profession,
|
||||||
|
on_change=RetenueState.set_profession,
|
||||||
|
placeholder="ex. Automaticien CFC",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
spacing="1", width="100%",
|
||||||
|
),
|
||||||
|
# Dates
|
||||||
|
rx.flex(
|
||||||
|
rx.vstack(
|
||||||
|
rx.text("Date de retenue", size="2", weight="medium", color="var(--gray-11)"),
|
||||||
|
rx.input(
|
||||||
|
type="date",
|
||||||
|
value=RetenueState.retenue_date,
|
||||||
|
on_change=RetenueState.set_retenue_date,
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
spacing="1", flex="1", min_width="200px",
|
||||||
|
),
|
||||||
|
rx.vstack(
|
||||||
|
rx.text("Date du problème", size="2", weight="medium", color="var(--gray-11)"),
|
||||||
|
rx.input(
|
||||||
|
type="date",
|
||||||
|
value=RetenueState.probleme_date,
|
||||||
|
on_change=RetenueState.set_probleme_date,
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
spacing="1", flex="1", min_width="200px",
|
||||||
|
),
|
||||||
|
gap="0.75rem", flex_wrap="wrap", width="100%",
|
||||||
|
),
|
||||||
|
# Motif (radio)
|
||||||
|
rx.vstack(
|
||||||
|
rx.text("Motif de la retenue", size="2", weight="medium", color="var(--gray-11)"),
|
||||||
|
rx.radio_group.root(
|
||||||
|
rx.vstack(
|
||||||
|
rx.radio_group.item(
|
||||||
|
rx.text("N'a pas remis ses tâches scolaires dans les délais", size="2"),
|
||||||
|
value="devoir",
|
||||||
|
),
|
||||||
|
rx.radio_group.item(
|
||||||
|
rx.text("A manifesté un comportement répréhensible", size="2"),
|
||||||
|
value="comportement",
|
||||||
|
),
|
||||||
|
rx.radio_group.item(
|
||||||
|
rx.text("Est arrivé en retard aux cours", size="2"),
|
||||||
|
value="retard",
|
||||||
|
),
|
||||||
|
spacing="2",
|
||||||
|
),
|
||||||
|
value=RetenueState.case,
|
||||||
|
on_change=RetenueState.set_case,
|
||||||
|
),
|
||||||
|
spacing="2", width="100%",
|
||||||
|
),
|
||||||
|
# Branche (visible seulement si case devoir)
|
||||||
|
rx.cond(
|
||||||
|
RetenueState.case == "devoir",
|
||||||
|
rx.vstack(
|
||||||
|
rx.text("Branche", size="2", weight="medium", color="var(--gray-11)"),
|
||||||
|
_branche_selector(),
|
||||||
|
spacing="1", width="100%",
|
||||||
|
),
|
||||||
|
rx.fragment(),
|
||||||
|
),
|
||||||
|
# Remarque
|
||||||
|
rx.vstack(
|
||||||
|
rx.text("Remarque éventuelle de l'école", size="2", weight="medium", color="var(--gray-11)"),
|
||||||
|
rx.text_area(
|
||||||
|
value=RetenueState.remarque,
|
||||||
|
on_change=RetenueState.set_remarque,
|
||||||
|
rows="4",
|
||||||
|
width="100%",
|
||||||
|
resize="vertical",
|
||||||
|
),
|
||||||
|
spacing="1", width="100%",
|
||||||
|
),
|
||||||
|
# Erreur
|
||||||
|
rx.cond(
|
||||||
|
RetenueState.form_error != "",
|
||||||
|
rx.callout.root(
|
||||||
|
rx.callout.icon(rx.icon("triangle-alert", size=16)),
|
||||||
|
rx.callout.text(RetenueState.form_error),
|
||||||
|
color_scheme="red", variant="soft", size="1",
|
||||||
|
),
|
||||||
|
rx.fragment(),
|
||||||
|
),
|
||||||
|
# Option : créer une notice Escada
|
||||||
|
rx.flex(
|
||||||
|
rx.checkbox(
|
||||||
|
checked=RetenueState.add_notice,
|
||||||
|
on_change=RetenueState.set_add_notice,
|
||||||
|
size="2",
|
||||||
|
),
|
||||||
|
rx.text(
|
||||||
|
"Ajouter automatiquement une notice sur Escada",
|
||||||
|
size="2", color="var(--gray-12)",
|
||||||
|
),
|
||||||
|
gap="0.5rem", align="center",
|
||||||
|
padding="0.5rem 0.65rem",
|
||||||
|
background_color="#f8f9fa",
|
||||||
|
border="1px solid #e5e7eb",
|
||||||
|
border_radius="6px",
|
||||||
|
),
|
||||||
|
# Actions : 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,
|
||||||
|
),
|
||||||
|
spacing="4",
|
||||||
|
width="100%",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
RetenueState.sel_fiche_email_appr != "",
|
||||||
|
rx.text("Apprenti — ", RetenueState.sel_fiche_email_appr, size="2"),
|
||||||
|
rx.text("Apprenti (email inconnu)", size="2", color="var(--gray-9)"),
|
||||||
|
),
|
||||||
|
value="apprenti",
|
||||||
|
disabled=RetenueState.sel_fiche_email_appr == "",
|
||||||
|
),
|
||||||
|
rx.radio_group.item(
|
||||||
|
rx.cond(
|
||||||
|
RetenueState.sel_fiche_email_form != "",
|
||||||
|
rx.text("Formateur — ", RetenueState.sel_fiche_email_form, size="2"),
|
||||||
|
rx.text("Formateur (email inconnu)", size="2", color="var(--gray-9)"),
|
||||||
|
),
|
||||||
|
value="formateur",
|
||||||
|
disabled=RetenueState.sel_fiche_email_form == "",
|
||||||
|
),
|
||||||
|
rx.radio_group.item(
|
||||||
|
rx.text("Autre adresse", size="2"),
|
||||||
|
value="autre",
|
||||||
|
),
|
||||||
|
spacing="2",
|
||||||
|
),
|
||||||
|
value=RetenueState.email_dest,
|
||||||
|
on_change=RetenueState.set_email_dest,
|
||||||
|
),
|
||||||
|
rx.cond(
|
||||||
|
RetenueState.email_dest == "autre",
|
||||||
|
rx.input(
|
||||||
|
placeholder="email@domaine.ch",
|
||||||
|
value=RetenueState.email_custom,
|
||||||
|
on_change=RetenueState.set_email_custom,
|
||||||
|
type="email",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
rx.fragment(),
|
||||||
|
),
|
||||||
|
rx.button(
|
||||||
|
rx.icon("send", size=16),
|
||||||
|
"Envoyer l'avis par email",
|
||||||
|
on_click=RetenueState.send_email_action,
|
||||||
|
color_scheme="blue", size="2",
|
||||||
|
disabled=RetenueState.selected_id == 0,
|
||||||
|
),
|
||||||
|
spacing="3", width="100%",
|
||||||
|
),
|
||||||
|
padding="1.25rem",
|
||||||
|
background_color="white",
|
||||||
|
border_radius="8px",
|
||||||
|
border="1px solid #e0e0e0",
|
||||||
|
width="100%",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def retenue_page() -> rx.Component:
|
||||||
|
return layout(
|
||||||
|
rx.vstack(
|
||||||
|
rx.heading("Avis de retenue", size="6"),
|
||||||
|
rx.cond(
|
||||||
|
RetenueState.has_apprentis,
|
||||||
|
rx.vstack(
|
||||||
|
rx.box(
|
||||||
|
_form(),
|
||||||
|
padding="1.25rem",
|
||||||
|
background_color="white",
|
||||||
|
border_radius="8px",
|
||||||
|
border="1px solid #e0e0e0",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
_email_section(),
|
||||||
|
spacing="4", width="100%",
|
||||||
|
),
|
||||||
|
empty_state(
|
||||||
|
icon="users",
|
||||||
|
title="Aucun apprenti",
|
||||||
|
description="Importe les classes depuis Escadaweb pour générer des avis.",
|
||||||
|
action_label="Lancer un import",
|
||||||
|
action_href="/escada",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
spacing="4",
|
||||||
|
width="100%",
|
||||||
|
max_width="780px",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -24,6 +24,7 @@ _PAGES = [
|
||||||
("Tableau de bord", "/accueil", "layout-dashboard"),
|
("Tableau de bord", "/accueil", "layout-dashboard"),
|
||||||
("Apprentis", "/fiche", "user"),
|
("Apprentis", "/fiche", "user"),
|
||||||
("Classes", "/classe", "users"),
|
("Classes", "/classe", "users"),
|
||||||
|
("Avis de retenue", "/retenue", "file-warning"),
|
||||||
]
|
]
|
||||||
|
|
||||||
_ADMIN_PAGES = [
|
_ADMIN_PAGES = [
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
|
||||||
reflex==0.9.2
|
reflex==0.9.2
|
||||||
markdown==3.10.2
|
markdown==3.10.2
|
||||||
|
pikepdf==10.5.1
|
||||||
|
|
@ -52,6 +52,7 @@ except Exception:
|
||||||
|
|
||||||
SCRIPT_SYNC = _ROOT / "scripts" / "sync_esacada.py"
|
SCRIPT_SYNC = _ROOT / "scripts" / "sync_esacada.py"
|
||||||
SCRIPT_PUSH = _ROOT / "scripts" / "push_to_escada.py"
|
SCRIPT_PUSH = _ROOT / "scripts" / "push_to_escada.py"
|
||||||
|
SCRIPT_PUSH_NOTICES = _ROOT / "scripts" / "push_notices.py"
|
||||||
DATA_DIR = _ROOT / "data"
|
DATA_DIR = _ROOT / "data"
|
||||||
|
|
||||||
# Marqueur écrit par run_imports.py à la fin des imports en DB
|
# Marqueur écrit par run_imports.py à la fin des imports en DB
|
||||||
|
|
@ -327,6 +328,8 @@ def run_job(job: CronJob, sess) -> None:
|
||||||
("Push Escada", _build_push_cmd(job)),
|
("Push Escada", _build_push_cmd(job)),
|
||||||
("Sync Escada", _build_sync_cmd(job)),
|
("Sync Escada", _build_sync_cmd(job)),
|
||||||
]
|
]
|
||||||
|
elif job.task_kind == "push_notices":
|
||||||
|
steps = [("Push notices", [sys.executable, str(SCRIPT_PUSH_NOTICES)])]
|
||||||
else:
|
else:
|
||||||
fp.write(f"[error] task_kind inconnu : {job.task_kind}\n")
|
fp.write(f"[error] task_kind inconnu : {job.task_kind}\n")
|
||||||
overall_rc = 99
|
overall_rc = 99
|
||||||
|
|
|
||||||
251
scripts/push_notices.py
Executable file
251
scripts/push_notices.py
Executable file
|
|
@ -0,0 +1,251 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Push des notices en attente vers Escadaweb.
|
||||||
|
|
||||||
|
Workflow par notice :
|
||||||
|
Classes → Élèves (de la classe) → Notices (de l'apprenti) → Ajouter
|
||||||
|
→ Date / Titre / Remarques → Mettre à jour → retour Élèves
|
||||||
|
|
||||||
|
Réutilise les helpers de `sync_esacada.py` :
|
||||||
|
- `_launch_context()` : navigateur headless avec profil persistant
|
||||||
|
- `_ensure_logged_in(page)` : login SSO + 2FA + langue FR
|
||||||
|
- `_go_to_students_page(page, class_name)` : ouvre ViewLernende d'une classe
|
||||||
|
|
||||||
|
Sortie standard (parsée par `cron_tick.py` et la page /escada) :
|
||||||
|
PUSH_NOTICES_DONE {"ok": N, "err": [...], "remaining": N}
|
||||||
|
|
||||||
|
Behaviour DB :
|
||||||
|
- status='pending' → tentative
|
||||||
|
- succès → suppression de la Notice de la DB
|
||||||
|
- échec → status='failed' + error_msg
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
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 # noqa: E402
|
||||||
|
from playwright.sync_api import Page # noqa: E402
|
||||||
|
|
||||||
|
from src.db import get_session, Notice # 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_date(page: Page, date_str: str) -> None:
|
||||||
|
"""Remplit le champ Date du formulaire notice (DevExpress).
|
||||||
|
|
||||||
|
On vise l'input texte directement (`id$="_DXEditor1_I"`), plus stable que
|
||||||
|
le calendrier popup.
|
||||||
|
"""
|
||||||
|
date_input = page.locator("input[id$='_DXEditor1_I']").first
|
||||||
|
date_input.wait_for(state="visible", timeout=10_000)
|
||||||
|
date_input.click()
|
||||||
|
# Sélectionne tout l'ancien contenu (date pré-remplie d'aujourd'hui) puis tape
|
||||||
|
date_input.press("Control+A")
|
||||||
|
date_input.type(date_str)
|
||||||
|
date_input.press("Tab") # commit la valeur
|
||||||
|
|
||||||
|
|
||||||
|
def _push_one_notice(page: Page, notice: Notice, students_url: str) -> tuple[bool, str]:
|
||||||
|
"""Pousse une notice. Renvoie (ok, error_message).
|
||||||
|
|
||||||
|
Pré : `page` est sur la liste Élèves de la classe de l'apprenti.
|
||||||
|
Post (succès ou échec) : `page` est de retour sur la liste Élèves.
|
||||||
|
"""
|
||||||
|
ap = notice.apprenti
|
||||||
|
nom = ap.nom
|
||||||
|
prenom = ap.prenom
|
||||||
|
|
||||||
|
# 1. Trouver la ligne de l'apprenti et cliquer "Notices"
|
||||||
|
try:
|
||||||
|
# On filtre par nom ET prénom pour éviter les homonymes
|
||||||
|
student_row = page.locator("tr").filter(has_text=nom).filter(has_text=prenom).first
|
||||||
|
if not student_row.count():
|
||||||
|
return False, f"Apprenti '{nom} {prenom}' introuvable dans la grille"
|
||||||
|
student_row.get_by_role("link", name="Notices").first.click()
|
||||||
|
page.wait_for_load_state("networkidle", timeout=15_000)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Navigation Notices : {e}"
|
||||||
|
|
||||||
|
# 2. Cliquer "Ajouter"
|
||||||
|
try:
|
||||||
|
page.locator("a").filter(has_text="Ajouter").first.click()
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Bouton Ajouter introuvable : {e}"
|
||||||
|
|
||||||
|
# 3. Remplir Date / Titre / Remarques
|
||||||
|
try:
|
||||||
|
_fill_date(page, notice.date_event.strftime("%d.%m.%Y"))
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Remplissage date : {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.get_by_role("textbox", name="Titre:").fill(notice.titre)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Remplissage titre : {e}"
|
||||||
|
|
||||||
|
if notice.remarque:
|
||||||
|
try:
|
||||||
|
page.get_by_role("textbox", name="Remarques:").fill(notice.remarque)
|
||||||
|
except Exception:
|
||||||
|
pass # Non bloquant
|
||||||
|
|
||||||
|
# 4. Sauver
|
||||||
|
try:
|
||||||
|
page.get_by_role("link", name="Mettre à jour").click()
|
||||||
|
page.wait_for_load_state("networkidle", timeout=15_000)
|
||||||
|
page.wait_for_timeout(500) # laisse le temps à la grille de se rafraîchir
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Échec Mettre à jour : {e}"
|
||||||
|
|
||||||
|
# 5. Vérifier que la notice est bien dans la grille de l'apprenti
|
||||||
|
try:
|
||||||
|
# On cherche le titre dans la grille des notices (max 30 chars pour éviter
|
||||||
|
# les soucis de longueur / wrapping).
|
||||||
|
needle = (notice.titre or "").strip()[:30]
|
||||||
|
if needle:
|
||||||
|
cell = page.locator("td").filter(has_text=needle).first
|
||||||
|
cell.wait_for(state="visible", timeout=8_000)
|
||||||
|
except Exception:
|
||||||
|
# Vérification échouée — on retourne quand même à la liste Élèves
|
||||||
|
# avant de signaler l'échec.
|
||||||
|
try:
|
||||||
|
page.goto(students_url)
|
||||||
|
page.wait_for_load_state("networkidle", timeout=15_000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False, "Notice non retrouvée dans la grille après save (échec probable)"
|
||||||
|
|
||||||
|
# 6. Retour à la liste Élèves de la même classe (option a : navigation directe)
|
||||||
|
try:
|
||||||
|
page.goto(students_url)
|
||||||
|
page.wait_for_load_state("networkidle", timeout=15_000)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Retour grille élèves : {e}"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
sess = get_session()
|
||||||
|
ok_count = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
try:
|
||||||
|
notices = sess.execute(
|
||||||
|
select(Notice).where(Notice.status == "pending")
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
app_log(f"[push_notices] {len(notices)} notice(s) en attente")
|
||||||
|
|
||||||
|
if not notices:
|
||||||
|
print(
|
||||||
|
'PUSH_NOTICES_DONE '
|
||||||
|
+ json.dumps({"ok": 0, "err": [], "remaining": 0}),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Groupe par classe pour minimiser les navigations
|
||||||
|
by_class: dict[str, list[Notice]] = {}
|
||||||
|
for n in notices:
|
||||||
|
by_class.setdefault(n.apprenti.classe, []).append(n)
|
||||||
|
|
||||||
|
pw, ctx, page = _launch_context()
|
||||||
|
try:
|
||||||
|
# Navigation initiale vers ViewKlassen : redirige vers le login
|
||||||
|
# si la session est expirée, et permet à _ensure_logged_in
|
||||||
|
# de détecter le succès (ViewKlassen dans l'URL).
|
||||||
|
page.goto(CLASSES_URL)
|
||||||
|
_ensure_logged_in(page)
|
||||||
|
|
||||||
|
for classe, class_notices in by_class.items():
|
||||||
|
_log(f"[push_notices] classe={classe} ({len(class_notices)} notices)")
|
||||||
|
try:
|
||||||
|
students_page = _go_to_students_page(page, classe)
|
||||||
|
except Exception as e:
|
||||||
|
students_page = None
|
||||||
|
_log(f"[push_notices] erreur navigation {classe}: {e}")
|
||||||
|
if not students_page:
|
||||||
|
msg = f"classe '{classe}' introuvable sur Escada"
|
||||||
|
for n in class_notices:
|
||||||
|
n.status = "failed"
|
||||||
|
n.error_msg = msg
|
||||||
|
errors.append(
|
||||||
|
f"id={n.id} ({n.apprenti.nom} {n.apprenti.prenom}): {msg}"
|
||||||
|
)
|
||||||
|
sess.commit()
|
||||||
|
continue
|
||||||
|
|
||||||
|
students_url = students_page.url
|
||||||
|
|
||||||
|
for notice in class_notices:
|
||||||
|
label = f"{notice.apprenti.nom} {notice.apprenti.prenom}"
|
||||||
|
try:
|
||||||
|
ok, err = _push_one_notice(students_page, notice, students_url)
|
||||||
|
if ok:
|
||||||
|
sess.delete(notice)
|
||||||
|
sess.commit()
|
||||||
|
ok_count += 1
|
||||||
|
_log(f"[push_notices] OK id={notice.id} ({label})")
|
||||||
|
else:
|
||||||
|
notice.status = "failed"
|
||||||
|
notice.error_msg = err[:500]
|
||||||
|
sess.commit()
|
||||||
|
errors.append(f"id={notice.id} ({label}): {err}")
|
||||||
|
_log(f"[push_notices] FAIL id={notice.id}: {err}")
|
||||||
|
# Si on est paumé, tenter un retour propre
|
||||||
|
try:
|
||||||
|
students_page.goto(students_url)
|
||||||
|
students_page.wait_for_load_state(
|
||||||
|
"networkidle", timeout=10_000
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
break # impossible de recover, on passe à la classe suivante
|
||||||
|
except Exception as e:
|
||||||
|
notice.status = "failed"
|
||||||
|
notice.error_msg = str(e)[:500]
|
||||||
|
sess.commit()
|
||||||
|
errors.append(f"id={notice.id} ({label}): {e}")
|
||||||
|
_log(f"[push_notices] EX id={notice.id}: {e}\n{traceback.format_exc()}")
|
||||||
|
finally:
|
||||||
|
try: ctx.close()
|
||||||
|
except Exception: pass
|
||||||
|
try: pw.stop()
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Compte les notices encore pending (n'incluant pas les "failed")
|
||||||
|
try:
|
||||||
|
remaining = sess.execute(
|
||||||
|
select(Notice).where(Notice.status == "pending")
|
||||||
|
).all()
|
||||||
|
remaining_count = len(remaining)
|
||||||
|
except Exception:
|
||||||
|
remaining_count = 0
|
||||||
|
sess.close()
|
||||||
|
|
||||||
|
print(
|
||||||
|
'PUSH_NOTICES_DONE '
|
||||||
|
+ json.dumps({
|
||||||
|
"ok": ok_count,
|
||||||
|
"err": errors,
|
||||||
|
"remaining": remaining_count,
|
||||||
|
}, ensure_ascii=False),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
43
src/db.py
43
src/db.py
|
|
@ -189,6 +189,9 @@ class ApprentiFiche(Base):
|
||||||
formateur_nom: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
formateur_nom: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
formateur_email: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
formateur_email: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
|
||||||
|
# Profession dérivée du préfixe de classe (mapping dans data/settings.json)
|
||||||
|
profession: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(default=datetime.now, onupdate=datetime.now)
|
updated_at: Mapped[datetime] = mapped_column(default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
apprenti: Mapped["Apprenti"] = relationship(back_populates="fiche")
|
apprenti: Mapped["Apprenti"] = relationship(back_populates="fiche")
|
||||||
|
|
@ -206,6 +209,30 @@ class NotesExamen(Base):
|
||||||
apprenti: Mapped["Apprenti"] = relationship(back_populates="notes_examen")
|
apprenti: Mapped["Apprenti"] = relationship(back_populates="notes_examen")
|
||||||
|
|
||||||
|
|
||||||
|
class Notice(Base):
|
||||||
|
"""Note à pousser sur Escada (liée à un apprenti).
|
||||||
|
|
||||||
|
Créée notamment lors de la génération d'un avis de retenue (si la case
|
||||||
|
correspondante est cochée). Supprimée après push réussi.
|
||||||
|
"""
|
||||||
|
__tablename__ = "notices"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
apprenti_id: Mapped[int] = mapped_column(ForeignKey("apprentis.id"))
|
||||||
|
date_event: Mapped[date]
|
||||||
|
titre: Mapped[str] = mapped_column(Text)
|
||||||
|
remarque: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
type_notice: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
matiere: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
source: Mapped[str] = mapped_column(default="manual") # "retenue" pour le moment
|
||||||
|
status: Mapped[str] = mapped_column(default="pending") # "pending" | "failed"
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=datetime.now)
|
||||||
|
created_by: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
error_msg: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
apprenti: Mapped["Apprenti"] = relationship()
|
||||||
|
|
||||||
|
|
||||||
class SanctionExport(Base):
|
class SanctionExport(Base):
|
||||||
__tablename__ = "sanctions_export"
|
__tablename__ = "sanctions_export"
|
||||||
|
|
||||||
|
|
@ -286,6 +313,21 @@ def init_db(engine=None):
|
||||||
for stmt in (
|
for stmt in (
|
||||||
"ALTER TABLE sanctions_export ADD COLUMN nb_absences INTEGER",
|
"ALTER TABLE sanctions_export ADD COLUMN nb_absences INTEGER",
|
||||||
"ALTER TABLE cron_jobs ADD COLUMN notify_level TEXT DEFAULT 'normal'",
|
"ALTER TABLE cron_jobs ADD COLUMN notify_level TEXT DEFAULT 'normal'",
|
||||||
|
"ALTER TABLE apprenti_fiches ADD COLUMN profession TEXT",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS notices (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
apprenti_id INTEGER NOT NULL REFERENCES apprentis(id),
|
||||||
|
date_event DATE NOT NULL,
|
||||||
|
titre TEXT NOT NULL,
|
||||||
|
remarque TEXT,
|
||||||
|
type_notice TEXT,
|
||||||
|
matiere TEXT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
created_by TEXT,
|
||||||
|
error_msg TEXT
|
||||||
|
)""",
|
||||||
"""CREATE TABLE IF NOT EXISTS escada_pending (
|
"""CREATE TABLE IF NOT EXISTS escada_pending (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
apprenti_id INTEGER NOT NULL REFERENCES apprentis(id),
|
apprenti_id INTEGER NOT NULL REFERENCES apprentis(id),
|
||||||
|
|
@ -315,6 +357,7 @@ def upsert_apprenti_fiche(session: Session, apprenti_id: int, data: dict) -> Non
|
||||||
"entreprise_nom", "entreprise_adresse", "entreprise_code_postal",
|
"entreprise_nom", "entreprise_adresse", "entreprise_code_postal",
|
||||||
"entreprise_localite", "entreprise_telephone", "entreprise_email",
|
"entreprise_localite", "entreprise_telephone", "entreprise_email",
|
||||||
"formateur_nom", "formateur_email",
|
"formateur_nom", "formateur_email",
|
||||||
|
"profession",
|
||||||
]
|
]
|
||||||
if existing:
|
if existing:
|
||||||
for f in fields:
|
for f in fields:
|
||||||
|
|
|
||||||
113
src/profession.py
Normal file
113
src/profession.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""Helper pour la résolution `classe → profession` via mapping configurable.
|
||||||
|
|
||||||
|
Mapping stocké dans `data/settings.json` sous la clé `class_profession_mapping`,
|
||||||
|
forme : `[{"prefix": "AUTOMAT", "profession": "Automaticien CFC"}, ...]`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.db import Apprenti, ApprentiFiche, upsert_apprenti_fiche
|
||||||
|
|
||||||
|
_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
_DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
|
||||||
|
_SETTINGS_PATH = _DATA_DIR / "settings.json"
|
||||||
|
|
||||||
|
_DEFAULT_MAPPING = [
|
||||||
|
{"prefix": "AUTOMAT", "profession": "Automaticien CFC"},
|
||||||
|
{"prefix": "MONTAUT", "profession": "Monteur Automaticien CFC"},
|
||||||
|
{"prefix": "EM-AU", "profession": "Automaticien CFC"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_settings() -> dict:
|
||||||
|
if _SETTINGS_PATH.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(_SETTINGS_PATH.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_settings(s: dict) -> None:
|
||||||
|
_SETTINGS_PATH.write_text(json.dumps(s, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def load_mapping() -> list[dict]:
|
||||||
|
"""Renvoie la liste des correspondances [{prefix, profession}, ...]."""
|
||||||
|
s = _load_settings()
|
||||||
|
return list(s.get("class_profession_mapping", _DEFAULT_MAPPING))
|
||||||
|
|
||||||
|
|
||||||
|
def save_mapping(mapping: list[dict]) -> None:
|
||||||
|
"""Sauve le mapping (filtre les entrées vides)."""
|
||||||
|
cleaned = [
|
||||||
|
{"prefix": (m.get("prefix") or "").strip(), "profession": (m.get("profession") or "").strip()}
|
||||||
|
for m in mapping
|
||||||
|
]
|
||||||
|
cleaned = [m for m in cleaned if m["prefix"] and m["profession"]]
|
||||||
|
s = _load_settings()
|
||||||
|
s["class_profession_mapping"] = cleaned
|
||||||
|
_save_settings(s)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_profession(classe: str, mapping: Optional[list[dict]] = None) -> str:
|
||||||
|
"""Renvoie la profession matchant le préfixe de la classe, ou '' si aucun."""
|
||||||
|
if not classe:
|
||||||
|
return ""
|
||||||
|
if mapping is None:
|
||||||
|
mapping = load_mapping()
|
||||||
|
# On préfère le préfixe le plus long en cas de chevauchement
|
||||||
|
for entry in sorted(mapping, key=lambda m: -len(m.get("prefix", ""))):
|
||||||
|
prefix = entry.get("prefix", "")
|
||||||
|
if prefix and classe.startswith(prefix):
|
||||||
|
return entry.get("profession", "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def find_unmapped_classes(session: Session) -> list[str]:
|
||||||
|
"""Liste les classes en DB sans correspondance dans le mapping.
|
||||||
|
|
||||||
|
Exclut MP/MI (déjà filtrées partout dans l'app).
|
||||||
|
"""
|
||||||
|
mapping = load_mapping()
|
||||||
|
classes = session.execute(
|
||||||
|
select(Apprenti.classe).distinct().order_by(Apprenti.classe)
|
||||||
|
).scalars().all()
|
||||||
|
out = []
|
||||||
|
for c in classes:
|
||||||
|
if not c or c.startswith(("MP", "MI")):
|
||||||
|
continue
|
||||||
|
if not resolve_profession(c, mapping):
|
||||||
|
out.append(c)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_all_professions(session: Session) -> int:
|
||||||
|
"""Recalcule `profession` pour tous les apprentis en base.
|
||||||
|
|
||||||
|
Renvoie le nombre de fiches mises à jour. Utile :
|
||||||
|
- une fois à l'init après ajout du champ
|
||||||
|
- après modification du mapping dans Paramètres
|
||||||
|
- après une sync Escada
|
||||||
|
"""
|
||||||
|
mapping = load_mapping()
|
||||||
|
apprentis = session.execute(select(Apprenti)).scalars().all()
|
||||||
|
n = 0
|
||||||
|
for ap in apprentis:
|
||||||
|
prof = resolve_profession(ap.classe, mapping)
|
||||||
|
if not prof:
|
||||||
|
# Pas de mapping → on laisse la valeur existante si présente
|
||||||
|
continue
|
||||||
|
# upsert : crée la fiche si elle n'existe pas, sinon met à jour profession
|
||||||
|
upsert_apprenti_fiche(session, ap.id, {"profession": prof})
|
||||||
|
n += 1
|
||||||
|
session.commit()
|
||||||
|
return n
|
||||||
231
src/retenue_pdf.py
Normal file
231
src/retenue_pdf.py
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
"""Génération d'avis de retenue à partir du template AcroForm.
|
||||||
|
|
||||||
|
Template : `data/templates/GF_FO_Avis_de_retenue.pdf`. Le champ `Date` du
|
||||||
|
template a 3 widgets-enfants partagés (un par ligne du formulaire). On les
|
||||||
|
sépare en 3 champs distincts (`Date_devoir`, `Date_comportement`, `Date_retard`)
|
||||||
|
puis on remplit uniquement celui correspondant à la case cochée.
|
||||||
|
|
||||||
|
Le PDF généré reste éditable (formulaire préservé).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
from datetime import date as _date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pypdf
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.db import Apprenti, ApprentiFiche
|
||||||
|
|
||||||
|
_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
_DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
|
||||||
|
_TEMPLATE_PATH = _DATA_DIR / "templates" / "GF_FO_Avis_de_retenue.pdf"
|
||||||
|
|
||||||
|
_MOIS_FR = [
|
||||||
|
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||||
|
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mapping case → suffixe + index (ordre des widgets enfants triés par Y desc)
|
||||||
|
_CASE_TO_SUFFIX = {
|
||||||
|
"devoir": ("Date_devoir", 0),
|
||||||
|
"comportement": ("Date_comportement", 1),
|
||||||
|
"retard": ("Date_retard", 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_date_long(d: _date) -> str:
|
||||||
|
"""Formate une date en 'jour mois année' (ex: '12 mars 2026')."""
|
||||||
|
return f"{d.day} {_MOIS_FR[d.month - 1]} {d.year}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_retenue_pdf(
|
||||||
|
sess: Session,
|
||||||
|
apprenti_id: int,
|
||||||
|
*,
|
||||||
|
profession: str,
|
||||||
|
retenue_date: _date,
|
||||||
|
probleme_date: _date,
|
||||||
|
case: str, # "devoir" | "comportement" | "retard"
|
||||||
|
branche: str = "",
|
||||||
|
remarque: str = "",
|
||||||
|
prof_name: str = "",
|
||||||
|
) -> Optional[bytes]:
|
||||||
|
"""Pré-remplit le template puis aplatit le PDF. Renvoie les bytes du PDF aplati."""
|
||||||
|
if not _TEMPLATE_PATH.exists():
|
||||||
|
return None
|
||||||
|
apprenti = sess.get(Apprenti, apprenti_id)
|
||||||
|
if apprenti is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
fiche: Optional[ApprentiFiche] = apprenti.fiche
|
||||||
|
|
||||||
|
classe_full = (
|
||||||
|
f"{profession.strip()} {apprenti.classe}".strip()
|
||||||
|
if profession else apprenti.classe
|
||||||
|
)
|
||||||
|
npa_ville = ""
|
||||||
|
if fiche:
|
||||||
|
cp = (fiche.entreprise_code_postal or "").strip()
|
||||||
|
loc = (fiche.entreprise_localite or "").strip()
|
||||||
|
npa_ville = f"{cp} {loc}".strip()
|
||||||
|
|
||||||
|
# 1. Lecture template + clone
|
||||||
|
reader = pypdf.PdfReader(str(_TEMPLATE_PATH))
|
||||||
|
writer = pypdf.PdfWriter(clone_from=reader)
|
||||||
|
|
||||||
|
# 2. Séparer les 3 widgets du champ Date en 3 champs distincts.
|
||||||
|
# Après cette opération, on peut remplir chaque Date_xxx individuellement.
|
||||||
|
_split_date_field(writer)
|
||||||
|
|
||||||
|
# 3. Remplit les champs texte (Date_xxx inclus pour la case sélectionnée)
|
||||||
|
target_date_field = _CASE_TO_SUFFIX.get(case, (None, None))[0]
|
||||||
|
field_values: dict[str, str] = {
|
||||||
|
"NomApprenti": f"{apprenti.prenom} {apprenti.nom}".strip(),
|
||||||
|
"Classe": classe_full,
|
||||||
|
"NomEntreprise": (fiche.entreprise_nom if fiche else "") or "",
|
||||||
|
"Adresse": (fiche.entreprise_adresse if fiche else "") or "",
|
||||||
|
"NPA-Ville": npa_ville,
|
||||||
|
"RetenueDateHeure": retenue_date.strftime("%d.%m.%Y"),
|
||||||
|
"Branche": branche if case == "devoir" else "",
|
||||||
|
"Remarque": remarque,
|
||||||
|
"DateAvis": format_date_long(_date.today()),
|
||||||
|
"Profs": prof_name or "",
|
||||||
|
}
|
||||||
|
if target_date_field:
|
||||||
|
field_values[target_date_field] = probleme_date.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
|
for page in writer.pages:
|
||||||
|
try:
|
||||||
|
writer.update_page_form_field_values(
|
||||||
|
page, field_values, auto_regenerate=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4. Checkboxes
|
||||||
|
case_to_field = {
|
||||||
|
"devoir": "CaseDevoir",
|
||||||
|
"comportement": "CaseComportement",
|
||||||
|
"retard": "CaseRetard",
|
||||||
|
}
|
||||||
|
target_check = case_to_field.get(case)
|
||||||
|
for fname in case_to_field.values():
|
||||||
|
try:
|
||||||
|
_set_checkbox(writer, fname, fname == target_check)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 5. Force NeedAppearances pour que les viewers redessinent les valeurs
|
||||||
|
try:
|
||||||
|
root = writer._root_object
|
||||||
|
if "/AcroForm" in root:
|
||||||
|
root["/AcroForm"].update({
|
||||||
|
pypdf.generic.NameObject("/NeedAppearances"):
|
||||||
|
pypdf.generic.BooleanObject(True)
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 6. Écriture (formulaire préservé éditable)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
writer.write(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _split_date_field(writer: pypdf.PdfWriter) -> None:
|
||||||
|
"""Sépare le champ `Date` (avec 3 widgets enfants) en 3 champs indépendants.
|
||||||
|
|
||||||
|
Renomme les widgets selon leur position Y (ordre du haut vers le bas) :
|
||||||
|
kid #0 (haut) → Date_devoir
|
||||||
|
kid #1 (milieu) → Date_comportement
|
||||||
|
kid #2 (bas) → Date_retard
|
||||||
|
"""
|
||||||
|
NameObject = pypdf.generic.NameObject
|
||||||
|
acroform_ref = writer._root_object.get("/AcroForm")
|
||||||
|
if not acroform_ref:
|
||||||
|
return
|
||||||
|
acroform = acroform_ref.get_object() if hasattr(acroform_ref, "get_object") else acroform_ref
|
||||||
|
fields = acroform.get("/Fields") or []
|
||||||
|
date_field = None
|
||||||
|
date_ref = None
|
||||||
|
for f in fields:
|
||||||
|
if f.get_object().get("/T") == "Date":
|
||||||
|
date_field = f.get_object()
|
||||||
|
date_ref = f
|
||||||
|
break
|
||||||
|
if date_field is None:
|
||||||
|
return
|
||||||
|
kids = date_field.get("/Kids") or []
|
||||||
|
if not kids:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Trier les enfants par Y descendant
|
||||||
|
indexed = []
|
||||||
|
for kid in kids:
|
||||||
|
ko = kid.get_object()
|
||||||
|
rect = ko.get("/Rect")
|
||||||
|
y = float(rect[1]) if rect else 0.0
|
||||||
|
indexed.append((y, kid, ko))
|
||||||
|
indexed.sort(key=lambda t: -t[0])
|
||||||
|
|
||||||
|
# Promouvoir chaque enfant en champ indépendant
|
||||||
|
new_fields = []
|
||||||
|
suffixes_by_order = ["Date_devoir", "Date_comportement", "Date_retard"]
|
||||||
|
for i, (_y, kid_ref, kid_obj) in enumerate(indexed):
|
||||||
|
# Renomme : donne un /T propre à l'ancien widget enfant
|
||||||
|
kid_obj[NameObject("/T")] = pypdf.generic.create_string_object(
|
||||||
|
suffixes_by_order[i]
|
||||||
|
)
|
||||||
|
# Hériter du /FT, /DA, /Q du parent si manquant sur l'enfant
|
||||||
|
for prop in ("/FT", "/DA", "/Q"):
|
||||||
|
if prop not in kid_obj and prop in date_field:
|
||||||
|
kid_obj[NameObject(prop)] = date_field[prop]
|
||||||
|
# Détacher du parent
|
||||||
|
if "/Parent" in kid_obj:
|
||||||
|
del kid_obj[NameObject("/Parent")]
|
||||||
|
new_fields.append(kid_ref)
|
||||||
|
|
||||||
|
# Retirer l'ancien champ Date de /Fields, ajouter les 3 nouveaux
|
||||||
|
new_field_list = [f for f in fields if f is not date_ref] + new_fields
|
||||||
|
acroform[NameObject("/Fields")] = pypdf.generic.ArrayObject(new_field_list)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_field(writer: pypdf.PdfWriter, name: str):
|
||||||
|
acroform = writer._root_object.get("/AcroForm")
|
||||||
|
if not acroform:
|
||||||
|
return None
|
||||||
|
for f in acroform.get("/Fields") or []:
|
||||||
|
obj = f.get_object()
|
||||||
|
if obj.get("/T") == name:
|
||||||
|
return obj
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _set_checkbox(writer: pypdf.PdfWriter, field_name: str, checked: bool) -> None:
|
||||||
|
"""Coche/décoche une checkbox AcroForm, gère les widgets enfants sans /T."""
|
||||||
|
NameObject = pypdf.generic.NameObject
|
||||||
|
field = _find_field(writer, field_name)
|
||||||
|
if field is None:
|
||||||
|
return
|
||||||
|
kids = field.get("/Kids")
|
||||||
|
widgets = [k.get_object() for k in kids] if kids else [field]
|
||||||
|
on_value = "/Yes"
|
||||||
|
for widget in widgets:
|
||||||
|
ap = widget.get("/AP") or field.get("/AP")
|
||||||
|
if ap is not None:
|
||||||
|
n_ap = ap.get("/N") if hasattr(ap, "get") else None
|
||||||
|
if n_ap is not None:
|
||||||
|
for k in n_ap.keys():
|
||||||
|
ks = str(k)
|
||||||
|
if ks not in ("/Off", "Off"):
|
||||||
|
on_value = ks if ks.startswith("/") else f"/{ks}"
|
||||||
|
break
|
||||||
|
new_val = NameObject(on_value if checked else "/Off")
|
||||||
|
widget[NameObject("/AS")] = new_val
|
||||||
|
field[NameObject("/V")] = NameObject(on_value if checked else "/Off")
|
||||||
Loading…
Add table
Reference in a new issue