eptm_dashboard/src/importer.py
Julien Balet 7d3b6e9136 v1.1.0 — fixes sync + UX dev/prod
Sync push_then_sync : préserve les absences 'publiee_escada' contre
écrasement/orphelines après push (PDF Escada stale). UI reconnaît le
statut (calendrier, éditeur, KPIs) au lieu d'afficher 'présent'.

Sync_esacada : timeout grille 20s → 45s + retry après reload (AUTOMAT 1
échouait à la 1re classe après changement de langue).

Telegram : ajoute liste d'erreurs + tail du log dans les notifs d'échec
même en mode normal — avant on avait juste 'a échoué (code 1)'.

UX :
- Calendrier toujours visible (même sans absences) et démarre sur le
  mois courant (pas sur le 1er mois d'absence) ; tous les jours
  cliquables pour pouvoir ajouter une absence.
- Date du jour pré-sélectionnée aussi via navigate_to (clic depuis
  /classe).
- KPIs cards taggées kpi-card/kpi-value pour CSS responsive mobile.
- Badge 'DEV' dans la sidebar (APP_ENV=dev) — invisible en prod.
- Badge 'Built with Reflex' masqué.
- KPIs retirés du dashboard /accueil.

Prod :
- Dockerfile.prod multi-stage (Reflex export bundle + runtime slim).
- docker-compose.prod.yml séparé (port 3002, projet eptm-dashboard-prod).
- .gitignore + .dockerignore nettoyés.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:11:39 +02:00

293 lines
12 KiB
Python

"""Logique d'import PDF → base de données avec déduplication."""
from dataclasses import dataclass, field
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from src.db import (
Absence, Apprenti, EscadaPending, Import,
find_or_create_apprenti, upsert_escada_pending,
)
from src.parser import parse_pdf
@dataclass
class ImportResult:
import_id: int
classe: str
semestre: str
fichier: str
nb_apprentis: int
nb_absences_nouvelles: int
nb_absences_doublons: int
nb_absences_mises_a_jour: int = 0
nb_absences_supprimees: int = 0 # orphelines : abs en DB plus présentes dans le PDF Escada
nb_pendings_orphelins_supprimes: int = 0 # pendings supprimés en cascade avec orphelines
nb_absences_pending_skipped: int = 0 # abs non modifiées car pending modif locale
details_nouvelles: list[str] = field(default_factory=list)
details_mises_a_jour: list[str] = field(default_factory=list)
details_orphelines: list[str] = field(default_factory=list)
def import_pdf(
pdf_path: str | Path,
session: Session,
imported_by: str = "system",
force: bool = False,
) -> ImportResult:
"""Parse un PDF et insère les absences dans la DB.
Déduplication : (apprenti_id, date, periode) déjà présent → ignoré.
Si force=True, réinitialise le statut de toutes les absences existantes
(même type inchangé) et efface les EscadaPending associés.
"""
pdf_path = Path(pdf_path)
data = parse_pdf(pdf_path)
classe = data["classe"]
semestre = data["semestre"]
apprentis_data = data["apprentis"]
# Garde-fou : on n'importe JAMAIS d'absences pour les classes MP/MI.
# Les MP servent uniquement au matching Matu (via NotesMatu, lié à des
# apprentis déjà présents dans une classe régulière). Les MI sont
# totalement ignorées.
if classe.startswith(("MP", "MI")):
return ImportResult(
import_id=0,
classe=classe, semestre=semestre, fichier=pdf_path.name,
nb_apprentis=0, nb_absences_nouvelles=0, nb_absences_doublons=0,
)
nb_nouvelles = 0
nb_doublons = 0
nb_mises_a_jour = 0
nb_supprimees = 0
nb_pending_skipped = 0
# Détails par apprenti : {apprenti_id: {"nom": str, "prenom": str, "dates": [str]}}
_nouv_by_ap: dict[int, dict] = {}
_upd_by_ap: dict[int, dict] = {}
# Crée l'enregistrement d'import en premier pour avoir son id
import_rec = Import(
fichier=pdf_path.name,
classe=classe,
semestre=semestre,
nb_apprentis=len(apprentis_data),
imported_by=imported_by,
)
session.add(import_rec)
session.flush() # génère import_rec.id
# Clés (apprenti_id, date, periode) vues dans le nouveau PDF
seen_keys: set[tuple] = set()
for a_data in apprentis_data:
try:
apprenti = find_or_create_apprenti(
session, a_data["nom"], a_data["prenom"], a_data["classe"]
)
except ValueError:
# Apprenti rejeté par le garde-fou (classe vide / MP / MI) :
# on saute cette page du PDF sans interrompre tout l'import.
continue
for ab in a_data["absences"]:
key = (apprenti.id, ab["date"], ab["periode"])
seen_keys.add(key)
existe = session.execute(
select(Absence).where(
Absence.apprenti_id == apprenti.id,
Absence.date == ab["date"],
Absence.periode == ab["periode"],
)
).scalar_one_or_none()
ep_pending = session.execute(
select(EscadaPending).where(
EscadaPending.apprenti_id == apprenti.id,
EscadaPending.date == ab["date"],
EscadaPending.periode == ab["periode"],
)
).scalar_one_or_none()
if existe is not None:
if force:
existe.type_origine = ab["type_absence"]
existe.statut = "excusee" if ab["type_absence"] == "E" else "a_traiter"
existe.updated_by = None
if ep_pending:
session.delete(ep_pending)
nb_mises_a_jour += 1
entry = _upd_by_ap.setdefault(apprenti.id, {
"nom": apprenti.nom, "prenom": apprenti.prenom, "dates": []
})
try:
entry["dates"].append(f"{ab['date'].strftime('%d.%m')} {ab['periode']}")
except Exception:
entry["dates"].append(str(ab["date"]))
elif ep_pending:
# Modification en attente de sync vers Escada → ne pas écraser
nb_doublons += 1
nb_pending_skipped += 1
elif existe.statut == "publiee_escada":
# Vient d'être poussée vers Escada. Si le PDF confirme la
# valeur (type identique), on transitionne vers le statut
# "stable" pour que la prochaine modif locale crée bien un
# pending. Si le PDF ne confirme pas encore (stale), on
# préserve le marqueur en attendant un PDF rafraîchi.
if existe.type_origine == ab["type_absence"]:
existe.statut = (
"excusee" if ab["type_absence"] == "E" else "a_traiter"
)
nb_doublons += 1
else:
nb_doublons += 1
nb_pending_skipped += 1
elif existe.type_origine != ab["type_absence"]:
existe.type_origine = ab["type_absence"]
existe.statut = "excusee" if ab["type_absence"] == "E" else "a_traiter"
nb_mises_a_jour += 1
entry = _upd_by_ap.setdefault(apprenti.id, {
"nom": apprenti.nom, "prenom": apprenti.prenom, "dates": []
})
try:
entry["dates"].append(f"{ab['date'].strftime('%d.%m')} {ab['periode']}")
except Exception:
entry["dates"].append(str(ab["date"]))
else:
nb_doublons += 1
else:
# Cas: l'absence est dans le PDF Escada mais pas en DB.
# Si un pending existe (typiquement action="clear" = suppression
# locale en attente de push), respecter le pending sauf en force.
if ep_pending and not force:
nb_pending_skipped += 1
continue
if ep_pending and force:
# Force : on écrase le pending et on (ré)insère l'absence
session.delete(ep_pending)
statut_initial = "excusee" if ab["type_absence"] == "E" else "a_traiter"
session.add(
Absence(
apprenti_id=apprenti.id,
date=ab["date"],
periode=ab["periode"],
type_origine=ab["type_absence"],
statut=statut_initial,
import_id=import_rec.id,
)
)
nb_nouvelles += 1
entry = _nouv_by_ap.setdefault(apprenti.id, {
"nom": apprenti.nom, "prenom": apprenti.prenom, "dates": []
})
try:
entry["dates"].append(f"{ab['date'].strftime('%d.%m')} {ab['periode']}")
except Exception:
entry["dates"].append(str(ab["date"]))
# ── Diff : absences supprimées dans Escada (orphelines) ──────────────────
# On veut toutes les abs des apprentis de cette classe dans la fenêtre de
# dates couverte par le PDF — y compris les abs créées localement
# (import_id=None) qui ne joignent pas Import.
pdf_dates: set = set()
for a_data in apprentis_data:
for ab in a_data["absences"]:
pdf_dates.add(ab["date"])
if pdf_dates:
date_min = min(pdf_dates)
date_max = max(pdf_dates)
prev_absences = session.execute(
select(Absence)
.join(Apprenti, Apprenti.id == Absence.apprenti_id)
.where(
Apprenti.classe == classe,
Absence.date >= date_min,
Absence.date <= date_max,
)
).scalars().all()
else:
prev_absences = []
details_orphelines: list[str] = []
nb_pendings_orphelins = 0
# Index des apprentis pour récupérer leur nom/prénom sans re-query par orphelin
apprenti_by_id: dict[int, Apprenti] = {}
for ab in prev_absences:
if (ab.apprenti_id, ab.date, ab.periode) not in seen_keys:
ep = session.execute(
select(EscadaPending).where(
EscadaPending.apprenti_id == ab.apprenti_id,
EscadaPending.date == ab.date,
EscadaPending.periode == ab.periode,
)
).scalar_one_or_none()
# Orphelin avec pending : préserve l'intent local (sauf force)
# — typique : utilisateur a ajouté localement (action="E") et n'a pas
# encore pushé vers Escada. Supprimer écraserait son travail.
if ep and not force:
nb_pending_skipped += 1
continue
# Orphelin déjà poussé sur Escada (statut="publiee_escada") :
# Escada peut servir un PDF stale ne reflétant pas encore notre
# push. Préserver l'absence pour éviter de la supprimer juste
# après l'avoir poussée. Un sync ultérieur avec PDF rafraîchi
# ramènera l'absence à "excusee"/"a_traiter".
if ab.statut == "publiee_escada" and not force:
nb_pending_skipped += 1
continue
if ep:
session.delete(ep)
nb_pendings_orphelins += 1
ap = apprenti_by_id.get(ab.apprenti_id)
if ap is None:
ap = session.get(Apprenti, ab.apprenti_id)
if ap is not None:
apprenti_by_id[ab.apprenti_id] = ap
try:
date_str = ab.date.strftime("%d.%m.%Y")
except Exception:
date_str = str(ab.date)
nom = f"{ap.prenom} {ap.nom}" if ap else f"apprenti#{ab.apprenti_id}"
details_orphelines.append(f"{nom}{date_str} P{ab.periode}")
session.delete(ab)
nb_supprimees += 1
import_rec.nb_absences_nouvelles = nb_nouvelles
import_rec.nb_absences_doublons = nb_doublons
session.commit()
def _fmt(d: dict) -> str:
dates = d["dates"]
sample = ", ".join(dates[:5])
extra = f" +{len(dates) - 5}" if len(dates) > 5 else ""
return f"{d['prenom']} {d['nom']}{len(dates)} abs. ({sample}{extra})"
return ImportResult(
import_id=import_rec.id,
classe=classe,
semestre=semestre,
fichier=pdf_path.name,
nb_apprentis=len(apprentis_data),
nb_absences_nouvelles=nb_nouvelles,
nb_absences_doublons=nb_doublons,
nb_absences_mises_a_jour=nb_mises_a_jour,
nb_absences_supprimees=nb_supprimees,
nb_pendings_orphelins_supprimes=nb_pendings_orphelins,
nb_absences_pending_skipped=nb_pending_skipped,
details_nouvelles=[_fmt(d) for d in _nouv_by_ap.values()],
details_mises_a_jour=[_fmt(d) for d in _upd_by_ap.values()],
details_orphelines=details_orphelines,
)