- 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>
231 lines
7.9 KiB
Python
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")
|