From 0182188de5877c5599639e5cd6879641a9e95f95 Mon Sep 17 00:00:00 2001 From: Julien Balet Date: Sat, 9 May 2026 23:27:17 +0200 Subject: [PATCH] chore: untrack runtime cache from git --- Dockerfile.dev | 10 +- assets/responsive.css | 8 + data/class_href_cache.json | 4 +- docker-compose.dev.yml | 21 +- eptm_dashboard/eptm_dashboard.py | 34 +- eptm_dashboard/pages/classe.py | 736 ++++++++++++++- eptm_dashboard/pages/escada.py | 1308 ++++++++++++++++++++++++++- eptm_dashboard/pages/export.py | 12 - eptm_dashboard/pages/fiche.py | 723 ++++++++++++--- eptm_dashboard/pages/import_page.py | 12 - eptm_dashboard/pages/logs.py | 223 ++++- eptm_dashboard/pages/traiter.py | 12 - eptm_dashboard/sidebar.py | 9 +- eptm_dashboard/state.py | 40 +- src/db.py | 10 +- 15 files changed, 2943 insertions(+), 219 deletions(-) delete mode 100644 eptm_dashboard/pages/export.py delete mode 100644 eptm_dashboard/pages/import_page.py delete mode 100644 eptm_dashboard/pages/traiter.py 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'
{header_div}' + f'' + f"{rows}
" + ) + + +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}
' + "" + "" + ) + 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'' + f'' + f'' + f'' + f'' + f'' + ) + html += "
DateExamenEnseignantCoeffTypeNote
{_ex["date"]}{_ex["description"]}{_ex["enseignant"]}{_ex["coefficient"] or ""}{_ex["type"]}{_note_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'
{header_div}' + f'' + f"{rows}
" + ) + + +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}
' + "" + "" + ) + 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'' + f'' + f'' + f'' + f'' + f'' + ) + html += "
DateExamenEnseignantCoeffTypeNote
{_ex["date"]}{_ex["description"]}{_ex["enseignant"]}{_ex["coefficient"] or ""}{_ex["type"]}{_note_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):