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>
293 lines
12 KiB
Python
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,
|
|
)
|