diff --git a/Dockerfile.dev b/Dockerfile.dev
index 26d2142..4e6dc85 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -2,15 +2,13 @@ FROM python:3.13
WORKDIR /app
-RUN apt-get update && apt-get install -y curl gnupg unzip && \
- curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
- apt-get install -y nodejs && \
- rm -rf /var/lib/apt/lists/*
+RUN apt-get update && apt-get install -y curl gnupg unzip xvfb && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
-RUN pip install --no-cache-dir pdfplumber sqlalchemy plotly pandas \
- openpyxl bcrypt pyyaml pypdf pyotp "qrcode[pil]" reportlab playwright
+RUN pip install --no-cache-dir pdfplumber sqlalchemy plotly pandas openpyxl bcrypt pyyaml pypdf pyotp "qrcode[pil]" reportlab playwright
+
+RUN playwright install --with-deps chromium
COPY . .
diff --git a/assets/responsive.css b/assets/responsive.css
index 4c9cfb2..83e7d29 100644
--- a/assets/responsive.css
+++ b/assets/responsive.css
@@ -23,6 +23,14 @@ body, html {
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
+ overflow-wrap: break-word;
+}
+
+/* Allow flex/grid descendants to shrink below their content size,
+ * preventing horizontal overflow from long text or wide tables.
+ * Inline `min-width` styles still win (higher specificity). */
+.content-area * {
+ min-width: 0;
}
/* Mobile: hide desktop sidebar, account for fixed topbar (56px) */
diff --git a/data/class_href_cache.json b/data/class_href_cache.json
index d250f22..1b220c3 100644
--- a/data/class_href_cache.json
+++ b/data/class_href_cache.json
@@ -1,3 +1,5 @@
{
- "AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=787492b5-b07a-4b80-8717-34ce6dda04fe"
+ "AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=84a4e84c-3566-42da-a8c9-3c00687182ff",
+ "AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f90a41d2-e507-4687-890a-48c454da583c",
+ "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=38bbf90d-51da-406e-a2af-4d5f8f5958bd"
}
\ No newline at end of file
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 22a1acd..5b379bc 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -3,19 +3,30 @@ services:
build:
context: .
dockerfile: Dockerfile.dev
+ init: true
restart: "no"
- ports:
- - "3001:3001"
- - "8001:8001"
+ # Pas de ports exposés sur le host : accès uniquement via NPM (proxy_net)
+ # → http://eptm-automation.ch:3001 ne fonctionne plus, utiliser https://dev.dashboard.eptm-automation.ch
volumes:
- ./eptm_dashboard:/app/eptm_dashboard
- ./rxconfig.py:/app/rxconfig.py
- - ./data:/data
+ - ./data:/app/data
- ./logs:/logs
- ./assets:/app/assets
+ - ./scripts:/app/scripts
+ - ./src:/app/src
env_file:
- .env.prod
environment:
- FRONTEND_PORT=3001
- BACKEND_PORT=8001
- - API_URL=http://eptm-automation.ch:8001
+ - API_URL=https://dev.dashboard.eptm-automation.ch
+ # Évite la boucle infinie de hot-reload causée par SQLite WAL/SHM dans data/
+ - REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data
+ networks:
+ - default
+ - proxy_net
+
+networks:
+ proxy_net:
+ external: true
diff --git a/eptm_dashboard/eptm_dashboard.py b/eptm_dashboard/eptm_dashboard.py
index 735afce..47d00ec 100644
--- a/eptm_dashboard/eptm_dashboard.py
+++ b/eptm_dashboard/eptm_dashboard.py
@@ -2,13 +2,10 @@ import reflex as rx
from .state import AuthState
from .pages.login import login_page
from .pages.accueil import accueil_page, AccueilState
-from .pages.traiter import traiter_page
from .pages.fiche import fiche_page, FicheState
-from .pages.classe import classe_page
-from .pages.import_page import import_page_page
-from .pages.escada import escada_page
-from .pages.export import export_page
-from .pages.logs import logs_page
+from .pages.classe import classe_page, ClasseState
+from .pages.escada import escada_page, EscadaState
+from .pages.logs import logs_page, LogsState
from .pages.users import users_page
from .pages.params import params_page
@@ -21,14 +18,17 @@ app = rx.App(
],
)
-app.add_page(login_page, route="/login", title=TITLE)
-app.add_page(accueil_page, route="/accueil", on_load=AccueilState.load_data, title=TITLE)
-app.add_page(traiter_page, route="/traiter", on_load=AuthState.check_auth, title=TITLE)
-app.add_page(fiche_page, route="/fiche", on_load=FicheState.load_data, title=TITLE)
-app.add_page(classe_page, route="/classe", on_load=AuthState.check_auth, title=TITLE)
-app.add_page(import_page_page, route="/import", on_load=AuthState.check_auth, title=TITLE)
-app.add_page(escada_page, route="/escada", on_load=AuthState.check_auth, title=TITLE)
-app.add_page(export_page, route="/export", on_load=AuthState.check_auth, title=TITLE)
-app.add_page(logs_page, route="/logs", on_load=AuthState.check_auth, title=TITLE)
-app.add_page(users_page, route="/users", on_load=AuthState.check_auth, title=TITLE)
-app.add_page(params_page, route="/params", on_load=AuthState.check_auth, title=TITLE)
+
+def index_page() -> rx.Component:
+ return rx.center(rx.spinner(size="3"), height="100vh")
+
+
+app.add_page(index_page, route="/", on_load=AuthState.index_redirect, title=TITLE)
+app.add_page(login_page, route="/login", on_load=AuthState.redirect_if_authenticated, title=TITLE)
+app.add_page(accueil_page, route="/accueil", on_load=[AuthState.check_auth, AccueilState.load_data], title=TITLE)
+app.add_page(fiche_page, route="/fiche", on_load=[AuthState.check_auth, FicheState.load_data], title=TITLE)
+app.add_page(classe_page, route="/classe", on_load=[AuthState.check_auth, ClasseState.load_data], title=TITLE)
+app.add_page(escada_page, route="/escada", on_load=[AuthState.check_auth, EscadaState.load_data], title=TITLE)
+app.add_page(logs_page, route="/logs", on_load=[AuthState.check_auth, LogsState.load_data], title=TITLE)
+app.add_page(users_page, route="/users", on_load=AuthState.check_auth, title=TITLE)
+app.add_page(params_page, route="/params", on_load=AuthState.check_auth, title=TITLE)
diff --git a/eptm_dashboard/pages/classe.py b/eptm_dashboard/pages/classe.py
index 62311ea..d635e95 100644
--- a/eptm_dashboard/pages/classe.py
+++ b/eptm_dashboard/pages/classe.py
@@ -1,12 +1,744 @@
+import io
+import json
+import os
import reflex as rx
+from datetime import timedelta
+from pathlib import Path
+from sqlalchemy import select
+
+DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
+
+from ..state import AuthState
from ..sidebar import layout
+from src.db import (
+ get_session, Apprenti, Absence,
+ NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu,
+)
+from src.stats import nb_blocs_absences, synthese_classe
+from src.parser_bn import sem_short_label
+
+QUOTA = 5
+
+_GROUP_LABELS = {
+ "CG": "Culture Gen.",
+ "BP": "Branches Prof.",
+ "TP": "Trav. Pratiques",
+}
+_GROUP_ORDER = {"DUAL": ["CG", "BP"], "EM": ["BP", "TP"]}
+
+
+# ── HTML generators ───────────────────────────────────────────────────────────
+
+def _bn_fmt(v) -> str:
+ if v is None:
+ return ""
+ try:
+ return f"{float(v):.1f}".replace(".", ",")
+ except (TypeError, ValueError):
+ return ""
+
+
+def _bn_cell_style(v) -> str:
+ base = "border:1px solid #dee2e6;padding:5px 10px;text-align:center"
+ if v is None:
+ return f"{base};color:#bbb"
+ try:
+ if float(v) < 4.0:
+ return f"{base};background:#ffcccc;color:#B71C1C;font-weight:bold"
+ except (TypeError, ValueError):
+ pass
+ return base
+
+
+def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str:
+ N = 8
+ TD = "border:1px solid #dee2e6;padding:5px 10px"
+ TH = "border:1px solid #dee2e6;padding:5px 10px;text-align:center;background:#f8f9fa"
+ SEP = ";border-top:3px solid #9e9e9e"
+
+ header = f'
| '
+ for i in range(N):
+ raw = sem_labels[i] if i < len(sem_labels) else None
+ short = sem_short_label(raw, i)
+ header += f'{short} | '
+
+ def _moy_sem_row(label, gd, label_style, sep=False):
+ s = SEP if sep else ""
+ cells = f'{label} | '
+ for i in range(N):
+ v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None
+ cells += f'{_bn_fmt(v)} | '
+ return f"{cells}
"
+
+ def _moy_ann_row(label, gd, label_style, sep=False):
+ s = SEP if sep else ""
+ cells = f'{label} | '
+ for year_start in range(0, N, 2):
+ v = gd["moy_ann"][year_start] if year_start < len(gd.get("moy_ann", [])) else None
+ cells += f'{_bn_fmt(v)} | '
+ return f"{cells}
"
+
+ body = ""
+ for grp in groups_order:
+ gd = d["groupes"].get(grp, {"moy_sem": [None] * N, "moy_ann": [None] * N})
+ lbl = _GROUP_LABELS.get(grp, grp)
+ body += _moy_sem_row(lbl, gd, f"{TD};font-weight:bold")
+ body += _moy_ann_row("Moyenne annuelle du groupe", gd, f"{TD};font-style:italic;color:#555")
+
+ body += _moy_sem_row("Moyenne semestrielle globale", d["globale"], f"{TD};font-style:italic", sep=True)
+ body += _moy_ann_row("Moyenne annuelle globale", d["globale"], f"{TD};font-weight:bold")
+
+ return (
+ f''
+ f'
'
+ f"{header}
"
+ f"{body}"
+ f"
"
+ )
+
+
+def _matu_html_table(nm) -> str:
+ TD = "border:1px solid #dee2e6;padding:5px 10px"
+ TDc = f"{TD};text-align:center"
+
+ def _cell_moy(v):
+ if v is None:
+ return f'— | '
+ style = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if v < 4.0 else TDc
+ return f'{_bn_fmt(v)} | '
+
+ def _cell_prom(p, info):
+ if not p:
+ return f'— | | '
+ red = p == "NB"
+ style = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if red else TDc
+ return (
+ f'{p} | '
+ f'{info or ""} | '
+ )
+
+ rows = (
+ f'| Moyenne du semestre | {_cell_moy(nm.moy)} |
'
+ f'| Promotion | {_cell_prom(nm.promotion, nm.prom_info)}
'
+ )
+ header_div = (
+ f'Matu — {nm.classe_mp} — {nm.sem_label}
'
+ )
+ return (
+ f'"
+ )
+
+
+def _render_notes_html(notes_data: list) -> str:
+ html = (
+ ""
+ )
+ for _br in notes_data:
+ _moy = _br.get("moy_arr")
+ _moy_prov = _br.get("moy_prov")
+ _insuf = _moy is not None and float(_moy) < 4.0
+ _mc = "#c62828" if _insuf else ("#e65100" if _moy and float(_moy) < 5.0 else "#2e7d32")
+ _br_name = ("⚠ " if _insuf else "") + _br["branche"]
+ _moy_html = (
+ f'
{_moy}'
+ + (f'
({_moy_prov})'
+ if _moy_prov is not None else "")
+ ) if _moy is not None else "—"
+ _insuf_cls = " insuf" if _insuf else ""
+ html += (
+ f'
'
+ f'{_br_name}'
+ f'Moyenne : {_moy_html}
'
+ "
| Date | Examen | Enseignant | "
+ "Coeff | Type | Note |
"
+ )
+ for _ex in _br.get("examens", []):
+ _n = _ex["note"]
+ if _n is None:
+ _note_html = '—'
+ elif _n == "disp.":
+ _note_html = 'disp.'
+ else:
+ _nc = "#c62828" if float(_n) < 4.0 else ("#e65100" if float(_n) < 5.0 else "#2e7d32")
+ _disp_tag = (
+ ' [disp.]'
+ if _ex.get("dispensed") else ""
+ )
+ _note_html = f'{_n}{_disp_tag}'
+ html += (
+ f'| {_ex["date"]} | '
+ f'{_ex["description"]} | '
+ f'{_ex["enseignant"]} | '
+ f'{_ex["coefficient"] or ""} | '
+ f'{_ex["type"]} | '
+ f'{_note_html} |
'
+ )
+ html += "
"
+ html += "
"
+ return html
+
+
+def _absence_pdf_apprenti(sess, apprenti) -> bytes:
+ from reportlab.lib import colors as _rl_colors
+ from reportlab.lib.pagesizes import A4, landscape
+ from reportlab.lib.styles import getSampleStyleSheet
+ from reportlab.lib.units import cm
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
+
+ absences = sess.execute(
+ select(Absence)
+ .where(Absence.apprenti_id == apprenti.id)
+ .order_by(Absence.date, Absence.periode)
+ ).scalars().all()
+
+ by_date: dict = {}
+ for ab in absences:
+ by_date.setdefault(ab.date, {})[ab.periode] = "E" if ab.statut == "excusee" else "N"
+
+ sorted_dates = sorted(by_date)
+ blocs: list = []
+ if sorted_dates:
+ dates_set = set(sorted_dates)
+ cur = [sorted_dates[0]]
+ for d in sorted_dates[1:]:
+ check = cur[-1] + timedelta(days=1)
+ gap_ok = True
+ while check < d:
+ if check.weekday() < 5 and check not in dates_set:
+ gap_ok = False
+ break
+ check += timedelta(days=1)
+ if gap_ok:
+ cur.append(d)
+ else:
+ blocs.append(cur)
+ cur = [d]
+ blocs.append(cur)
+
+ DARK = _rl_colors.HexColor("#37474F")
+ BLUE_BG = _rl_colors.HexColor("#E3F2FD")
+ BLUE_FG = _rl_colors.HexColor("#0D47A1")
+ RED_BG = _rl_colors.HexColor("#FFEBEE")
+ RED_FG = _rl_colors.HexColor("#B71C1C")
+ GREY_BG = _rl_colors.HexColor("#F5F5F5")
+ FOOT_BG = _rl_colors.HexColor("#ECEFF1")
+
+ data = [["Abs.", "Date"] + [f"P{i}" for i in range(1, 11)]]
+ styles_tbl = [
+ ("BACKGROUND", (0, 0), (-1, 0), DARK),
+ ("TEXTCOLOR", (0, 0), (-1, 0), _rl_colors.white),
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("GRID", (0, 0), (-1, -1), 0.5, _rl_colors.HexColor("#CCCCCC")),
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [_rl_colors.white, GREY_BG]),
+ ]
+
+ total_e = total_n = 0
+ row_idx = 1
+
+ for bloc_num, bloc_dates in enumerate(blocs, start=1):
+ first_row = row_idx
+ for i, d in enumerate(bloc_dates):
+ periods = by_date[d]
+ row = [str(bloc_num) if i == 0 else "", d.strftime("%d.%m.%Y")]
+ for p in range(1, 11):
+ val = periods.get(p, "")
+ row.append(val)
+ if val == "E":
+ total_e += 1
+ styles_tbl += [
+ ("BACKGROUND", (p + 1, row_idx), (p + 1, row_idx), BLUE_BG),
+ ("TEXTCOLOR", (p + 1, row_idx), (p + 1, row_idx), BLUE_FG),
+ ("FONTNAME", (p + 1, row_idx), (p + 1, row_idx), "Helvetica-Bold"),
+ ]
+ elif val == "N":
+ total_n += 1
+ styles_tbl += [
+ ("BACKGROUND", (p + 1, row_idx), (p + 1, row_idx), RED_BG),
+ ("TEXTCOLOR", (p + 1, row_idx), (p + 1, row_idx), RED_FG),
+ ("FONTNAME", (p + 1, row_idx), (p + 1, row_idx), "Helvetica-Bold"),
+ ]
+ data.append(row)
+ row_idx += 1
+ if row_idx - first_row > 1:
+ styles_tbl.append(("SPAN", (0, first_row), (0, row_idx - 1)))
+
+ total_row = row_idx
+ total_periodes = total_e + total_n
+ footer_label = (
+ f"{len(blocs)} absence(s) | "
+ f"{total_periodes} periode(s) | "
+ f"{total_e} excusee(s) | "
+ f"{total_n} non excusee(s)"
+ )
+ data.append([footer_label] + [""] * 11)
+ styles_tbl += [
+ ("SPAN", (0, total_row), (-1, total_row)),
+ ("BACKGROUND", (0, total_row), (-1, total_row), FOOT_BG),
+ ("FONTNAME", (0, total_row), (-1, total_row), "Helvetica-Bold"),
+ ("ALIGN", (0, total_row), (-1, total_row), "LEFT"),
+ ("LEFTPADDING", (0, total_row), (-1, total_row), 8),
+ ]
+
+ col_w = [1.5 * cm, 2.8 * cm] + [2.24 * cm] * 10
+ t = Table(data if len(data) > 1 else [data[0]], colWidths=col_w, repeatRows=1)
+ t.setStyle(TableStyle(styles_tbl))
+
+ buf = io.BytesIO()
+ doc = SimpleDocTemplate(
+ buf,
+ pagesize=landscape(A4),
+ leftMargin=1.5 * cm, rightMargin=1.5 * cm,
+ topMargin=1.5 * cm, bottomMargin=1.5 * cm,
+ )
+ styles = getSampleStyleSheet()
+ title = Paragraph(
+ f"Absences - {apprenti.nom} {apprenti.prenom}"
+ f" Classe : {apprenti.classe}",
+ styles["Normal"],
+ )
+ doc.build([title, Spacer(1, 0.5 * cm), t])
+ return buf.getvalue()
+
+
+def _extract_bn_pages(pdf_path, nom: str, prenom: str) -> bytes | None:
+ try:
+ import pdfplumber
+ from pypdf import PdfWriter, PdfReader
+ except ImportError:
+ return None
+ try:
+ pages_to_extract = []
+ with pdfplumber.open(str(pdf_path)) as pdf:
+ for i, page in enumerate(pdf.pages):
+ text = page.extract_text() or ""
+ if nom.upper() in text.upper() and prenom.upper() in text.upper():
+ pages_to_extract.append(i)
+ if not pages_to_extract:
+ return None
+ reader = PdfReader(str(pdf_path))
+ writer = PdfWriter()
+ for i in pages_to_extract:
+ writer.add_page(reader.pages[i])
+ buf = io.BytesIO()
+ writer.write(buf)
+ return buf.getvalue()
+ except Exception:
+ return None
+
+
+# ── State ─────────────────────────────────────────────────────────────────────
+
+class ClasseState(AuthState):
+ classes: list[str] = []
+ selected_class: str = ""
+ has_classes: bool = False
+ apprentis_data: list[dict] = []
+
+ def load_data(self):
+ if not self.authenticated:
+ return rx.redirect("/login")
+ sess = get_session()
+ classes = sess.execute(
+ select(Apprenti.classe).distinct().order_by(Apprenti.classe)
+ ).scalars().all()
+ # Filtrer les classes MP / MI (formations maturité, hors scope)
+ classes = [c for c in classes if c and not c.startswith(("MP", "MI"))]
+ if not classes:
+ self.has_classes = False
+ self.classes = []
+ self.selected_class = ""
+ self.apprentis_data = []
+ return
+ self.has_classes = True
+ self.classes = list(classes)
+ if not self.selected_class or self.selected_class not in self.classes:
+ self.selected_class = self.classes[0]
+ self._reload()
+
+ def set_class(self, classe: str):
+ self.selected_class = classe
+ self._reload()
+
+ def download_abs_pdf(self, apprenti_id: int):
+ sess = get_session()
+ apprenti = sess.get(Apprenti, apprenti_id)
+ if apprenti is None:
+ return
+ pdf_bytes = _absence_pdf_apprenti(sess, apprenti)
+ filename = f"Absences_{apprenti.nom}_{apprenti.prenom}.pdf"
+ return rx.download(data=pdf_bytes, filename=filename)
+
+ def download_bn_pdf(self, apprenti_id: int):
+ sess = get_session()
+ apprenti = sess.get(Apprenti, apprenti_id)
+ if apprenti is None:
+ return
+ latest_imp = sess.execute(
+ select(ImportBN)
+ .where(ImportBN.classe == apprenti.classe)
+ .order_by(ImportBN.date_import.desc())
+ .limit(1)
+ ).scalar_one_or_none()
+ if not latest_imp or not latest_imp.fichier:
+ return
+ pdf_path = DATA_DIR / "pdfs" / latest_imp.fichier
+ pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom)
+ if pdf_bytes is None:
+ return
+ filename = f"BN_{apprenti.nom}_{apprenti.prenom}.pdf"
+ return rx.download(data=pdf_bytes, filename=filename)
+
+ def download_notes_pdf(self, apprenti_id: int):
+ sess = get_session()
+ apprenti = sess.get(Apprenti, apprenti_id)
+ if apprenti is None:
+ return
+ notes_fname = f"notes_{apprenti.classe.replace(' ', '_')}.pdf"
+ pdf_path = DATA_DIR / "pdfs" / notes_fname
+ pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom)
+ if pdf_bytes is None:
+ return
+ filename = f"Notes_{apprenti.nom}_{apprenti.prenom}.pdf"
+ return rx.download(data=pdf_bytes, filename=filename)
+
+ def _reload(self):
+ if not self.selected_class:
+ self.apprentis_data = []
+ return
+
+ sess = get_session()
+ pdf_dir = DATA_DIR / "pdfs"
+ classe = self.selected_class
+
+ apprentis = sess.execute(
+ select(Apprenti)
+ .where(Apprenti.classe == classe)
+ .order_by(Apprenti.nom, Apprenti.prenom)
+ ).scalars().all()
+
+ # Absence stats
+ df_syn = synthese_classe(sess, classe)
+ abs_by_name: dict = {}
+ if not df_syn.empty:
+ for _, r in df_syn.iterrows():
+ abs_by_name[(r["Nom"], r["Prénom"])] = r
+
+ # BN import
+ latest_imp = sess.execute(
+ select(ImportBN)
+ .where(ImportBN.classe == classe)
+ .order_by(ImportBN.date_import.desc())
+ .limit(1)
+ ).scalar_one_or_none()
+ if latest_imp:
+ bn_records = sess.execute(
+ select(NotesBulletin).where(NotesBulletin.import_id == latest_imp.id)
+ ).scalars().all()
+ bn_by_id = {bn.apprenti_id: bn for bn in bn_records}
+ first_bn = next(iter(bn_by_id.values()), None)
+ sem_labels = json.loads(first_bn.sem_labels_json) if first_bn else []
+ groups_order = _GROUP_ORDER.get(latest_imp.type_classe, ["BP"])
+ bn_caption = (
+ f"Import du {latest_imp.date_import.strftime('%d.%m.%Y %H:%M')}"
+ f" — {latest_imp.imported_by}"
+ )
+ bn_pdf_exists = (
+ bool(latest_imp.fichier) and (pdf_dir / latest_imp.fichier).exists()
+ )
+ else:
+ bn_by_id = {}
+ sem_labels = []
+ groups_order = ["BP"]
+ bn_caption = ""
+ bn_pdf_exists = False
+
+ notes_pdf_fname = f"notes_{classe.replace(' ', '_')}.pdf"
+ notes_pdf_exists = (pdf_dir / notes_pdf_fname).exists()
+
+ # Notes d'examen
+ ne_rows = sess.execute(
+ select(NotesExamen, Apprenti)
+ .join(Apprenti, Apprenti.id == NotesExamen.apprenti_id)
+ .where(Apprenti.classe == classe)
+ ).all()
+ ne_by_id = {ne.apprenti_id: json.loads(ne.donnees_json) for ne, _ in ne_rows}
+
+ data = []
+ for apprenti in apprentis:
+ abs_data = abs_by_name.get((apprenti.nom, apprenti.prenom))
+ total = int(abs_data["Total"]) if abs_data is not None else 0
+ excusees = int(abs_data["Excusées"]) if abs_data is not None else 0
+ non_exc = int(abs_data["NON excusées"]) if abs_data is not None else 0
+ blocs = nb_blocs_absences(sess, apprenti.id)
+ quota_atteint = blocs >= QUOTA
+
+ # BN HTML
+ bn = bn_by_id.get(apprenti.id)
+ if bn and sem_labels:
+ d = json.loads(bn.donnees_json)
+ html = _bn_html_table(d, sem_labels, groups_order)
+ nm = sess.execute(
+ select(NotesMatu)
+ .join(ImportMatu, ImportMatu.id == NotesMatu.import_id)
+ .where(NotesMatu.apprenti_id == apprenti.id)
+ .order_by(ImportMatu.date_import.desc())
+ .limit(1)
+ ).scalar_one_or_none()
+ if nm:
+ html += _matu_html_table(nm)
+ has_bn = True
+ bn_html = html
+ else:
+ has_bn = False
+ bn_html = ""
+
+ # Notes HTML
+ nd = ne_by_id.get(apprenti.id)
+ if nd:
+ has_notes = True
+ notes_html = _render_notes_html(nd)
+ else:
+ has_notes = False
+ notes_html = ""
+
+ data.append({
+ "id": apprenti.id,
+ "nom": apprenti.nom,
+ "prenom": apprenti.prenom,
+ "total": total,
+ "excusees": excusees,
+ "non_exc": non_exc,
+ "blocs": blocs,
+ "quota_atteint": quota_atteint,
+ "has_bn": has_bn,
+ "bn_html": bn_html,
+ "bn_caption": bn_caption if has_bn else "",
+ "has_notes": has_notes,
+ "notes_html": notes_html,
+ "has_pdf_bn": bn_pdf_exists,
+ "has_pdf_notes": notes_pdf_exists,
+ })
+
+ self.apprentis_data = data
+
+
+# ── UI components ─────────────────────────────────────────────────────────────
+
+def _kpi_mini(label: str, value, color: str = "#37474f") -> rx.Component:
+ return rx.box(
+ rx.text(label, size="1", color="#888"),
+ rx.text(value, size="5", font_weight="700", color=color),
+ padding="0.5rem 0.75rem",
+ background_color="#f8f9fa",
+ border_radius="6px",
+ border="1px solid #e9ecef",
+ min_width="80px",
+ text_align="center",
+ )
+
+
+def _apprenti_card(item) -> rx.Component:
+ return rx.box(
+ # ── En-tête : nom + badge quota ───────────────────────────────────────
+ rx.hstack(
+ rx.link(
+ rx.text(
+ item["prenom"], " ", item["nom"],
+ size="4", font_weight="700", color="#1a237e",
+ ),
+ href="/fiche",
+ text_decoration="none",
+ ),
+ rx.cond(
+ item["quota_atteint"],
+ rx.badge(
+ rx.icon("triangle-alert", size=11),
+ " Sanction",
+ color_scheme="red",
+ variant="soft",
+ ),
+ ),
+ spacing="3",
+ align="center",
+ wrap="wrap",
+ width="100%",
+ margin_bottom="0.75rem",
+ ),
+
+ # ── KPIs absences ─────────────────────────────────────────────────────
+ rx.flex(
+ _kpi_mini("Total", item["total"]),
+ _kpi_mini("Excusees", item["excusees"], "#2e7d32"),
+ _kpi_mini("Non excusees", item["non_exc"], "#c62828"),
+ rx.cond(
+ item["quota_atteint"],
+ _kpi_mini("Absences", item["blocs"], "#c62828"),
+ _kpi_mini("Absences", item["blocs"]),
+ ),
+ gap="0.5rem",
+ flex_wrap="wrap",
+ margin_bottom="0.75rem",
+ ),
+
+ # ── Boutons téléchargement PDF ────────────────────────────────────────
+ rx.flex(
+ rx.button(
+ rx.icon("download", size=13),
+ "PDF absences",
+ on_click=ClasseState.download_abs_pdf(item["id"]),
+ variant="outline",
+ color_scheme="gray",
+ size="1",
+ ),
+ rx.cond(
+ item["has_pdf_bn"],
+ rx.button(
+ rx.icon("file-text", size=13),
+ "PDF bulletin",
+ on_click=ClasseState.download_bn_pdf(item["id"]),
+ variant="outline",
+ color_scheme="blue",
+ size="1",
+ ),
+ ),
+ rx.cond(
+ item["has_pdf_notes"],
+ rx.button(
+ rx.icon("file-text", size=13),
+ "PDF notes",
+ on_click=ClasseState.download_notes_pdf(item["id"]),
+ variant="outline",
+ color_scheme="violet",
+ size="1",
+ ),
+ ),
+ flex_wrap="wrap",
+ gap="0.5rem",
+ margin_bottom="0.75rem",
+ ),
+
+ # ── Onglets BN / Notes ────────────────────────────────────────────────
+ rx.tabs.root(
+ rx.tabs.list(
+ rx.tabs.trigger("Cours professionnels", value="bn"),
+ rx.tabs.trigger("Notes d'examen", value="notes"),
+ ),
+ rx.tabs.content(
+ rx.cond(
+ item["has_bn"],
+ rx.vstack(
+ rx.text(item["bn_caption"], size="1", color="#9e9e9e"),
+ rx.box(
+ rx.html(item["bn_html"]),
+ width="100%",
+ overflow_x="auto",
+ ),
+ spacing="2", width="100%",
+ ),
+ rx.text(
+ "Aucun bulletin de notes importe.",
+ size="2", color="#666",
+ ),
+ ),
+ value="bn",
+ width="100%",
+ padding_top="0.75rem",
+ ),
+ rx.tabs.content(
+ rx.cond(
+ item["has_notes"],
+ rx.box(
+ rx.html(item["notes_html"]),
+ width="100%",
+ overflow_x="auto",
+ ),
+ rx.text(
+ "Aucune note d'examen disponible. Lancez une synchronisation Escada avec l'option Notes.",
+ size="2", color="#666",
+ ),
+ ),
+ value="notes",
+ width="100%",
+ padding_top="0.75rem",
+ ),
+ default_value="bn",
+ width="100%",
+ ),
+
+ padding="1.25rem",
+ background_color="white",
+ border_radius="8px",
+ border="1px solid #e0e0e0",
+ width="100%",
+ overflow="hidden",
+ )
def classe_page() -> rx.Component:
return layout(
rx.vstack(
rx.heading("Vue classe", size="7"),
- rx.text("Page en cours de migration..."),
- spacing="4",
+
+ rx.cond(
+ ClasseState.has_classes,
+ rx.vstack(
+ rx.select(
+ ClasseState.classes,
+ value=ClasseState.selected_class,
+ on_change=ClasseState.set_class,
+ width="100%",
+ ),
+
+ rx.cond(
+ ClasseState.apprentis_data.length() > 0,
+ rx.vstack(
+ rx.foreach(ClasseState.apprentis_data, _apprenti_card),
+ spacing="4",
+ width="100%",
+ ),
+ rx.text(
+ "Aucun apprenti dans cette classe.",
+ size="2", color="#666",
+ ),
+ ),
+
+ spacing="4",
+ width="100%",
+ ),
+ rx.box(
+ rx.text(
+ "Aucune donnee. Importez d'abord un PDF.",
+ size="2", color="#666",
+ ),
+ padding="1rem",
+ background_color="#e3f2fd",
+ border_radius="6px",
+ border="1px solid #90caf9",
+ width="100%",
+ ),
+ ),
+
+ spacing="5",
+ width="100%",
)
)
diff --git a/eptm_dashboard/pages/escada.py b/eptm_dashboard/pages/escada.py
index 54f5d58..53183a1 100644
--- a/eptm_dashboard/pages/escada.py
+++ b/eptm_dashboard/pages/escada.py
@@ -1,12 +1,1312 @@
-import reflex as rx
-from ..sidebar import layout
+import asyncio
+import concurrent.futures as _cf
+import json
+import os
+import re
+import subprocess
+import sys
+import tempfile
+import time as _time
+from datetime import datetime
+from pathlib import Path
+from sqlalchemy import select
+from sqlalchemy.orm import joinedload
+import reflex as rx
+
+
+def _background(fn):
+ fn._reflex_background_task = True
+ return fn
+
+from ..state import AuthState
+from ..sidebar import layout
+from src.db import get_session, Apprenti, EscadaPending
+from src.logger import app_log
+
+_RE_SYNC_PROD = re.compile(r"^\[\d{2}:\d{2}:\d{2}\] ([^ ].*)$")
+_RE_SYNC_DEBUG = re.compile(r"^\[\d{2}:\d{2}:\d{2}\]\s+(.+)$")
+
+
+def _log_sync_line(line: str, prefix: str = "sync") -> None:
+ m = _RE_SYNC_PROD.match(line)
+ if m:
+ app_log(f"[{prefix}] {m.group(1)}")
+ return
+ m2 = _RE_SYNC_DEBUG.match(line)
+ if m2:
+ app_log(f" [{prefix}] {m2.group(1)}", debug=True)
+ elif line.strip():
+ app_log(f" [{prefix}] {line}", debug=True)
+
+
+_ROOT = Path(__file__).resolve().parent.parent.parent
+DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
+CLASSES_CACHE = DATA_DIR / "esacada_classes.json"
+_SYNC_SCRIPT = _ROOT / "scripts" / "sync_esacada.py"
+_PUSH_SCRIPT = _ROOT / "scripts" / "push_to_escada.py"
+_SYNC_RESULT_FILE = DATA_DIR / "sync_last_result.json"
+_SYNC_ALL_DONE_FILE = DATA_DIR / "sync_all_done.json"
+
+
+# ── State ─────────────────────────────────────────────────────────────────────
+
+class EscadaState(AuthState):
+ classes_cache: list[str] = []
+ class_checked: dict[str, bool] = {}
+
+ sync_abs: bool = True
+ sync_bn: bool = True
+ sync_notes: bool = True
+ sync_fiches: bool = False
+ force_abs: bool = False
+
+ is_refreshing: bool = False
+ is_syncing: bool = False
+ is_pushing: bool = False
+ import_in_progress: bool = False
+
+ op_log: str = ""
+
+ sync_done: bool = False
+ sync_res_abs: list[dict] = []
+ sync_res_bn: list[dict] = []
+ sync_res_notes: list[dict] = []
+ sync_res_matu: list[dict] = []
+ sync_errors: list[str] = []
+
+ pending_count: int = 0
+ pending_data: list[dict] = []
+
+ push_done: bool = False
+ push_ok: int = 0
+ push_errors: list[str] = []
+ push_test: bool = False
+
+ @rx.var
+ def selected_count(self) -> int:
+ return sum(1 for v in self.class_checked.values() if v)
+
+ @rx.var
+ def selected_classes_list(self) -> list[str]:
+ return [c for c in self.classes_cache if self.class_checked.get(c, False)]
+
+ @rx.var
+ def all_selected(self) -> bool:
+ return (
+ len(self.classes_cache) > 0
+ and self.selected_count >= len(self.classes_cache)
+ )
+
+ @rx.var
+ def has_classes(self) -> bool:
+ return len(self.classes_cache) > 0
+
+ @rx.var
+ def sync_disabled(self) -> bool:
+ return (
+ self.is_refreshing or self.is_syncing or self.is_pushing
+ or self.import_in_progress or self.selected_count == 0
+ )
+
+ @rx.var
+ def is_busy(self) -> bool:
+ return self.is_refreshing or self.is_syncing or self.is_pushing
+
+ # ── Simple setters ─────────────────────────────────────────────────────────
+
+ def set_sync_abs(self, v: bool): self.sync_abs = v
+ def set_sync_bn(self, v: bool): self.sync_bn = v
+ def set_sync_notes(self, v: bool): self.sync_notes = v
+ def set_sync_fiches(self, v: bool): self.sync_fiches = v
+ def set_force_abs(self, v: bool): self.force_abs = v
+ def set_push_test(self, v: bool): self.push_test = v
+
+ def _clear_results(self):
+ self.sync_done = False
+ self.sync_res_abs = []
+ self.sync_res_bn = []
+ self.sync_res_notes = []
+ self.sync_res_matu = []
+ self.sync_errors = []
+
+ def toggle_class(self, classe: str, checked: bool):
+ self.class_checked[classe] = checked
+ self._clear_results()
+
+ def pick_class(self, classe: str):
+ """Toggle binaire (utilisé par le multi-select chip widget)."""
+ self.class_checked[classe] = not self.class_checked.get(classe, False)
+ self._clear_results()
+
+ def toggle_all_classes(self):
+ """Si tout sélectionné → tout désélectionner, sinon → tout sélectionner."""
+ if self.all_selected:
+ self.class_checked = {c: False for c in self.classes_cache}
+ else:
+ self.class_checked = {c: True for c in self.classes_cache}
+ self._clear_results()
+
+ def select_all(self):
+ self.class_checked = {c: True for c in self.classes_cache}
+ self._clear_results()
+
+ def clear_all(self):
+ self.class_checked = {c: False for c in self.classes_cache}
+ self._clear_results()
+
+
+ def reset_sync(self):
+ self.is_syncing = False
+ self.is_refreshing = False
+ self.is_pushing = False
+ self.import_in_progress = False
+ app_log("Sync annulee")
+
+ # ── load_data ──────────────────────────────────────────────────────────────
+
+ def load_data(self):
+ if not self.authenticated:
+ return rx.redirect("/login")
+ # Réinitialiser les états bloqués (crash worker)
+ self.is_syncing = False
+ self.is_refreshing = False
+ self.is_pushing = False
+ self.import_in_progress = False
+ if CLASSES_CACHE.exists():
+ try:
+ cached = json.loads(CLASSES_CACHE.read_text(encoding="utf-8"))
+ # Filtrer MP/MI (formations maturité, hors scope)
+ cached = [c for c in cached if c and not c.startswith(("MP", "MI"))]
+ self.classes_cache = cached
+ for c in cached:
+ if c not in self.class_checked:
+ self.class_checked[c] = False
+ except Exception:
+ pass
+ self._reload_pending()
+ # Vider les résultats à chaque visite de la page
+ self.sync_done = False
+ self.sync_res_abs = []
+ self.sync_res_bn = []
+ self.sync_res_notes = []
+ self.sync_res_matu = []
+ self.sync_errors = []
+
+ def _reload_pending(self):
+ sess = get_session()
+ try:
+ pending = sess.execute(
+ select(EscadaPending)
+ .options(joinedload(EscadaPending.apprenti))
+ .join(Apprenti, EscadaPending.apprenti_id == Apprenti.id)
+ .order_by(Apprenti.classe, EscadaPending.date, Apprenti.nom)
+ ).scalars().all()
+ self.pending_count = len(pending)
+ self.pending_data = [
+ {
+ "classe": ep.apprenti.classe,
+ "nom": ep.apprenti.nom,
+ "prenom": ep.apprenti.prenom,
+ "date": ep.date.strftime("%d.%m.%Y"),
+ "periode": str(ep.periode),
+ "action": ep.action,
+ }
+ for ep in pending
+ ]
+ finally:
+ sess.close()
+
+ # ── Background: refresh classes ────────────────────────────────────────────
+
+ @_background
+ async def refresh_classes(self):
+ app_log("Rafraichissement liste classes Escada")
+ async with self:
+ self.is_refreshing = True
+ self.op_log = "Connexion a Escadaweb..."
+
+ cmd = [sys.executable, str(_SYNC_SCRIPT), "--list-classes"]
+ lines: list[str] = []
+ _rc_holder = [0]
+
+ def _run_refresh() -> None:
+ _fd, _tmp = tempfile.mkstemp(suffix="_refresh.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="refresh")
+ if _proc.poll() is not None:
+ _time.sleep(0.5)
+ try:
+ with open(_tmp, "rb") as _fin:
+ _fin.seek(_offset); _chunk = _fin.read()
+ if _chunk:
+ _buf += _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="refresh")
+ if _buf.strip():
+ _ln = _buf.decode("utf-8", errors="replace").rstrip()
+ if _ln:
+ lines.append(_ln); _log_sync_line(_ln, prefix="refresh")
+ except Exception:
+ pass
+ _rc_holder[0] = _proc.wait() or 0
+ break
+ except Exception as _exc:
+ app_log(f"Erreur refresh subprocess : {_exc}")
+ finally:
+ try: os.unlink(_tmp)
+ except Exception: pass
+
+ _pool = _cf.ThreadPoolExecutor(max_workers=1)
+ _fut = _pool.submit(_run_refresh)
+ 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"[refresh] thread exception : {_te}")
+ finally:
+ _pool.shutdown(wait=False)
+
+ new_classes: list[str] = []
+ for line in lines:
+ if line.startswith("CLASSES_JSON:"):
+ try:
+ new_classes = json.loads(line[len("CLASSES_JSON:"):])
+ except Exception:
+ pass
+
+ # Filtrer MP/MI (formations maturité, hors scope)
+ new_classes = [c for c in new_classes if c and not c.startswith(("MP", "MI"))]
+
+ if new_classes:
+ app_log(f"Classes recuperees : {', '.join(new_classes)}")
+ else:
+ app_log(f"Aucune classe recuperee (code={_rc_holder[0]}, lignes={len(lines)})")
+
+ try:
+ _t = asyncio.current_task()
+ if _t is not None:
+ for _ in range(_t.cancelling()):
+ _t.uncancel()
+ async with self:
+ if new_classes:
+ self.classes_cache = new_classes
+ existing = dict(self.class_checked)
+ self.class_checked = {c: existing.get(c, False) for c in new_classes}
+ try:
+ CLASSES_CACHE.write_text(
+ json.dumps(new_classes, ensure_ascii=False), encoding="utf-8"
+ )
+ except Exception:
+ pass
+ self.op_log = "\n".join(lines[-60:])
+ self.is_refreshing = False
+ except Exception as _e:
+ app_log(f"Erreur mise a jour etat refresh : {_e}")
+ try:
+ async with self:
+ self.is_refreshing = False
+ except Exception:
+ pass
+
+ # ── Background: sync depuis Escada ─────────────────────────────────────────
+ # UN SEUL async with self: (au début) — transitions via yield vers regular handlers.
+
+ @_background
+ async def sync_escada(self):
+ async with self: # SEUL async with self: de cette background task
+ selected = [c for c, v in self.class_checked.items() if v]
+ sync_abs = self.sync_abs
+ sync_bn = self.sync_bn
+ sync_notes = self.sync_notes
+ sync_fiches = self.sync_fiches
+ force_abs = self.force_abs
+ username = self.username or "escada"
+ if not selected:
+ return
+ self.is_syncing = True
+ self.import_in_progress = False
+ self.sync_done = False
+ self.sync_errors = []
+ self.sync_res_abs = []
+ self.sync_res_bn = []
+ self.sync_res_notes = []
+ self.sync_res_matu = []
+
+ app_log(f"Sync Escada — {len(selected)} classe(s) : {', '.join(selected)}")
+
+ args = ["--sync-all"] + selected
+ if not sync_abs: args.append("--skip-abs")
+ if not sync_bn: args.append("--skip-bn")
+ if not sync_notes: args.append("--skip-notes")
+ if not sync_fiches: args.append("--skip-fiches")
+ if force_abs: args.append("--force-abs")
+
+ cmd = [sys.executable, str(_SYNC_SCRIPT), *args]
+ lines: list[str] = []
+ _rc_holder = [0]
+ _sync_start = datetime.now()
+
+ def _run_subprocess() -> None:
+ _fd, _tmp = tempfile.mkstemp(suffix="_sync.log")
+ os.close(_fd)
+ _all_done_payload: dict = {}
+ 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,
+ )
+ app_log(f" Popen pid={_proc.pid}", debug=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="sync")
+ if not _all_done_payload and "ALL_DONE " in _ln:
+ try:
+ _all_done_payload = json.loads(
+ _ln[_ln.index("ALL_DONE ") + len("ALL_DONE "):]
+ )
+ except Exception as _je:
+ app_log(f" ERREUR parse ALL_DONE JSON : {_je}")
+ if _all_done_payload:
+ try:
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+ _SYNC_ALL_DONE_FILE.write_text(
+ json.dumps({
+ "timestamp": datetime.now().isoformat(),
+ "payload": _all_done_payload,
+ }, ensure_ascii=False),
+ encoding="utf-8",
+ )
+ app_log(f"sync_all_done.json ecrit")
+ except Exception as _we:
+ app_log(f" ERREUR ecriture sync_all_done.json : {_we}")
+
+ if _proc.poll() is not None:
+ _time.sleep(0.5)
+ try:
+ with open(_tmp, "rb") as _fin:
+ _fin.seek(_offset); _chunk = _fin.read()
+ if _chunk:
+ _buf += _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="sync")
+ if not _all_done_payload and "ALL_DONE " in _ln:
+ try:
+ _all_done_payload = json.loads(
+ _ln[_ln.index("ALL_DONE ") + len("ALL_DONE "):]
+ )
+ except Exception as _je:
+ app_log(f" ERREUR parse ALL_DONE JSON (drain) : {_je}")
+ if _all_done_payload:
+ try:
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+ _SYNC_ALL_DONE_FILE.write_text(
+ json.dumps({
+ "timestamp": datetime.now().isoformat(),
+ "payload": _all_done_payload,
+ }, ensure_ascii=False),
+ encoding="utf-8",
+ )
+ app_log(f"sync_all_done.json ecrit (drain)")
+ except Exception as _we:
+ app_log(f" ERREUR ecriture sync_all_done.json (drain) : {_we}")
+ if _buf.strip():
+ _ln = _buf.decode("utf-8", errors="replace").rstrip()
+ if _ln:
+ lines.append(_ln); _log_sync_line(_ln, prefix="sync")
+ except Exception:
+ pass
+ _rc_holder[0] = _proc.wait() or 0
+ app_log(f" subprocess termine, code={_rc_holder[0]}", debug=True)
+ break
+
+ elif _all_done_payload:
+ app_log(" ALL_DONE recu — arret force subprocess", debug=True)
+ try:
+ os.killpg(os.getpgid(_proc.pid), 9)
+ except Exception:
+ try: _proc.kill()
+ except Exception: pass
+ try: _proc.wait(timeout=2)
+ except Exception: pass
+ _rc_holder[0] = 0
+ break
+
+ except Exception as _exc:
+ app_log(f"Erreur sync subprocess : {type(_exc).__name__}: {_exc}")
+ finally:
+ try: os.unlink(_tmp)
+ except Exception: pass
+
+ _rc = _rc_holder[0]
+ app_log(f"Sync script termine — code={_rc}, lignes={len(lines)}")
+ if not _all_done_payload:
+ app_log(f"ALL_DONE non trouve (code={_rc})")
+
+ _pool = _cf.ThreadPoolExecutor(max_workers=1)
+ _fut = _pool.submit(_run_subprocess)
+ 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"[sync] thread exception : {_te}")
+ finally:
+ _pool.shutdown(wait=False)
+
+ # ── Vérifier ALL_DONE ────────────────────────────────────────────────────
+ def _read_ts(p):
+ try:
+ return datetime.fromisoformat(
+ json.loads(p.read_text(encoding="utf-8")).get("timestamp", "") or ""
+ )
+ except Exception:
+ return datetime(2000, 1, 1)
+
+ _all_done_ts = datetime(2000, 1, 1)
+ try:
+ if _SYNC_ALL_DONE_FILE.exists():
+ _adf_t = _read_ts(_SYNC_ALL_DONE_FILE)
+ if _adf_t > _sync_start:
+ _all_done_ts = _adf_t
+ except Exception:
+ pass
+
+ def _uncancel():
+ _cur = asyncio.current_task()
+ if _cur is not None:
+ for _ in range(_cur.cancelling()):
+ _cur.uncancel()
+
+ if _all_done_ts == datetime(2000, 1, 1):
+ # Sync échouée — async with self #2
+ app_log("ALL_DONE absent — sync echouee")
+ _uncancel()
+ async with self:
+ self.is_syncing = False
+ self.sync_errors = ["Synchronisation echouee — aucune donnee recue depuis Escadaweb."]
+ return
+
+ # ── Phase 2 : import subprocess en cours — async with self #2 ────────────
+ app_log("ALL_DONE confirme — phase import")
+ _uncancel()
+ async with self:
+ self.is_syncing = False
+ self.import_in_progress = True
+
+ # Polling inline (max 20 × 3s = 60s) — aucune mise à jour d'état ici
+ _result_data: dict = {}
+ _result_ready = False
+ for _ in range(20):
+ try:
+ await asyncio.sleep(3)
+ except asyncio.CancelledError:
+ _uncancel()
+ if _SYNC_RESULT_FILE.exists():
+ _res_ts = _read_ts(_SYNC_RESULT_FILE)
+ if _res_ts > _all_done_ts:
+ try:
+ _result_data = json.loads(_SYNC_RESULT_FILE.read_text(encoding="utf-8"))
+ except Exception:
+ pass
+ _result_ready = True
+ break
+
+ # ── État final — async with self #3 ──────────────────────────────────────
+ app_log(f"Poll termine — result_ready={_result_ready}")
+ _uncancel()
+ async with self:
+ self.import_in_progress = False
+ if _result_ready:
+ self.sync_res_abs = _result_data.get("res_abs", [])
+ self.sync_res_bn = _result_data.get("res_bn", [])
+ self.sync_res_notes = _result_data.get("res_notes", [])
+ self.sync_res_matu = _result_data.get("res_matu", [])
+ self.sync_errors = _result_data.get("errors", [])
+ self.sync_done = True
+ app_log("Resultats charges — sync terminee OK")
+ else:
+ self.sync_errors = ["Import timeout — verifiez les logs (> 60s)."]
+
+ # ── Background: push vers Escada ───────────────────────────────────────────
+
+ @_background
+ async def push_escada(self):
+ async with self:
+ push_test = self.push_test
+ self.is_pushing = True
+ self.op_log = "Envoi vers Escadaweb..."
+ self.push_done = False
+ self.push_ok = 0
+ self.push_errors = []
+
+ app_log(f"Push Escada demarre (test={push_test})")
+ extra = ["--test"] if push_test else []
+ cmd = [sys.executable, str(_PUSH_SCRIPT), *extra]
+ lines: list[str] = []
+ _rc_holder = [0]
+
+ def _run_push() -> None:
+ _fd, _tmp = tempfile.mkstemp(suffix="_push.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")
+ if _proc.poll() is not None:
+ _time.sleep(0.5)
+ try:
+ with open(_tmp, "rb") as _fin:
+ _fin.seek(_offset); _chunk = _fin.read()
+ if _chunk:
+ _buf += _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")
+ if _buf.strip():
+ _ln = _buf.decode("utf-8", errors="replace").rstrip()
+ if _ln:
+ lines.append(_ln); _log_sync_line(_ln, prefix="push")
+ except Exception:
+ pass
+ _rc_holder[0] = _proc.wait() or 0
+ break
+ except Exception as _exc:
+ app_log(f"Erreur push subprocess : {_exc}")
+ finally:
+ try: os.unlink(_tmp)
+ except Exception: pass
+
+ _pool = _cf.ThreadPoolExecutor(max_workers=1)
+ _fut = _pool.submit(_run_push)
+ 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] thread exception : {_te}")
+ finally:
+ _pool.shutdown(wait=False)
+
+ _rc = _rc_holder[0]
+ push_ok = 0
+ push_errors: list[str] = []
+ push_done = False
+ for line in lines:
+ if "PUSH_DONE " in line:
+ push_done = True
+ try:
+ p = json.loads(line[line.index("PUSH_DONE ") + len("PUSH_DONE "):])
+ push_ok = p.get("ok", 0)
+ push_errors = p.get("err", [])
+ except Exception as _e:
+ app_log(f" Erreur parse PUSH_DONE : {_e}", debug=True)
+
+ if push_done:
+ app_log(f"Push termine — ok:{push_ok} erreurs:{len(push_errors)}")
+ else:
+ app_log(f"Push : PUSH_DONE non trouve (code={_rc}, lignes={len(lines)})")
+
+ try:
+ _t = asyncio.current_task()
+ if _t is not None:
+ for _ in range(_t.cancelling()):
+ _t.uncancel()
+ async with self:
+ self.push_done = push_done
+ self.push_ok = push_ok
+ self.push_errors = push_errors
+ self.op_log = "\n".join(lines[-60:])
+ self.is_pushing = False
+ self._reload_pending()
+ except Exception as _e:
+ app_log(f"Erreur mise a jour etat push : {_e}")
+ try:
+ async with self:
+ self.is_pushing = False
+ except Exception:
+ pass
+
+
+# ── UI helpers ────────────────────────────────────────────────────────────────
+
+def _escada_chip(classe: rx.Var) -> rx.Component:
+ """Chip rouge avec X pour retirer une classe sélectionnée."""
+ return rx.flex(
+ rx.text(classe, size="1", color="white", font_weight="500"),
+ rx.icon(
+ "x",
+ size=12,
+ color="white",
+ cursor="pointer",
+ on_click=EscadaState.pick_class(classe).stop_propagation,
+ ),
+ align="center",
+ gap="0.25rem",
+ padding="0.15rem 0.4rem 0.15rem 0.6rem",
+ background_color="var(--red-9)",
+ border_radius="9999px",
+ flex_shrink="0",
+ )
+
+
+def _escada_option(classe: rx.Var) -> rx.Component:
+ """Ligne de la dropdown avec checkmark si la classe est sélectionnée."""
+ return rx.box(
+ rx.flex(
+ rx.cond(
+ EscadaState.class_checked[classe],
+ rx.icon("check", size=14, color="var(--red-9)"),
+ rx.box(width="14px", height="14px"),
+ ),
+ rx.text(classe, size="2"),
+ align="center",
+ gap="0.5rem",
+ ),
+ padding="0.45rem 0.75rem",
+ cursor="pointer",
+ on_click=EscadaState.pick_class(classe),
+ _hover={"background_color": "var(--gray-3)"},
+ width="100%",
+ )
+
+
+def _classe_multi_select_escada() -> rx.Component:
+ """Multi-select chips + dropdown style Streamlit."""
+ return rx.popover.root(
+ rx.popover.trigger(
+ rx.box(
+ rx.flex(
+ rx.cond(
+ EscadaState.selected_count == 0,
+ rx.text(
+ "Sélectionner une ou plusieurs classes…",
+ color="var(--gray-9)",
+ size="2",
+ ),
+ rx.foreach(EscadaState.selected_classes_list, _escada_chip),
+ ),
+ wrap="wrap",
+ gap="0.3rem",
+ flex="1",
+ min_height="28px",
+ align="center",
+ ),
+ rx.icon("chevron-down", size=18, color="var(--gray-9)"),
+ display="flex",
+ align_items="center",
+ gap="0.5rem",
+ padding="0.45rem 0.6rem",
+ border="2px solid var(--red-7)",
+ border_radius="6px",
+ background_color="white",
+ cursor="pointer",
+ width="100%",
+ max_width="640px",
+ ),
+ ),
+ rx.popover.content(
+ rx.vstack(
+ rx.box(
+ rx.text(
+ rx.cond(
+ EscadaState.all_selected,
+ "Tout désélectionner",
+ "Tout sélectionner",
+ ),
+ size="2",
+ font_weight="500",
+ color="var(--red-9)",
+ ),
+ on_click=EscadaState.toggle_all_classes,
+ padding="0.5rem 0.75rem",
+ cursor="pointer",
+ _hover={"background_color": "var(--gray-3)"},
+ width="100%",
+ ),
+ rx.divider(margin="0"),
+ rx.foreach(EscadaState.classes_cache, _escada_option),
+ spacing="0",
+ align="stretch",
+ width="100%",
+ ),
+ max_height="320px",
+ overflow_y="auto",
+ min_width="280px",
+ padding="0",
+ ),
+ )
+
+
+def _log_box() -> rx.Component:
+ return rx.cond(
+ EscadaState.op_log != "",
+ rx.box(
+ rx.text(
+ EscadaState.op_log,
+ size="1",
+ color="#37474f",
+ white_space="pre",
+ font_family="'Courier New', monospace",
+ ),
+ max_height="240px",
+ overflow_y="auto",
+ overflow_x="auto",
+ background_color="#f8f9fa",
+ border_radius="6px",
+ border="1px solid #dee2e6",
+ padding="0.75rem",
+ width="100%",
+ margin_top="0.75rem",
+ ),
+ )
+
+
+def _result_list(label: str, items, row_fn) -> rx.Component:
+ return rx.cond(
+ items.length() > 0,
+ rx.vstack(
+ rx.text(label, size="2", font_weight="700", color="#37474f"),
+ rx.foreach(items, row_fn),
+ spacing="1",
+ ),
+ )
+
+
+def _pending_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(item["periode"], text_align="center"),
+ rx.table.cell(
+ rx.badge(
+ item["action"],
+ color_scheme=rx.cond(
+ item["action"] == "E", "green",
+ rx.cond(item["action"] == "clear", "gray", "red"),
+ ),
+ variant="soft",
+ )
+ ),
+ )
+
+
+def _sync_progress() -> rx.Component:
+ """Indicateurs de progression — remplace l'ancien op_log dans la section sync."""
+ return rx.vstack(
+ # Phase 1 : scraping Escadaweb
+ rx.cond(
+ EscadaState.is_syncing,
+ rx.box(
+ rx.hstack(
+ rx.spinner(size="3"),
+ rx.vstack(
+ rx.text(
+ "Synchronisation Escadaweb en cours...",
+ size="3", font_weight="600", color="#1565c0",
+ ),
+ rx.text(
+ "Telechargement depuis escadaweb.vs.ch (1-3 min)",
+ size="2", color="#555",
+ ),
+ spacing="0",
+ ),
+ align="center",
+ spacing="3",
+ ),
+ rx.button(
+ rx.icon("x", size=13),
+ " Annuler",
+ on_click=EscadaState.reset_sync,
+ variant="outline",
+ color_scheme="gray",
+ size="1",
+ margin_top="0.75rem",
+ ),
+ padding="1rem",
+ background_color="#e3f2fd",
+ border_radius="8px",
+ border="1px solid #90caf9",
+ width="100%",
+ ),
+ ),
+
+ # Phase 2 : import en base
+ rx.cond(
+ EscadaState.import_in_progress,
+ rx.box(
+ rx.hstack(
+ rx.spinner(size="3"),
+ rx.vstack(
+ rx.text(
+ "Import des donnees en cours...",
+ size="3", font_weight="600", color="#e65100",
+ ),
+ rx.text(
+ "Insertion en base de donnees (~30s)",
+ size="2", color="#555",
+ ),
+ spacing="0",
+ ),
+ align="center",
+ spacing="3",
+ ),
+ padding="1rem",
+ background_color="#fff3e0",
+ border_radius="8px",
+ border="1px solid #ffb74d",
+ width="100%",
+ ),
+ ),
+
+ # Résultats
+ rx.cond(
+ EscadaState.sync_done,
+ rx.vstack(
+ rx.callout.root(
+ rx.callout.icon(rx.icon("check", size=16)),
+ rx.callout.text("Synchronisation et import termines."),
+ color_scheme="green",
+ variant="soft",
+ size="1",
+ ),
+ _result_list(
+ "Absences",
+ EscadaState.sync_res_abs,
+ lambda r: rx.text(
+ "✓ ", r["classe"], " — ", r["detail"],
+ size="2", color="#2e7d32",
+ ),
+ ),
+ _result_list(
+ "Bulletins de notes",
+ EscadaState.sync_res_bn,
+ lambda r: rx.text(
+ "✓ ", r["classe"], " — ", r["nb"], " apprenti(e)s",
+ size="2", color="#2e7d32",
+ ),
+ ),
+ _result_list(
+ "Notes d'examen",
+ EscadaState.sync_res_notes,
+ lambda r: rx.text(
+ "✓ ", r["classe"], " — ", r["nb"], " apprenti(e)s",
+ size="2", color="#2e7d32",
+ ),
+ ),
+ _result_list(
+ "Notes Matu",
+ EscadaState.sync_res_matu,
+ lambda r: rx.text(
+ "✓ ", r["classe"], " — ", r["nb"], " matches",
+ size="2", color="#2e7d32",
+ ),
+ ),
+ rx.cond(
+ EscadaState.sync_errors.length() > 0,
+ rx.vstack(
+ rx.text("Erreurs", size="2", font_weight="700", color="#c62828"),
+ rx.foreach(
+ EscadaState.sync_errors,
+ lambda e: rx.text("• ", e, size="2", color="#c62828"),
+ ),
+ spacing="1",
+ ),
+ ),
+ spacing="3",
+ width="100%",
+ ),
+ ),
+
+ # Erreur (sync échouée, pas de résultats)
+ rx.cond(
+ EscadaState.sync_errors.length() > 0,
+ rx.cond(
+ ~EscadaState.sync_done,
+ rx.cond(
+ ~EscadaState.is_syncing,
+ rx.cond(
+ ~EscadaState.import_in_progress,
+ rx.callout.root(
+ rx.callout.icon(rx.icon("alert-circle", size=16)),
+ rx.callout.text(
+ rx.foreach(
+ EscadaState.sync_errors,
+ lambda e: rx.text(e, size="2"),
+ ),
+ ),
+ color_scheme="red",
+ variant="soft",
+ size="1",
+ ),
+ ),
+ ),
+ ),
+ ),
+
+ spacing="3",
+ width="100%",
+ margin_top="0.75rem",
+ )
+
+
+# ── Page ──────────────────────────────────────────────────────────────────────
def escada_page() -> rx.Component:
return layout(
rx.vstack(
- rx.heading("Escada", size="7"),
- rx.text("Page en cours de migration..."),
+ rx.heading("Synchronisation Escada", size="7"),
+ rx.text(
+ "Telecharge absences, BN, notes et fiches depuis escadaweb.vs.ch "
+ "et les importe directement en base.",
+ size="2", color="#666",
+ ),
+
+ # ── Section sync depuis Escada ─────────────────────────────────────
+ rx.box(
+ rx.text(
+ "Synchronisation depuis Escada",
+ size="3", font_weight="700", color="#37474f",
+ margin_bottom="0.75rem",
+ ),
+
+ # Cache info + bouton refresh
+ rx.flex(
+ rx.cond(
+ EscadaState.has_classes,
+ rx.text(EscadaState.classes_cache.length(), " classe(s) en cache.", size="2", color="#666"),
+ rx.text("Aucun cache de classes.", size="2", color="#666"),
+ ),
+ rx.button(
+ rx.cond(
+ EscadaState.is_refreshing,
+ rx.spinner(size="1"),
+ rx.icon("refresh-cw", size=13),
+ ),
+ " Actualiser",
+ on_click=EscadaState.refresh_classes,
+ disabled=EscadaState.sync_disabled,
+ variant="outline",
+ color_scheme="gray",
+ size="1",
+ ),
+ justify="between",
+ align="center",
+ width="100%",
+ flex_wrap="wrap",
+ gap="0.5rem",
+ margin_bottom="0.75rem",
+ ),
+
+ rx.cond(
+ ~EscadaState.has_classes,
+ rx.box(
+ rx.text(
+ "Cliquez sur Actualiser pour recuperer la liste des classes depuis Escadaweb.",
+ size="2", color="#555",
+ ),
+ padding="0.75rem",
+ background_color="#e3f2fd",
+ border_radius="6px",
+ border="1px solid #90caf9",
+ ),
+
+ # ── Formulaire sync ────────────────────────────────────────
+ rx.vstack(
+ # Sélection des classes — multi-select style Streamlit
+ rx.text("Classes", size="2", font_weight="700", color="#37474f"),
+ _classe_multi_select_escada(),
+
+ # Options de sync
+ rx.text("Options", size="2", font_weight="700", color="#37474f"),
+ rx.flex(
+ rx.flex(
+ rx.checkbox(checked=EscadaState.sync_abs,
+ on_change=EscadaState.set_sync_abs, size="2"),
+ rx.text("Absences", size="2"),
+ gap="0.4rem", align="center",
+ ),
+ rx.flex(
+ rx.checkbox(checked=EscadaState.sync_bn,
+ on_change=EscadaState.set_sync_bn, size="2"),
+ rx.text("BN + Matu", size="2"),
+ gap="0.4rem", align="center",
+ ),
+ rx.flex(
+ rx.checkbox(checked=EscadaState.sync_notes,
+ on_change=EscadaState.set_sync_notes, size="2"),
+ rx.text("Notes", size="2"),
+ gap="0.4rem", align="center",
+ ),
+ rx.flex(
+ rx.checkbox(checked=EscadaState.sync_fiches,
+ on_change=EscadaState.set_sync_fiches, size="2"),
+ rx.text("Fiches", size="2"),
+ gap="0.4rem", align="center",
+ ),
+ gap="1rem",
+ flex_wrap="wrap",
+ ),
+
+ rx.cond(
+ EscadaState.sync_abs,
+ rx.flex(
+ rx.checkbox(
+ checked=EscadaState.force_abs,
+ on_change=EscadaState.set_force_abs,
+ size="2",
+ ),
+ rx.text(
+ "Forcer la reimportation des absences existantes",
+ size="2", color="#555",
+ ),
+ gap="0.4rem", align="center",
+ ),
+ ),
+
+ # Bouton Synchroniser
+ rx.button(
+ rx.cond(
+ EscadaState.is_syncing,
+ rx.spinner(size="2"),
+ rx.icon("refresh-cw", size=14),
+ ),
+ rx.cond(
+ EscadaState.is_syncing,
+ rx.text("Synchronisation en cours..."),
+ rx.text(
+ "Synchroniser ",
+ EscadaState.selected_count,
+ " classe(s)",
+ ),
+ ),
+ on_click=EscadaState.sync_escada,
+ disabled=EscadaState.sync_disabled,
+ color_scheme="blue",
+ size="2",
+ ),
+
+ spacing="3",
+ width="100%",
+ ),
+ ),
+
+ # Indicateurs de progression (phase 1 → phase 2 → résultats / erreur)
+ _sync_progress(),
+
+ # Log refresh (uniquement pour la commande Actualiser)
+ rx.cond(
+ EscadaState.is_refreshing,
+ _log_box(),
+ ),
+
+ padding="1.25rem",
+ background_color="white",
+ border_radius="8px",
+ border="1px solid #e0e0e0",
+ width="100%",
+ ),
+
+ # ── Section push vers Escada ───────────────────────────────────────
+ rx.box(
+ rx.text(
+ "Pousser vers Escada",
+ size="3", font_weight="700", color="#37474f",
+ margin_bottom="0.75rem",
+ ),
+
+ rx.cond(
+ EscadaState.pending_count == 0,
+ rx.text("Aucun changement en attente.", size="2", color="#666"),
+ rx.vstack(
+ rx.text(
+ EscadaState.pending_count,
+ " changement(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("P."),
+ rx.table.column_header_cell("Action"),
+ )
+ ),
+ rx.table.body(
+ rx.foreach(EscadaState.pending_data, _pending_row),
+ ),
+ width="100%",
+ size="1",
+ ),
+ overflow_x="auto",
+ width="100%",
+ ),
+ spacing="2",
+ width="100%",
+ margin_bottom="0.75rem",
+ ),
+ ),
+
+ rx.flex(
+ rx.flex(
+ rx.checkbox(
+ checked=EscadaState.push_test,
+ on_change=EscadaState.set_push_test,
+ size="2",
+ ),
+ rx.text("Mode test", size="2", color="#555"),
+ gap="0.4rem", align="center",
+ ),
+ rx.button(
+ rx.cond(
+ EscadaState.is_pushing,
+ rx.spinner(size="2"),
+ rx.icon("send", size=14),
+ ),
+ rx.cond(
+ EscadaState.is_pushing,
+ rx.text("Envoi en cours..."),
+ rx.text("Pousser vers Escada"),
+ ),
+ on_click=EscadaState.push_escada,
+ disabled=EscadaState.is_busy,
+ color_scheme="red",
+ size="2",
+ ),
+ gap="1rem",
+ align="center",
+ flex_wrap="wrap",
+ margin_top="0.75rem",
+ ),
+
+ rx.cond(
+ EscadaState.push_done,
+ rx.vstack(
+ rx.cond(
+ EscadaState.push_ok > 0,
+ rx.text(
+ EscadaState.push_ok,
+ " changement(s) envoye(s) avec succes.",
+ size="2", color="#2e7d32", font_weight="600",
+ ),
+ ),
+ rx.cond(
+ EscadaState.push_errors.length() > 0,
+ rx.vstack(
+ rx.foreach(
+ EscadaState.push_errors,
+ lambda e: rx.text("• ", e, size="2", color="#c62828"),
+ ),
+ spacing="1",
+ ),
+ ),
+ spacing="2",
+ margin_top="0.75rem",
+ width="100%",
+ ),
+ ),
+
+ # Log push
+ rx.cond(
+ ~EscadaState.is_pushing,
+ _log_box(),
+ ),
+
+ padding="1.25rem",
+ background_color="white",
+ border_radius="8px",
+ border="1px solid #e0e0e0",
+ width="100%",
+ ),
+
spacing="4",
+ width="100%",
)
)
diff --git a/eptm_dashboard/pages/export.py b/eptm_dashboard/pages/export.py
deleted file mode 100644
index 6deca2b..0000000
--- a/eptm_dashboard/pages/export.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import reflex as rx
-from ..sidebar import layout
-
-
-def export_page() -> rx.Component:
- return layout(
- rx.vstack(
- rx.heading("Export", size="7"),
- rx.text("Page en cours de migration..."),
- spacing="4",
- )
- )
diff --git a/eptm_dashboard/pages/fiche.py b/eptm_dashboard/pages/fiche.py
index 654deb4..6c352e8 100644
--- a/eptm_dashboard/pages/fiche.py
+++ b/eptm_dashboard/pages/fiche.py
@@ -1,12 +1,23 @@
+import base64
import calendar
+import io
+import json
+import os
import reflex as rx
-from datetime import date
+from datetime import date, timedelta
+from pathlib import Path
from sqlalchemy import select
+DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
+
from ..state import AuthState
from ..sidebar import layout
-from src.db import get_session, Apprenti, Absence, ApprentiFiche
+from src.db import (
+ get_session, Apprenti, Absence, ApprentiFiche,
+ NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu,
+)
from src.stats import nb_blocs_absences
+from src.parser_bn import sem_short_label
MOIS_FR = [
"janvier", "fevrier", "mars", "avril", "mai", "juin",
@@ -14,6 +25,334 @@ MOIS_FR = [
]
QUOTA = 5
+_GROUP_LABELS = {
+ "CG": "Culture Gen.",
+ "BP": "Branches Prof.",
+ "TP": "Trav. Pratiques",
+}
+_GROUP_ORDER = {"DUAL": ["CG", "BP"], "EM": ["BP", "TP"]}
+
+
+# ── HTML generators (pure Python, no Streamlit dependency) ───────────────────
+
+def _bn_fmt(v) -> str:
+ if v is None:
+ return ""
+ try:
+ return f"{float(v):.1f}".replace(".", ",")
+ except (TypeError, ValueError):
+ return ""
+
+
+def _bn_cell_style(v) -> str:
+ base = "border:1px solid #dee2e6;padding:5px 10px;text-align:center"
+ if v is None:
+ return f"{base};color:#bbb"
+ try:
+ if float(v) < 4.0:
+ return f"{base};background:#ffcccc;color:#B71C1C;font-weight:bold"
+ except (TypeError, ValueError):
+ pass
+ return base
+
+
+def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str:
+ N = 8
+ TD = "border:1px solid #dee2e6;padding:5px 10px"
+ TH = "border:1px solid #dee2e6;padding:5px 10px;text-align:center;background:#f8f9fa"
+ SEP = ";border-top:3px solid #9e9e9e"
+
+ header = f' | '
+ for i in range(N):
+ raw = sem_labels[i] if i < len(sem_labels) else None
+ short = sem_short_label(raw, i)
+ header += f'{short} | '
+
+ def _moy_sem_row(label, gd, label_style, sep=False):
+ s = SEP if sep else ""
+ cells = f'{label} | '
+ for i in range(N):
+ v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None
+ cells += f'{_bn_fmt(v)} | '
+ return f"{cells}
"
+
+ def _moy_ann_row(label, gd, label_style, sep=False):
+ s = SEP if sep else ""
+ cells = f'{label} | '
+ for year_start in range(0, N, 2):
+ v = gd["moy_ann"][year_start] if year_start < len(gd.get("moy_ann", [])) else None
+ cells += f'{_bn_fmt(v)} | '
+ return f"{cells}
"
+
+ body = ""
+ for grp in groups_order:
+ gd = d["groupes"].get(grp, {"moy_sem": [None] * N, "moy_ann": [None] * N})
+ lbl = _GROUP_LABELS.get(grp, grp)
+ body += _moy_sem_row(lbl, gd, f"{TD};font-weight:bold")
+ body += _moy_ann_row("Moyenne annuelle du groupe", gd, f"{TD};font-style:italic;color:#555")
+
+ body += _moy_sem_row("Moyenne semestrielle globale", d["globale"], f"{TD};font-style:italic", sep=True)
+ body += _moy_ann_row("Moyenne annuelle globale", d["globale"], f"{TD};font-weight:bold")
+
+ return (
+ f''
+ f'
'
+ f"{header}
"
+ f"{body}"
+ f"
"
+ )
+
+
+def _matu_html_table(nm) -> str:
+ TD = "border:1px solid #dee2e6;padding:5px 10px"
+ TDc = f"{TD};text-align:center"
+
+ def _cell_moy(v):
+ if v is None:
+ return f'— | '
+ style = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if v < 4.0 else TDc
+ return f'{_bn_fmt(v)} | '
+
+ def _cell_prom(p, info):
+ if not p:
+ return f'— | | '
+ red = p == "NB"
+ style = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if red else TDc
+ return (
+ f'{p} | '
+ f'{info or ""} | '
+ )
+
+ rows = (
+ f'| Moyenne du semestre | {_cell_moy(nm.moy)} |
'
+ f'| Promotion | {_cell_prom(nm.promotion, nm.prom_info)}
'
+ )
+ header_div = (
+ f'Matu — {nm.classe_mp} — {nm.sem_label}
'
+ )
+ return (
+ f'"
+ )
+
+
+def _render_notes_html(notes_data: list) -> str:
+ html = (
+ ""
+ )
+ for _br in notes_data:
+ _moy = _br.get("moy_arr")
+ _moy_prov = _br.get("moy_prov")
+ _insuf = _moy is not None and float(_moy) < 4.0
+ _mc = "#c62828" if _insuf else ("#e65100" if _moy and float(_moy) < 5.0 else "#2e7d32")
+ _br_name = ("⚠ " if _insuf else "") + _br["branche"]
+ _moy_html = (
+ f'
{_moy}'
+ + (f'
({_moy_prov})'
+ if _moy_prov is not None else "")
+ ) if _moy is not None else "—"
+ _insuf_cls = " insuf" if _insuf else ""
+ html += (
+ f'
'
+ f'{_br_name}'
+ f'Moyenne : {_moy_html}
'
+ "
| Date | Examen | Enseignant | "
+ "Coeff | Type | Note |
"
+ )
+ for _ex in _br.get("examens", []):
+ _n = _ex["note"]
+ if _n is None:
+ _note_html = '—'
+ elif _n == "disp.":
+ _note_html = 'disp.'
+ else:
+ _nc = "#c62828" if float(_n) < 4.0 else ("#e65100" if float(_n) < 5.0 else "#2e7d32")
+ _disp_tag = (
+ ' [disp.]'
+ if _ex.get("dispensed") else ""
+ )
+ _note_html = f'{_n}{_disp_tag}'
+ html += (
+ f'| {_ex["date"]} | '
+ f'{_ex["description"]} | '
+ f'{_ex["enseignant"]} | '
+ f'{_ex["coefficient"] or ""} | '
+ f'{_ex["type"]} | '
+ f'{_note_html} |
'
+ )
+ html += "
"
+ html += "
"
+ return html
+
+
+def _absence_pdf_apprenti(sess, apprenti) -> bytes:
+ from reportlab.lib import colors as _rl_colors
+ from reportlab.lib.pagesizes import A4, landscape
+ from reportlab.lib.styles import getSampleStyleSheet
+ from reportlab.lib.units import cm
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
+
+ absences = sess.execute(
+ select(Absence)
+ .where(Absence.apprenti_id == apprenti.id)
+ .order_by(Absence.date, Absence.periode)
+ ).scalars().all()
+
+ by_date: dict = {}
+ for ab in absences:
+ by_date.setdefault(ab.date, {})[ab.periode] = "E" if ab.statut == "excusee" else "N"
+
+ sorted_dates = sorted(by_date)
+ blocs: list = []
+ if sorted_dates:
+ dates_set = set(sorted_dates)
+ cur = [sorted_dates[0]]
+ for d in sorted_dates[1:]:
+ check = cur[-1] + timedelta(days=1)
+ gap_ok = True
+ while check < d:
+ if check.weekday() < 5 and check not in dates_set:
+ gap_ok = False
+ break
+ check += timedelta(days=1)
+ if gap_ok:
+ cur.append(d)
+ else:
+ blocs.append(cur)
+ cur = [d]
+ blocs.append(cur)
+
+ DARK = _rl_colors.HexColor("#37474F")
+ BLUE_BG = _rl_colors.HexColor("#E3F2FD")
+ BLUE_FG = _rl_colors.HexColor("#0D47A1")
+ RED_BG = _rl_colors.HexColor("#FFEBEE")
+ RED_FG = _rl_colors.HexColor("#B71C1C")
+ GREY_BG = _rl_colors.HexColor("#F5F5F5")
+ FOOT_BG = _rl_colors.HexColor("#ECEFF1")
+
+ data = [["Abs.", "Date"] + [f"P{i}" for i in range(1, 11)]]
+ styles_tbl = [
+ ("BACKGROUND", (0, 0), (-1, 0), DARK),
+ ("TEXTCOLOR", (0, 0), (-1, 0), _rl_colors.white),
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("GRID", (0, 0), (-1, -1), 0.5, _rl_colors.HexColor("#CCCCCC")),
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [_rl_colors.white, GREY_BG]),
+ ]
+
+ total_e = total_n = 0
+ row_idx = 1
+
+ for bloc_num, bloc_dates in enumerate(blocs, start=1):
+ first_row = row_idx
+ for i, d in enumerate(bloc_dates):
+ periods = by_date[d]
+ row = [str(bloc_num) if i == 0 else "", d.strftime("%d.%m.%Y")]
+ for p in range(1, 11):
+ val = periods.get(p, "")
+ row.append(val)
+ if val == "E":
+ total_e += 1
+ styles_tbl += [
+ ("BACKGROUND", (p + 1, row_idx), (p + 1, row_idx), BLUE_BG),
+ ("TEXTCOLOR", (p + 1, row_idx), (p + 1, row_idx), BLUE_FG),
+ ("FONTNAME", (p + 1, row_idx), (p + 1, row_idx), "Helvetica-Bold"),
+ ]
+ elif val == "N":
+ total_n += 1
+ styles_tbl += [
+ ("BACKGROUND", (p + 1, row_idx), (p + 1, row_idx), RED_BG),
+ ("TEXTCOLOR", (p + 1, row_idx), (p + 1, row_idx), RED_FG),
+ ("FONTNAME", (p + 1, row_idx), (p + 1, row_idx), "Helvetica-Bold"),
+ ]
+ data.append(row)
+ row_idx += 1
+ if row_idx - first_row > 1:
+ styles_tbl.append(("SPAN", (0, first_row), (0, row_idx - 1)))
+
+ total_row = row_idx
+ total_periodes = total_e + total_n
+ footer_label = (
+ f"{len(blocs)} absence(s) | "
+ f"{total_periodes} periode(s) | "
+ f"{total_e} excusee(s) | "
+ f"{total_n} non excusee(s)"
+ )
+ data.append([footer_label] + [""] * 11)
+ styles_tbl += [
+ ("SPAN", (0, total_row), (-1, total_row)),
+ ("BACKGROUND", (0, total_row), (-1, total_row), FOOT_BG),
+ ("FONTNAME", (0, total_row), (-1, total_row), "Helvetica-Bold"),
+ ("ALIGN", (0, total_row), (-1, total_row), "LEFT"),
+ ("LEFTPADDING", (0, total_row), (-1, total_row), 8),
+ ]
+
+ col_w = [1.5 * cm, 2.8 * cm] + [2.24 * cm] * 10
+ t = Table(data if len(data) > 1 else [data[0]], colWidths=col_w, repeatRows=1)
+ t.setStyle(TableStyle(styles_tbl))
+
+ buf = io.BytesIO()
+ doc = SimpleDocTemplate(
+ buf,
+ pagesize=landscape(A4),
+ leftMargin=1.5 * cm, rightMargin=1.5 * cm,
+ topMargin=1.5 * cm, bottomMargin=1.5 * cm,
+ )
+ styles = getSampleStyleSheet()
+ title = Paragraph(
+ f"Absences - {apprenti.nom} {apprenti.prenom}"
+ f" Classe : {apprenti.classe}",
+ styles["Normal"],
+ )
+ doc.build([title, Spacer(1, 0.5 * cm), t])
+ return buf.getvalue()
+
+
+def _extract_bn_pages(pdf_path, nom: str, prenom: str) -> bytes | None:
+ try:
+ import pdfplumber
+ from pypdf import PdfWriter, PdfReader
+ except ImportError:
+ return None
+ try:
+ pages_to_extract = []
+ with pdfplumber.open(str(pdf_path)) as pdf:
+ for i, page in enumerate(pdf.pages):
+ text = page.extract_text() or ""
+ if nom.upper() in text.upper() and prenom.upper() in text.upper():
+ pages_to_extract.append(i)
+ if not pages_to_extract:
+ return None
+ reader = PdfReader(str(pdf_path))
+ writer = PdfWriter()
+ for i in pages_to_extract:
+ writer.add_page(reader.pages[i])
+ buf = io.BytesIO()
+ writer.write(buf)
+ return buf.getvalue()
+ except Exception:
+ return None
+
+
+# ── State ─────────────────────────────────────────────────────────────────────
class FicheState(AuthState):
apprenti_labels: list[str] = []
@@ -51,6 +390,15 @@ class FicheState(AuthState):
fiche_formateur_email: str = ""
fiche_updated_at: str = ""
+ has_bn: bool = False
+ bn_html: str = ""
+ bn_caption: str = ""
+ has_notes: bool = False
+ notes_html: str = ""
+ bn_pdf_fichier: str = ""
+ has_pdf_bn: bool = False
+ has_pdf_notes: bool = False
+
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
@@ -105,6 +453,42 @@ class FicheState(AuthState):
self.cal_month += 1
self._rebuild_calendar()
+ def download_abs_pdf(self):
+ sess = get_session()
+ apprenti = sess.get(Apprenti, self.selected_id)
+ if apprenti is None:
+ return
+ pdf_bytes = _absence_pdf_apprenti(sess, apprenti)
+ filename = f"Absences_{apprenti.nom}_{apprenti.prenom}.pdf"
+ return rx.download(data=pdf_bytes, filename=filename)
+
+ def download_bn_pdf(self):
+ if not self.bn_pdf_fichier:
+ return
+ sess = get_session()
+ apprenti = sess.get(Apprenti, self.selected_id)
+ if apprenti is None:
+ return
+ pdf_path = DATA_DIR / "pdfs" / self.bn_pdf_fichier
+ pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom)
+ if pdf_bytes is None:
+ return
+ filename = f"BN_{apprenti.nom}_{apprenti.prenom}.pdf"
+ return rx.download(data=pdf_bytes, filename=filename)
+
+ def download_notes_pdf(self):
+ sess = get_session()
+ apprenti = sess.get(Apprenti, self.selected_id)
+ if apprenti is None:
+ return
+ notes_fname = f"notes_{apprenti.classe.replace(' ', '_')}.pdf"
+ pdf_path = DATA_DIR / "pdfs" / notes_fname
+ pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom)
+ if pdf_bytes is None:
+ return
+ filename = f"Notes_{apprenti.nom}_{apprenti.prenom}.pdf"
+ return rx.download(data=pdf_bytes, filename=filename)
+
def _reload(self):
sess = get_session()
absences = sess.execute(
@@ -115,16 +499,12 @@ class FicheState(AuthState):
self.kpi_total = len(absences)
self.kpi_excusees = sum(1 for a in absences if a.statut == "excusee")
- self.kpi_non_excusees = sum(
- 1 for a in absences if a.statut == "a_traiter"
- )
+ self.kpi_non_excusees = sum(1 for a in absences if a.statut == "a_traiter")
self.kpi_blocs = nb_blocs_absences(sess, self.selected_id)
self.quota_atteint = self.kpi_blocs >= QUOTA
fiche = sess.execute(
- select(ApprentiFiche).where(
- ApprentiFiche.apprenti_id == self.selected_id
- )
+ select(ApprentiFiche).where(ApprentiFiche.apprenti_id == self.selected_id)
).scalar_one_or_none()
if fiche:
self.fiche_available = True
@@ -135,10 +515,9 @@ class FicheState(AuthState):
self.fiche_telephone = fiche.telephone or ""
self.fiche_email_val = fiche.email or ""
self.fiche_date_naissance = fiche.date_naissance or ""
- if fiche.majeur is not None:
- self.fiche_majeur = "oui" if fiche.majeur else "non"
- else:
- self.fiche_majeur = ""
+ self.fiche_majeur = (
+ ("oui" if fiche.majeur else "non") if fiche.majeur is not None else ""
+ )
self.fiche_entreprise_nom = fiche.entreprise_nom or ""
self.fiche_entreprise_adresse = fiche.entreprise_adresse or ""
self.fiche_entreprise_cp_localite = (
@@ -150,26 +529,21 @@ class FicheState(AuthState):
self.fiche_formateur_nom = fiche.formateur_nom or ""
self.fiche_formateur_email = fiche.formateur_email or ""
self.fiche_updated_at = (
- fiche.updated_at.strftime("%d.%m.%Y %H:%M")
- if fiche.updated_at
- else ""
+ fiche.updated_at.strftime("%d.%m.%Y %H:%M") if fiche.updated_at else ""
)
else:
self.fiche_available = False
- self.fiche_adresse = ""
- self.fiche_cp_localite = ""
- self.fiche_telephone = ""
- self.fiche_email_val = ""
- self.fiche_date_naissance = ""
- self.fiche_majeur = ""
- self.fiche_entreprise_nom = ""
- self.fiche_entreprise_adresse = ""
- self.fiche_entreprise_cp_localite = ""
- self.fiche_entreprise_telephone = ""
- self.fiche_entreprise_email = ""
- self.fiche_formateur_nom = ""
- self.fiche_formateur_email = ""
- self.fiche_updated_at = ""
+ for attr in [
+ "fiche_adresse", "fiche_cp_localite", "fiche_telephone",
+ "fiche_email_val", "fiche_date_naissance", "fiche_majeur",
+ "fiche_entreprise_nom", "fiche_entreprise_adresse",
+ "fiche_entreprise_cp_localite", "fiche_entreprise_telephone",
+ "fiche_entreprise_email", "fiche_formateur_nom",
+ "fiche_formateur_email", "fiche_updated_at",
+ ]:
+ setattr(self, attr, "")
+
+ self._build_bn(sess)
if absences:
self.cal_year = absences[0].date.year
@@ -180,6 +554,65 @@ class FicheState(AuthState):
self.cal_month = today.month
self._build_calendar_from(absences)
+ def _build_bn(self, sess):
+ bn_records = sess.execute(
+ select(NotesBulletin, ImportBN)
+ .join(ImportBN, ImportBN.id == NotesBulletin.import_id)
+ .where(NotesBulletin.apprenti_id == self.selected_id)
+ .order_by(ImportBN.date_import.desc())
+ ).all()
+
+ if not bn_records:
+ self.has_bn = False
+ self.bn_html = ""
+ self.bn_caption = ""
+ self.bn_pdf_fichier = ""
+ else:
+ bn, imp = bn_records[0]
+ sem_labels = json.loads(bn.sem_labels_json)
+ d = json.loads(bn.donnees_json)
+ groups_order = _GROUP_ORDER.get(bn.type_classe, ["BP"])
+ html = _bn_html_table(d, sem_labels, groups_order)
+ nm = sess.execute(
+ select(NotesMatu)
+ .join(ImportMatu, ImportMatu.id == NotesMatu.import_id)
+ .where(NotesMatu.apprenti_id == self.selected_id)
+ .order_by(ImportMatu.date_import.desc())
+ .limit(1)
+ ).scalar_one_or_none()
+ if nm:
+ html += _matu_html_table(nm)
+ self.has_bn = True
+ self.bn_html = html
+ self.bn_caption = (
+ f"Import du {imp.date_import.strftime('%d.%m.%Y %H:%M')} — {imp.imported_by}"
+ )
+ self.bn_pdf_fichier = imp.fichier or ""
+
+ ne_rec = sess.execute(
+ select(NotesExamen).where(NotesExamen.apprenti_id == self.selected_id)
+ ).scalar_one_or_none()
+ if ne_rec:
+ notes_data = json.loads(ne_rec.donnees_json)
+ if notes_data:
+ self.has_notes = True
+ self.notes_html = _render_notes_html(notes_data)
+ else:
+ self.has_notes = False
+ self.notes_html = ""
+ else:
+ self.has_notes = False
+ self.notes_html = ""
+
+ pdf_dir = DATA_DIR / "pdfs"
+ self.has_pdf_bn = bool(self.bn_pdf_fichier) and (pdf_dir / self.bn_pdf_fichier).exists()
+ apprenti = sess.get(Apprenti, self.selected_id)
+ if apprenti:
+ notes_pdf_fname = f"notes_{apprenti.classe.replace(' ', '_')}.pdf"
+ self.has_pdf_notes = (pdf_dir / notes_pdf_fname).exists()
+ else:
+ self.has_pdf_notes = False
+
def _rebuild_calendar(self):
sess = get_session()
absences = sess.execute(
@@ -216,15 +649,9 @@ class FicheState(AuthState):
for day_num in week:
if day_num == 0:
days.append({
- "day": 0,
- "is_empty": True,
- "has_abs": False,
- "has_non_exc": False,
- "excusees": 0,
- "non_excusees": 0,
- "is_today": False,
- "date_str": "",
- "label": "",
+ "day": 0, "is_empty": True, "has_abs": False,
+ "has_non_exc": False, "excusees": 0, "non_excusees": 0,
+ "is_today": False, "date_str": "", "label": "",
})
else:
d = date(self.cal_year, self.cal_month, day_num)
@@ -233,7 +660,6 @@ class FicheState(AuthState):
has_abs = key in abs_by_date
exc = info.get("excusees", 0)
non = info.get("non_excusees", 0)
- is_today = d == today
has_non_exc = non > 0
if has_abs and has_non_exc:
lbl = str(day_num) + " !"
@@ -242,19 +668,15 @@ class FicheState(AuthState):
else:
lbl = str(day_num)
days.append({
- "day": day_num,
- "is_empty": False,
- "has_abs": has_abs,
- "has_non_exc": has_non_exc,
- "excusees": exc,
- "non_excusees": non,
- "is_today": is_today,
- "date_str": key,
- "label": lbl,
+ "day": day_num, "is_empty": False, "has_abs": has_abs,
+ "has_non_exc": has_non_exc, "excusees": exc, "non_excusees": non,
+ "is_today": d == today, "date_str": key, "label": lbl,
})
self.cal_days = days
+# ── UI components ─────────────────────────────────────────────────────────────
+
def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component:
return rx.box(
rx.text(label, size="1", color="#666"),
@@ -330,6 +752,8 @@ def fiche_page() -> rx.Component:
rx.cond(
FicheState.has_apprentis,
rx.vstack(
+
+ # ── Sélecteur apprenti ────────────────────────────────────
rx.select(
FicheState.apprenti_labels,
value=FicheState.selected_label,
@@ -337,6 +761,7 @@ def fiche_page() -> rx.Component:
width="100%",
),
+ # ── Alerte quota ──────────────────────────────────────────
rx.cond(
FicheState.quota_atteint,
rx.hstack(
@@ -345,36 +770,30 @@ def fiche_page() -> rx.Component:
"Avis de sanction — ",
FicheState.kpi_blocs,
" absences sur 5 autorisees",
- size="2",
- color="#c62828",
+ size="2", color="#c62828",
),
padding="0.75rem 1rem",
background_color="#ffebee",
border_radius="6px",
border="1px solid #ffcdd2",
- width="100%",
- spacing="2",
- align="center",
+ width="100%", spacing="2", align="center",
),
),
+ # ── KPI cards ─────────────────────────────────────────────
rx.flex(
_kpi_card("Total periodes", FicheState.kpi_total),
_kpi_card("Excusees", FicheState.kpi_excusees, "#2e7d32"),
_kpi_card("Non excusees", FicheState.kpi_non_excusees, "#c62828"),
_kpi_card("Absences (blocs)", FicheState.kpi_blocs),
- gap="1rem",
- flex_wrap="wrap",
- width="100%",
+ gap="1rem", flex_wrap="wrap", width="100%",
),
+ # ── Fiche détaillée Escada ────────────────────────────────
rx.box(
rx.text(
- "Fiche detaillee (Escada)",
- size="3",
- font_weight="700",
- color="#37474f",
- margin_bottom="0.75rem",
+ "Fiche detaillee (Escada)", size="3",
+ font_weight="700", color="#37474f", margin_bottom="0.75rem",
),
rx.cond(
FicheState.fiche_available,
@@ -388,10 +807,7 @@ def fiche_page() -> rx.Component:
_info_line("mail", FicheState.fiche_email_val),
_info_line("cake", FicheState.fiche_date_naissance),
_info_line("user-check", FicheState.fiche_majeur),
- spacing="1",
- align="start",
- flex="1",
- min_width="200px",
+ spacing="1", align="start", flex="1", min_width="200px",
),
rx.vstack(
rx.text("Entreprise", size="2", font_weight="700", color="#37474f"),
@@ -400,39 +816,25 @@ def fiche_page() -> rx.Component:
_info_line("map-pin", FicheState.fiche_entreprise_cp_localite),
_info_line("phone", FicheState.fiche_entreprise_telephone),
_info_line("mail", FicheState.fiche_entreprise_email),
- spacing="1",
- align="start",
- flex="1",
- min_width="200px",
+ spacing="1", align="start", flex="1", min_width="200px",
),
rx.vstack(
rx.text("Formateur", size="2", font_weight="700", color="#37474f"),
_info_line("user", FicheState.fiche_formateur_nom),
_info_line("mail", FicheState.fiche_formateur_email),
- spacing="1",
- align="start",
- flex="1",
- min_width="200px",
+ spacing="1", align="start", flex="1", min_width="200px",
),
- gap="1.5rem",
- flex_wrap="wrap",
- width="100%",
+ gap="1.5rem", flex_wrap="wrap", width="100%",
),
rx.text(
- "Mis a jour le ",
- FicheState.fiche_updated_at,
- " depuis Escada",
- size="1",
- color="#9e9e9e",
- margin_top="0.5rem",
+ "Mis a jour le ", FicheState.fiche_updated_at, " depuis Escada",
+ size="1", color="#9e9e9e", margin_top="0.5rem",
),
- spacing="3",
- width="100%",
+ spacing="3", width="100%",
),
rx.text(
- "Aucune fiche disponible. Lancez une synchronisation Escada avec l'option Fiches apprentis.",
- size="2",
- color="#666",
+ "Aucune fiche disponible. Lancez une synchronisation Escada.",
+ size="2", color="#666",
),
),
padding="1rem",
@@ -442,6 +844,93 @@ def fiche_page() -> rx.Component:
width="100%",
),
+ # ── Bulletin de notes ─────────────────────────────────────
+ rx.box(
+ rx.tabs.root(
+ rx.tabs.list(
+ rx.tabs.trigger("Cours professionnels", value="bn"),
+ rx.tabs.trigger("Notes d'examen", value="notes"),
+ ),
+ rx.tabs.content(
+ rx.cond(
+ FicheState.has_bn,
+ rx.vstack(
+ rx.text(
+ FicheState.bn_caption,
+ size="1", color="#9e9e9e",
+ ),
+ rx.html(FicheState.bn_html),
+ spacing="2", width="100%",
+ ),
+ rx.text(
+ "Aucun bulletin de notes importe pour cet(te) apprenti(e).",
+ size="2", color="#666",
+ ),
+ ),
+ value="bn",
+ width="100%",
+ padding_top="1rem",
+ ),
+ rx.tabs.content(
+ rx.cond(
+ FicheState.has_notes,
+ rx.html(FicheState.notes_html),
+ rx.text(
+ "Aucune note d'examen disponible. Lancez une synchronisation Escada avec l'option Notes.",
+ size="2", color="#666",
+ ),
+ ),
+ value="notes",
+ width="100%",
+ padding_top="1rem",
+ ),
+ default_value="bn",
+ width="100%",
+ ),
+ padding="1rem",
+ background_color="white",
+ border_radius="8px",
+ border="1px solid #e0e0e0",
+ width="100%",
+ ),
+
+ # ── Export PDF ────────────────────────────────────────────
+ rx.flex(
+ rx.button(
+ rx.icon("download", size=13),
+ "PDF absences",
+ on_click=FicheState.download_abs_pdf,
+ variant="outline",
+ color_scheme="gray",
+ size="1",
+ ),
+ rx.cond(
+ FicheState.has_pdf_bn,
+ rx.button(
+ rx.icon("file-text", size=13),
+ "PDF bulletin",
+ on_click=FicheState.download_bn_pdf,
+ variant="outline",
+ color_scheme="blue",
+ size="1",
+ ),
+ ),
+ rx.cond(
+ FicheState.has_pdf_notes,
+ rx.button(
+ rx.icon("file-text", size=13),
+ "PDF notes",
+ on_click=FicheState.download_notes_pdf,
+ variant="outline",
+ color_scheme="violet",
+ size="1",
+ ),
+ ),
+ flex_wrap="wrap",
+ gap="0.5rem",
+ ),
+
+ # ── Calendrier mensuel ────────────────────────────────────
rx.cond(
FicheState.kpi_total > 0,
rx.box(
@@ -450,70 +939,49 @@ def fiche_page() -> rx.Component:
rx.icon("chevron-left", size=14),
FicheState.cal_prev_name,
on_click=FicheState.prev_month,
- variant="outline",
- color_scheme="gray",
- size="2",
+ variant="outline", color_scheme="gray", size="2",
),
rx.text(
FicheState.cal_month_name,
- size="4",
- font_weight="700",
- color="#37474f",
- flex="1",
- text_align="center",
+ size="4", font_weight="700", color="#37474f",
+ flex="1", text_align="center",
),
rx.button(
FicheState.cal_next_name,
rx.icon("chevron-right", size=14),
on_click=FicheState.next_month,
- variant="outline",
- color_scheme="gray",
- size="2",
+ variant="outline", color_scheme="gray", size="2",
),
- width="100%",
- align="center",
- margin_bottom="0.5rem",
+ width="100%", align="center", margin_bottom="0.5rem",
),
rx.grid(
*[
rx.text(
- h,
- size="1",
- color="#9e9e9e",
- text_align="center",
- font_weight="600",
+ h, size="1", color="#9e9e9e",
+ text_align="center", font_weight="600",
)
for h in _DOW
],
- columns="7",
- gap="2px",
- width="100%",
- margin_bottom="2px",
+ columns="7", gap="2px", width="100%", margin_bottom="2px",
),
rx.grid(
rx.foreach(FicheState.cal_days, _cal_day_cell),
- columns="7",
- gap="2px",
- width="100%",
+ columns="7", gap="2px", width="100%",
),
rx.hstack(
rx.box(
width="12px", height="12px",
- background_color="#ffebee",
- border_radius="2px",
+ background_color="#ffebee", border_radius="2px",
border="1px solid #eee",
),
rx.text("Non excusee", size="1", color="#666"),
rx.box(
width="12px", height="12px",
- background_color="#e8f5e9",
- border_radius="2px",
+ background_color="#e8f5e9", border_radius="2px",
border="1px solid #eee",
),
rx.text("Excusee", size="1", color="#666"),
- spacing="2",
- align="center",
- margin_top="0.5rem",
+ spacing="2", align="center", margin_top="0.5rem",
),
padding="1rem",
background_color="white",
@@ -521,21 +989,15 @@ def fiche_page() -> rx.Component:
border="1px solid #e0e0e0",
width="100%",
),
- rx.text(
- "Aucune absence enregistree.",
- size="2",
- color="#666",
- ),
+ rx.text("Aucune absence enregistree.", size="2", color="#666"),
),
- spacing="4",
- width="100%",
+ spacing="4", width="100%",
),
rx.box(
rx.text(
"Aucun apprenti en base. Faites d'abord un import.",
- size="2",
- color="#666",
+ size="2", color="#666",
),
padding="1rem",
background_color="#e3f2fd",
@@ -545,7 +1007,6 @@ def fiche_page() -> rx.Component:
),
),
- spacing="5",
- width="100%",
+ spacing="5", width="100%",
)
)
diff --git a/eptm_dashboard/pages/import_page.py b/eptm_dashboard/pages/import_page.py
deleted file mode 100644
index d6d8e0b..0000000
--- a/eptm_dashboard/pages/import_page.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import reflex as rx
-from ..sidebar import layout
-
-
-def import_page_page() -> rx.Component:
- return layout(
- rx.vstack(
- rx.heading("Import", size="7"),
- rx.text("Page en cours de migration..."),
- spacing="4",
- )
- )
diff --git a/eptm_dashboard/pages/logs.py b/eptm_dashboard/pages/logs.py
index cdfe20a..e03f478 100644
--- a/eptm_dashboard/pages/logs.py
+++ b/eptm_dashboard/pages/logs.py
@@ -1,12 +1,229 @@
+import re
+import os
+from pathlib import Path
+
import reflex as rx
+
+from ..state import AuthState
from ..sidebar import layout
+_ROOT = Path(__file__).resolve().parent.parent.parent
+DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
+_LOG_FILE = DATA_DIR / "logs" / "operations.log"
+
+
+# ── State ──────────────────────────────────────────────────────────────────────
+
+class LogsState(AuthState):
+ log_level: str = "PROD"
+ log_content: str = ""
+ log_total: int = 0
+ log_shown: int = 0
+ log_empty: bool = True
+ confirm_clear: bool = False
+
+ def _read_log(self):
+ if not _LOG_FILE.exists() or _LOG_FILE.stat().st_size == 0:
+ self.log_empty = True
+ self.log_content = ""
+ self.log_total = 0
+ self.log_shown = 0
+ return
+ raw = _LOG_FILE.read_text(encoding="utf-8", errors="replace")
+ lines = raw.splitlines()
+ self.log_total = len(lines)
+ self.log_empty = len(lines) == 0
+ if self.log_level == "PROD":
+ filtered = [
+ ln for ln in lines
+ if re.match(r"^\[\d{2}:\d{2}:\d{2}\] [^ ]", ln) or not ln.strip()
+ ]
+ self.log_content = "\n".join(filtered)
+ self.log_shown = len(filtered)
+ else:
+ self.log_content = raw
+ self.log_shown = self.log_total
+
+ def load_data(self):
+ if not self.authenticated:
+ return rx.redirect("/login")
+ self._read_log()
+
+ def refresh(self):
+ self._read_log()
+
+ def set_log_level(self, level: str):
+ self.log_level = level
+ self._read_log()
+
+ def ask_clear(self):
+ self.confirm_clear = True
+
+ def cancel_clear(self):
+ self.confirm_clear = False
+
+ def clear_logs(self):
+ try:
+ _LOG_FILE.write_text("", encoding="utf-8")
+ except Exception:
+ pass
+ self.confirm_clear = False
+ self._read_log()
+
+ def download_logs(self):
+ if _LOG_FILE.exists():
+ raw = _LOG_FILE.read_bytes()
+ return rx.download(data=raw, filename="operations.log")
+
+
+# ── UI ─────────────────────────────────────────────────────────────────────────
+
+def _clear_zone() -> rx.Component:
+ return rx.cond(
+ LogsState.confirm_clear,
+ rx.hstack(
+ rx.text("Effacer tous les logs ?", size="2", color="red", weight="medium"),
+ rx.button(
+ "Confirmer",
+ on_click=LogsState.clear_logs,
+ size="1",
+ color_scheme="red",
+ ),
+ rx.button(
+ "Annuler",
+ on_click=LogsState.cancel_clear,
+ size="1",
+ color_scheme="gray",
+ variant="soft",
+ ),
+ gap="0.5rem",
+ align="center",
+ ),
+ rx.button(
+ rx.icon("trash-2", size=13),
+ "Effacer",
+ on_click=LogsState.ask_clear,
+ size="1",
+ color_scheme="red",
+ variant="soft",
+ disabled=LogsState.log_empty,
+ ),
+ )
+
+
+def _caption() -> rx.Component:
+ return rx.cond(
+ LogsState.log_level == "PROD",
+ rx.text(
+ LogsState.log_shown,
+ " ligne(s) affichée(s) / ",
+ LogsState.log_total,
+ " total — mode PROD (lignes de synthèse uniquement)",
+ size="1",
+ color="gray",
+ ),
+ rx.text(
+ LogsState.log_total,
+ " ligne(s) — mode DEBUG (tous les logs)",
+ size="1",
+ color="gray",
+ ),
+ )
+
+
+def _log_display() -> rx.Component:
+ return rx.cond(
+ LogsState.log_empty,
+ rx.callout.root(
+ rx.callout.icon(rx.icon("info")),
+ rx.callout.text("Aucun log disponible."),
+ color_scheme="gray",
+ variant="soft",
+ width="100%",
+ ),
+ rx.vstack(
+ _caption(),
+ rx.box(
+ rx.el.pre(
+ LogsState.log_content,
+ style={
+ "fontFamily": "'Courier New', Courier, monospace",
+ "fontSize": "0.72rem",
+ "whiteSpace": "pre-wrap",
+ "wordBreak": "break-all",
+ "color": "#abb2bf",
+ "margin": "0",
+ },
+ ),
+ background="#1e2228",
+ padding="1rem",
+ border_radius="6px",
+ overflow_y="auto",
+ max_height="70vh",
+ width="100%",
+ ),
+ width="100%",
+ gap="0.375rem",
+ align="start",
+ ),
+ )
+
def logs_page() -> rx.Component:
return layout(
rx.vstack(
- rx.heading("Logs", size="7"),
- rx.text("Page en cours de migration..."),
- spacing="4",
+ # ── En-tête ──────────────────────────────────────────────────────
+ rx.flex(
+ rx.heading("Logs", size="6"),
+ rx.flex(
+ rx.hstack(
+ rx.text("Niveau :", size="2", weight="medium", color="#555"),
+ rx.select(
+ ["PROD", "DEBUG"],
+ value=LogsState.log_level,
+ on_change=LogsState.set_log_level,
+ size="1",
+ ),
+ align="center",
+ gap="0.375rem",
+ ),
+ rx.button(
+ rx.icon("refresh-cw", size=13),
+ "Rafraîchir",
+ on_click=LogsState.refresh,
+ size="1",
+ color_scheme="gray",
+ variant="soft",
+ ),
+ rx.button(
+ rx.icon("download", size=13),
+ "Exporter",
+ on_click=LogsState.download_logs,
+ size="1",
+ color_scheme="blue",
+ variant="soft",
+ disabled=LogsState.log_empty,
+ ),
+ _clear_zone(),
+ gap="0.5rem",
+ align="center",
+ flex_wrap="wrap",
+ ),
+ justify="between",
+ align="center",
+ flex_wrap="wrap",
+ gap="0.75rem",
+ width="100%",
+ ),
+
+ rx.divider(),
+
+ # ── Contenu ──────────────────────────────────────────────────────
+ _log_display(),
+
+ width="100%",
+ align="start",
+ gap="0.75rem",
+ padding="1rem",
)
)
diff --git a/eptm_dashboard/pages/traiter.py b/eptm_dashboard/pages/traiter.py
deleted file mode 100644
index 3a51247..0000000
--- a/eptm_dashboard/pages/traiter.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import reflex as rx
-from ..sidebar import layout
-
-
-def traiter_page() -> rx.Component:
- return layout(
- rx.vstack(
- rx.heading("À traiter", size="7"),
- rx.text("Page en cours de migration..."),
- spacing="4",
- )
- )
diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py
index 0cbb5e7..57afd2b 100644
--- a/eptm_dashboard/sidebar.py
+++ b/eptm_dashboard/sidebar.py
@@ -17,15 +17,12 @@ _USER_BG = "#f3f4f6" # slightly darker user section
_PAGES = [
("Tableau de bord", "/accueil", "layout-dashboard"),
- ("A traiter", "/traiter", "triangle-alert"),
- ("Fiche apprenti", "/fiche", "user"),
- ("Vue classe", "/classe", "users"),
- ("Import", "/import", "upload"),
- ("Escada", "/escada", "globe"),
- ("Export", "/export", "download"),
+ ("Apprentis", "/fiche", "user"),
+ ("Classes", "/classe", "users"),
]
_ADMIN_PAGES = [
+ ("Escada", "/escada", "globe"),
("Logs", "/logs", "file-text"),
("Utilisateurs", "/users", "user-cog"),
("Parametres", "/params", "settings"),
diff --git a/eptm_dashboard/state.py b/eptm_dashboard/state.py
index f2351dd..8d61b28 100644
--- a/eptm_dashboard/state.py
+++ b/eptm_dashboard/state.py
@@ -8,11 +8,13 @@ DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
class AuthState(rx.State):
- authenticated: bool = False
- username: str = ""
- name: str = ""
- role: str = "user"
+ # Persisted in browser localStorage (survives hot reload / container restart).
+ # Note: client-side trustable only because re-validated against auth.yaml in check_auth.
+ username: str = rx.LocalStorage("", sync=True)
+ name: str = rx.LocalStorage("", sync=True)
+ role: str = rx.LocalStorage("user", sync=True)
+ # In-memory only (login form, transient UI state)
login_user: str = ""
login_pass: str = ""
login_error: str = ""
@@ -21,6 +23,10 @@ class AuthState(rx.State):
mobile_menu_open: bool = False
admin_expanded: bool = True
+ @rx.var
+ def authenticated(self) -> bool:
+ return self.username != ""
+
@rx.var
def name_initials(self) -> str:
if not self.name:
@@ -48,8 +54,21 @@ class AuthState(rx.State):
def set_login_pass(self, value: str):
self.login_pass = value
+ def index_redirect(self):
+ if self.authenticated:
+ return rx.redirect("/accueil")
+ return rx.redirect("/login")
+
+ def redirect_if_authenticated(self):
+ if self.authenticated:
+ return rx.redirect("/accueil")
+
def check_auth(self):
- if not self.authenticated:
+ if not self.username:
+ return rx.redirect("/login")
+ users = self._load_users()
+ if self.username not in users:
+ self._clear_session()
return rx.redirect("/login")
def handle_login(self, form_data: dict | None = None):
@@ -62,7 +81,6 @@ class AuthState(rx.State):
except Exception:
ok = False
if ok:
- self.authenticated = True
self.username = self.login_user
self.name = user.get("name", self.login_user)
self.role = user.get("role", "user")
@@ -72,9 +90,17 @@ class AuthState(rx.State):
self.login_pass = ""
def logout(self):
- self.reset()
+ self._clear_session()
return rx.redirect("/login")
+ def _clear_session(self):
+ self.username = ""
+ self.name = ""
+ self.role = "user"
+ self.login_user = ""
+ self.login_pass = ""
+ self.login_error = ""
+
@staticmethod
def _load_users() -> dict:
auth_file = DATA_DIR / "auth.yaml"
diff --git a/src/db.py b/src/db.py
index a58e404..b80980d 100644
--- a/src/db.py
+++ b/src/db.py
@@ -220,7 +220,15 @@ class SanctionExport(Base):
def get_engine(db_url: str | None = None):
url = db_url or f"sqlite:///{DB_PATH}"
- return create_engine(url, connect_args={"check_same_thread": False})
+ from sqlalchemy import event as _sa_event
+ engine = create_engine(url, connect_args={"check_same_thread": False})
+
+ @_sa_event.listens_for(engine, "connect")
+ def _set_wal(dbapi_conn, _rec):
+ dbapi_conn.execute("PRAGMA journal_mode=WAL")
+ dbapi_conn.execute("PRAGMA busy_timeout=10000")
+
+ return engine
def init_db(engine=None):