eptm_dashboard/src/retenue_pdf.py
Julien Balet 6d1b7c8044 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>
2026-05-11 11:24:15 +02:00

231 lines
7.9 KiB
Python

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