chore: untrack runtime cache from git

This commit is contained in:
Julien Balet 2026-05-09 23:27:17 +02:00
parent d468ec32c9
commit 0182188de5
15 changed files with 2943 additions and 219 deletions

View file

@ -2,15 +2,13 @@ FROM python:3.13
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y curl gnupg unzip && \ 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/*
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir pdfplumber sqlalchemy plotly pandas \ RUN pip install --no-cache-dir pdfplumber sqlalchemy plotly pandas openpyxl bcrypt pyyaml pypdf pyotp "qrcode[pil]" reportlab playwright
openpyxl bcrypt pyyaml pypdf pyotp "qrcode[pil]" reportlab playwright
RUN playwright install --with-deps chromium
COPY . . COPY . .

View file

@ -23,6 +23,14 @@ body, html {
height: 100vh; height: 100vh;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; 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) */ /* Mobile: hide desktop sidebar, account for fixed topbar (56px) */

View file

@ -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"
} }

View file

@ -3,19 +3,30 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
init: true
restart: "no" restart: "no"
ports: # Pas de ports exposés sur le host : accès uniquement via NPM (proxy_net)
- "3001:3001" # → http://eptm-automation.ch:3001 ne fonctionne plus, utiliser https://dev.dashboard.eptm-automation.ch
- "8001:8001"
volumes: volumes:
- ./eptm_dashboard:/app/eptm_dashboard - ./eptm_dashboard:/app/eptm_dashboard
- ./rxconfig.py:/app/rxconfig.py - ./rxconfig.py:/app/rxconfig.py
- ./data:/data - ./data:/app/data
- ./logs:/logs - ./logs:/logs
- ./assets:/app/assets - ./assets:/app/assets
- ./scripts:/app/scripts
- ./src:/app/src
env_file: env_file:
- .env.prod - .env.prod
environment: environment:
- FRONTEND_PORT=3001 - FRONTEND_PORT=3001
- BACKEND_PORT=8001 - 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

View file

@ -2,13 +2,10 @@ import reflex as rx
from .state import AuthState from .state import AuthState
from .pages.login import login_page from .pages.login import login_page
from .pages.accueil import accueil_page, AccueilState from .pages.accueil import accueil_page, AccueilState
from .pages.traiter import traiter_page
from .pages.fiche import fiche_page, FicheState from .pages.fiche import fiche_page, FicheState
from .pages.classe import classe_page from .pages.classe import classe_page, ClasseState
from .pages.import_page import import_page_page from .pages.escada import escada_page, EscadaState
from .pages.escada import escada_page from .pages.logs import logs_page, LogsState
from .pages.export import export_page
from .pages.logs import logs_page
from .pages.users import users_page from .pages.users import users_page
from .pages.params import params_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) def index_page() -> rx.Component:
app.add_page(traiter_page, route="/traiter", on_load=AuthState.check_auth, title=TITLE) return rx.center(rx.spinner(size="3"), height="100vh")
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(index_page, route="/", on_load=AuthState.index_redirect, title=TITLE)
app.add_page(escada_page, route="/escada", on_load=AuthState.check_auth, title=TITLE) app.add_page(login_page, route="/login", on_load=AuthState.redirect_if_authenticated, title=TITLE)
app.add_page(export_page, route="/export", on_load=AuthState.check_auth, title=TITLE) app.add_page(accueil_page, route="/accueil", on_load=[AuthState.check_auth, AccueilState.load_data], title=TITLE)
app.add_page(logs_page, route="/logs", on_load=AuthState.check_auth, title=TITLE) app.add_page(fiche_page, route="/fiche", on_load=[AuthState.check_auth, FicheState.load_data], title=TITLE)
app.add_page(users_page, route="/users", on_load=AuthState.check_auth, title=TITLE) app.add_page(classe_page, route="/classe", on_load=[AuthState.check_auth, ClasseState.load_data], title=TITLE)
app.add_page(params_page, route="/params", on_load=AuthState.check_auth, 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)

View file

@ -1,12 +1,744 @@
import io
import json
import os
import reflex as rx 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 ..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'<th style="{TH};text-align:left;min-width:230px"></th>'
for i in range(N):
raw = sem_labels[i] if i < len(sem_labels) else None
short = sem_short_label(raw, i)
header += f'<th style="{TH}">{short}</th>'
def _moy_sem_row(label, gd, label_style, sep=False):
s = SEP if sep else ""
cells = f'<td style="{label_style}{s}">{label}</td>'
for i in range(N):
v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None
cells += f'<td style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
def _moy_ann_row(label, gd, label_style, sep=False):
s = SEP if sep else ""
cells = f'<td style="{label_style}{s}">{label}</td>'
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'<td colspan="2" style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
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'<div style="overflow-x:auto;margin-bottom:16px">'
f'<table style="width:100%;border-collapse:collapse;font-size:0.875em">'
f"<thead><tr>{header}</tr></thead>"
f"<tbody>{body}</tbody>"
f"</table></div>"
)
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'<td style="{TDc};color:#bbb">&#8212;</td>'
style = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if v < 4.0 else TDc
return f'<td style="{style}">{_bn_fmt(v)}</td>'
def _cell_prom(p, info):
if not p:
return f'<td style="{TDc};color:#bbb">&#8212;</td><td style="{TD}"></td>'
red = p == "NB"
style = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if red else TDc
return (
f'<td style="{style}">{p}</td>'
f'<td style="{TD};color:#555;font-style:italic">{info or ""}</td>'
)
rows = (
f'<tr><td style="{TD}">Moyenne du semestre</td>{_cell_moy(nm.moy)}<td style="{TD}"></td></tr>'
f'<tr><td style="{TD}">Promotion</td>{_cell_prom(nm.promotion, nm.prom_info)}</tr>'
)
header_div = (
f'<div style="font-size:0.95em;font-weight:bold;padding:6px 12px;'
f'background:#f9fbe7;border-radius:4px 4px 0 0;border:1px solid #dee2e6;'
f'border-bottom:none">Matu &#8212; {nm.classe_mp} &#8212; {nm.sem_label}</div>'
)
return (
f'<div style="overflow-x:auto;margin-bottom:16px">{header_div}'
f'<table style="border-collapse:collapse;font-size:0.875em;border-top:none">'
f"<tbody>{rows}</tbody></table></div>"
)
def _render_notes_html(notes_data: list) -> str:
html = (
"<style>"
".nt table{border-collapse:collapse;width:100%;font-size:0.82rem;margin-bottom:2px}"
".nt th{background:#f0f2f6;padding:5px 10px;text-align:left;font-size:0.78rem;"
"color:#555;font-weight:600;letter-spacing:.02em}"
".nt td{padding:5px 10px;border-bottom:1px solid #f0f0f0;vertical-align:middle}"
".nt tr:last-child td{border-bottom:none}"
".nt .bh{display:flex;justify-content:space-between;align-items:center;"
"margin:18px 0 0;padding:7px 12px;border-radius:6px 6px 0 0;"
"background:#e8eaf6;border-left:4px solid #3949ab}"
".nt .bh.insuf{background:#ffebee;border-left:4px solid #c62828}"
".nt .bn{font-weight:700;font-size:0.9rem;color:#1a237e;letter-spacing:.01em}"
".nt .bh.insuf .bn{color:#b71c1c}"
".nt .moy{font-size:0.85rem;color:#555}"
"</style><div class='nt'>"
)
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 = ("&#9888; " if _insuf else "") + _br["branche"]
_moy_html = (
f'<span style="font-weight:700;color:{_mc}">{_moy}</span>'
+ (f'&nbsp;<span style="font-size:0.8em;color:#888">({_moy_prov})</span>'
if _moy_prov is not None else "")
) if _moy is not None else "&#8212;"
_insuf_cls = " insuf" if _insuf else ""
html += (
f'<div class="bh{_insuf_cls}">'
f'<span class="bn">{_br_name}</span>'
f'<span class="moy">Moyenne&nbsp;: {_moy_html}</span></div>'
"<table><tr><th>Date</th><th>Examen</th><th>Enseignant</th>"
"<th>Coeff</th><th>Type</th><th>Note</th></tr>"
)
for _ex in _br.get("examens", []):
_n = _ex["note"]
if _n is None:
_note_html = '<span style="color:#bbb">&#8212;</span>'
elif _n == "disp.":
_note_html = '<span style="color:#888;font-style:italic">disp.</span>'
else:
_nc = "#c62828" if float(_n) < 4.0 else ("#e65100" if float(_n) < 5.0 else "#2e7d32")
_disp_tag = (
' <span style="font-size:0.75em;color:#888">[disp.]</span>'
if _ex.get("dispensed") else ""
)
_note_html = f'<span style="font-weight:700;color:{_nc}">{_n}</span>{_disp_tag}'
html += (
f'<tr><td>{_ex["date"]}</td>'
f'<td>{_ex["description"]}</td>'
f'<td style="color:#555">{_ex["enseignant"]}</td>'
f'<td style="text-align:center">{_ex["coefficient"] or ""}</td>'
f'<td style="color:#777">{_ex["type"]}</td>'
f'<td style="text-align:center">{_note_html}</td></tr>'
)
html += "</table>"
html += "</div>"
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"<b>Absences - {apprenti.nom} {apprenti.prenom}</b>"
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: def classe_page() -> rx.Component:
return layout( return layout(
rx.vstack( rx.vstack(
rx.heading("Vue classe", size="7"), 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%",
) )
) )

File diff suppressed because it is too large Load diff

View file

@ -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",
)
)

View file

@ -1,12 +1,23 @@
import base64
import calendar import calendar
import io
import json
import os
import reflex as rx import reflex as rx
from datetime import date from datetime import date, timedelta
from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
from ..state import AuthState from ..state import AuthState
from ..sidebar import layout 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.stats import nb_blocs_absences
from src.parser_bn import sem_short_label
MOIS_FR = [ MOIS_FR = [
"janvier", "fevrier", "mars", "avril", "mai", "juin", "janvier", "fevrier", "mars", "avril", "mai", "juin",
@ -14,6 +25,334 @@ MOIS_FR = [
] ]
QUOTA = 5 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'<th style="{TH};text-align:left;min-width:230px"></th>'
for i in range(N):
raw = sem_labels[i] if i < len(sem_labels) else None
short = sem_short_label(raw, i)
header += f'<th style="{TH}">{short}</th>'
def _moy_sem_row(label, gd, label_style, sep=False):
s = SEP if sep else ""
cells = f'<td style="{label_style}{s}">{label}</td>'
for i in range(N):
v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None
cells += f'<td style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
def _moy_ann_row(label, gd, label_style, sep=False):
s = SEP if sep else ""
cells = f'<td style="{label_style}{s}">{label}</td>'
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'<td colspan="2" style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
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'<div style="overflow-x:auto;margin-bottom:16px">'
f'<table style="width:100%;border-collapse:collapse;font-size:0.875em">'
f"<thead><tr>{header}</tr></thead>"
f"<tbody>{body}</tbody>"
f"</table></div>"
)
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'<td style="{TDc};color:#bbb">&#8212;</td>'
style = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if v < 4.0 else TDc
return f'<td style="{style}">{_bn_fmt(v)}</td>'
def _cell_prom(p, info):
if not p:
return f'<td style="{TDc};color:#bbb">&#8212;</td><td style="{TD}"></td>'
red = p == "NB"
style = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if red else TDc
return (
f'<td style="{style}">{p}</td>'
f'<td style="{TD};color:#555;font-style:italic">{info or ""}</td>'
)
rows = (
f'<tr><td style="{TD}">Moyenne du semestre</td>{_cell_moy(nm.moy)}<td style="{TD}"></td></tr>'
f'<tr><td style="{TD}">Promotion</td>{_cell_prom(nm.promotion, nm.prom_info)}</tr>'
)
header_div = (
f'<div style="font-size:0.95em;font-weight:bold;padding:6px 12px;'
f'background:#f9fbe7;border-radius:4px 4px 0 0;border:1px solid #dee2e6;'
f'border-bottom:none">Matu &#8212; {nm.classe_mp} &#8212; {nm.sem_label}</div>'
)
return (
f'<div style="overflow-x:auto;margin-bottom:16px">{header_div}'
f'<table style="border-collapse:collapse;font-size:0.875em;border-top:none">'
f"<tbody>{rows}</tbody></table></div>"
)
def _render_notes_html(notes_data: list) -> str:
html = (
"<style>"
".nt table{border-collapse:collapse;width:100%;font-size:0.82rem;margin-bottom:2px}"
".nt th{background:#f0f2f6;padding:5px 10px;text-align:left;font-size:0.78rem;"
"color:#555;font-weight:600;letter-spacing:.02em}"
".nt td{padding:5px 10px;border-bottom:1px solid #f0f0f0;vertical-align:middle}"
".nt tr:last-child td{border-bottom:none}"
".nt .bh{display:flex;justify-content:space-between;align-items:center;"
"margin:18px 0 0;padding:7px 12px;border-radius:6px 6px 0 0;"
"background:#e8eaf6;border-left:4px solid #3949ab}"
".nt .bh.insuf{background:#ffebee;border-left:4px solid #c62828}"
".nt .bn{font-weight:700;font-size:0.9rem;color:#1a237e;letter-spacing:.01em}"
".nt .bh.insuf .bn{color:#b71c1c}"
".nt .moy{font-size:0.85rem;color:#555}"
"</style><div class='nt'>"
)
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 = ("&#9888; " if _insuf else "") + _br["branche"]
_moy_html = (
f'<span style="font-weight:700;color:{_mc}">{_moy}</span>'
+ (f'&nbsp;<span style="font-size:0.8em;color:#888">({_moy_prov})</span>'
if _moy_prov is not None else "")
) if _moy is not None else "&#8212;"
_insuf_cls = " insuf" if _insuf else ""
html += (
f'<div class="bh{_insuf_cls}">'
f'<span class="bn">{_br_name}</span>'
f'<span class="moy">Moyenne&nbsp;: {_moy_html}</span></div>'
"<table><tr><th>Date</th><th>Examen</th><th>Enseignant</th>"
"<th>Coeff</th><th>Type</th><th>Note</th></tr>"
)
for _ex in _br.get("examens", []):
_n = _ex["note"]
if _n is None:
_note_html = '<span style="color:#bbb">&#8212;</span>'
elif _n == "disp.":
_note_html = '<span style="color:#888;font-style:italic">disp.</span>'
else:
_nc = "#c62828" if float(_n) < 4.0 else ("#e65100" if float(_n) < 5.0 else "#2e7d32")
_disp_tag = (
' <span style="font-size:0.75em;color:#888">[disp.]</span>'
if _ex.get("dispensed") else ""
)
_note_html = f'<span style="font-weight:700;color:{_nc}">{_n}</span>{_disp_tag}'
html += (
f'<tr><td>{_ex["date"]}</td>'
f'<td>{_ex["description"]}</td>'
f'<td style="color:#555">{_ex["enseignant"]}</td>'
f'<td style="text-align:center">{_ex["coefficient"] or ""}</td>'
f'<td style="color:#777">{_ex["type"]}</td>'
f'<td style="text-align:center">{_note_html}</td></tr>'
)
html += "</table>"
html += "</div>"
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"<b>Absences - {apprenti.nom} {apprenti.prenom}</b>"
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): class FicheState(AuthState):
apprenti_labels: list[str] = [] apprenti_labels: list[str] = []
@ -51,6 +390,15 @@ class FicheState(AuthState):
fiche_formateur_email: str = "" fiche_formateur_email: str = ""
fiche_updated_at: 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): def load_data(self):
if not self.authenticated: if not self.authenticated:
return rx.redirect("/login") return rx.redirect("/login")
@ -105,6 +453,42 @@ class FicheState(AuthState):
self.cal_month += 1 self.cal_month += 1
self._rebuild_calendar() 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): def _reload(self):
sess = get_session() sess = get_session()
absences = sess.execute( absences = sess.execute(
@ -115,16 +499,12 @@ class FicheState(AuthState):
self.kpi_total = len(absences) self.kpi_total = len(absences)
self.kpi_excusees = sum(1 for a in absences if a.statut == "excusee") self.kpi_excusees = sum(1 for a in absences if a.statut == "excusee")
self.kpi_non_excusees = sum( self.kpi_non_excusees = sum(1 for a in absences if a.statut == "a_traiter")
1 for a in absences if a.statut == "a_traiter"
)
self.kpi_blocs = nb_blocs_absences(sess, self.selected_id) self.kpi_blocs = nb_blocs_absences(sess, self.selected_id)
self.quota_atteint = self.kpi_blocs >= QUOTA self.quota_atteint = self.kpi_blocs >= QUOTA
fiche = sess.execute( fiche = sess.execute(
select(ApprentiFiche).where( select(ApprentiFiche).where(ApprentiFiche.apprenti_id == self.selected_id)
ApprentiFiche.apprenti_id == self.selected_id
)
).scalar_one_or_none() ).scalar_one_or_none()
if fiche: if fiche:
self.fiche_available = True self.fiche_available = True
@ -135,10 +515,9 @@ class FicheState(AuthState):
self.fiche_telephone = fiche.telephone or "" self.fiche_telephone = fiche.telephone or ""
self.fiche_email_val = fiche.email or "" self.fiche_email_val = fiche.email or ""
self.fiche_date_naissance = fiche.date_naissance or "" self.fiche_date_naissance = fiche.date_naissance or ""
if fiche.majeur is not None: self.fiche_majeur = (
self.fiche_majeur = "oui" if fiche.majeur else "non" ("oui" if fiche.majeur else "non") if fiche.majeur is not None else ""
else: )
self.fiche_majeur = ""
self.fiche_entreprise_nom = fiche.entreprise_nom or "" self.fiche_entreprise_nom = fiche.entreprise_nom or ""
self.fiche_entreprise_adresse = fiche.entreprise_adresse or "" self.fiche_entreprise_adresse = fiche.entreprise_adresse or ""
self.fiche_entreprise_cp_localite = ( self.fiche_entreprise_cp_localite = (
@ -150,26 +529,21 @@ class FicheState(AuthState):
self.fiche_formateur_nom = fiche.formateur_nom or "" self.fiche_formateur_nom = fiche.formateur_nom or ""
self.fiche_formateur_email = fiche.formateur_email or "" self.fiche_formateur_email = fiche.formateur_email or ""
self.fiche_updated_at = ( self.fiche_updated_at = (
fiche.updated_at.strftime("%d.%m.%Y %H:%M") fiche.updated_at.strftime("%d.%m.%Y %H:%M") if fiche.updated_at else ""
if fiche.updated_at
else ""
) )
else: else:
self.fiche_available = False self.fiche_available = False
self.fiche_adresse = "" for attr in [
self.fiche_cp_localite = "" "fiche_adresse", "fiche_cp_localite", "fiche_telephone",
self.fiche_telephone = "" "fiche_email_val", "fiche_date_naissance", "fiche_majeur",
self.fiche_email_val = "" "fiche_entreprise_nom", "fiche_entreprise_adresse",
self.fiche_date_naissance = "" "fiche_entreprise_cp_localite", "fiche_entreprise_telephone",
self.fiche_majeur = "" "fiche_entreprise_email", "fiche_formateur_nom",
self.fiche_entreprise_nom = "" "fiche_formateur_email", "fiche_updated_at",
self.fiche_entreprise_adresse = "" ]:
self.fiche_entreprise_cp_localite = "" setattr(self, attr, "")
self.fiche_entreprise_telephone = ""
self.fiche_entreprise_email = "" self._build_bn(sess)
self.fiche_formateur_nom = ""
self.fiche_formateur_email = ""
self.fiche_updated_at = ""
if absences: if absences:
self.cal_year = absences[0].date.year self.cal_year = absences[0].date.year
@ -180,6 +554,65 @@ class FicheState(AuthState):
self.cal_month = today.month self.cal_month = today.month
self._build_calendar_from(absences) 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): def _rebuild_calendar(self):
sess = get_session() sess = get_session()
absences = sess.execute( absences = sess.execute(
@ -216,15 +649,9 @@ class FicheState(AuthState):
for day_num in week: for day_num in week:
if day_num == 0: if day_num == 0:
days.append({ days.append({
"day": 0, "day": 0, "is_empty": True, "has_abs": False,
"is_empty": True, "has_non_exc": False, "excusees": 0, "non_excusees": 0,
"has_abs": False, "is_today": False, "date_str": "", "label": "",
"has_non_exc": False,
"excusees": 0,
"non_excusees": 0,
"is_today": False,
"date_str": "",
"label": "",
}) })
else: else:
d = date(self.cal_year, self.cal_month, day_num) d = date(self.cal_year, self.cal_month, day_num)
@ -233,7 +660,6 @@ class FicheState(AuthState):
has_abs = key in abs_by_date has_abs = key in abs_by_date
exc = info.get("excusees", 0) exc = info.get("excusees", 0)
non = info.get("non_excusees", 0) non = info.get("non_excusees", 0)
is_today = d == today
has_non_exc = non > 0 has_non_exc = non > 0
if has_abs and has_non_exc: if has_abs and has_non_exc:
lbl = str(day_num) + " !" lbl = str(day_num) + " !"
@ -242,19 +668,15 @@ class FicheState(AuthState):
else: else:
lbl = str(day_num) lbl = str(day_num)
days.append({ days.append({
"day": day_num, "day": day_num, "is_empty": False, "has_abs": has_abs,
"is_empty": False, "has_non_exc": has_non_exc, "excusees": exc, "non_excusees": non,
"has_abs": has_abs, "is_today": d == today, "date_str": key, "label": lbl,
"has_non_exc": has_non_exc,
"excusees": exc,
"non_excusees": non,
"is_today": is_today,
"date_str": key,
"label": lbl,
}) })
self.cal_days = days self.cal_days = days
# ── UI components ─────────────────────────────────────────────────────────────
def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component: def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component:
return rx.box( return rx.box(
rx.text(label, size="1", color="#666"), rx.text(label, size="1", color="#666"),
@ -330,6 +752,8 @@ def fiche_page() -> rx.Component:
rx.cond( rx.cond(
FicheState.has_apprentis, FicheState.has_apprentis,
rx.vstack( rx.vstack(
# ── Sélecteur apprenti ────────────────────────────────────
rx.select( rx.select(
FicheState.apprenti_labels, FicheState.apprenti_labels,
value=FicheState.selected_label, value=FicheState.selected_label,
@ -337,6 +761,7 @@ def fiche_page() -> rx.Component:
width="100%", width="100%",
), ),
# ── Alerte quota ──────────────────────────────────────────
rx.cond( rx.cond(
FicheState.quota_atteint, FicheState.quota_atteint,
rx.hstack( rx.hstack(
@ -345,36 +770,30 @@ def fiche_page() -> rx.Component:
"Avis de sanction — ", "Avis de sanction — ",
FicheState.kpi_blocs, FicheState.kpi_blocs,
" absences sur 5 autorisees", " absences sur 5 autorisees",
size="2", size="2", color="#c62828",
color="#c62828",
), ),
padding="0.75rem 1rem", padding="0.75rem 1rem",
background_color="#ffebee", background_color="#ffebee",
border_radius="6px", border_radius="6px",
border="1px solid #ffcdd2", border="1px solid #ffcdd2",
width="100%", width="100%", spacing="2", align="center",
spacing="2",
align="center",
), ),
), ),
# ── KPI cards ─────────────────────────────────────────────
rx.flex( rx.flex(
_kpi_card("Total periodes", FicheState.kpi_total), _kpi_card("Total periodes", FicheState.kpi_total),
_kpi_card("Excusees", FicheState.kpi_excusees, "#2e7d32"), _kpi_card("Excusees", FicheState.kpi_excusees, "#2e7d32"),
_kpi_card("Non excusees", FicheState.kpi_non_excusees, "#c62828"), _kpi_card("Non excusees", FicheState.kpi_non_excusees, "#c62828"),
_kpi_card("Absences (blocs)", FicheState.kpi_blocs), _kpi_card("Absences (blocs)", FicheState.kpi_blocs),
gap="1rem", gap="1rem", flex_wrap="wrap", width="100%",
flex_wrap="wrap",
width="100%",
), ),
# ── Fiche détaillée Escada ────────────────────────────────
rx.box( rx.box(
rx.text( rx.text(
"Fiche detaillee (Escada)", "Fiche detaillee (Escada)", size="3",
size="3", font_weight="700", color="#37474f", margin_bottom="0.75rem",
font_weight="700",
color="#37474f",
margin_bottom="0.75rem",
), ),
rx.cond( rx.cond(
FicheState.fiche_available, FicheState.fiche_available,
@ -388,10 +807,7 @@ def fiche_page() -> rx.Component:
_info_line("mail", FicheState.fiche_email_val), _info_line("mail", FicheState.fiche_email_val),
_info_line("cake", FicheState.fiche_date_naissance), _info_line("cake", FicheState.fiche_date_naissance),
_info_line("user-check", FicheState.fiche_majeur), _info_line("user-check", FicheState.fiche_majeur),
spacing="1", spacing="1", align="start", flex="1", min_width="200px",
align="start",
flex="1",
min_width="200px",
), ),
rx.vstack( rx.vstack(
rx.text("Entreprise", size="2", font_weight="700", color="#37474f"), 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("map-pin", FicheState.fiche_entreprise_cp_localite),
_info_line("phone", FicheState.fiche_entreprise_telephone), _info_line("phone", FicheState.fiche_entreprise_telephone),
_info_line("mail", FicheState.fiche_entreprise_email), _info_line("mail", FicheState.fiche_entreprise_email),
spacing="1", spacing="1", align="start", flex="1", min_width="200px",
align="start",
flex="1",
min_width="200px",
), ),
rx.vstack( rx.vstack(
rx.text("Formateur", size="2", font_weight="700", color="#37474f"), rx.text("Formateur", size="2", font_weight="700", color="#37474f"),
_info_line("user", FicheState.fiche_formateur_nom), _info_line("user", FicheState.fiche_formateur_nom),
_info_line("mail", FicheState.fiche_formateur_email), _info_line("mail", FicheState.fiche_formateur_email),
spacing="1", spacing="1", align="start", flex="1", min_width="200px",
align="start",
flex="1",
min_width="200px",
), ),
gap="1.5rem", gap="1.5rem", flex_wrap="wrap", width="100%",
flex_wrap="wrap",
width="100%",
), ),
rx.text( rx.text(
"Mis a jour le ", "Mis a jour le ", FicheState.fiche_updated_at, " depuis Escada",
FicheState.fiche_updated_at, size="1", color="#9e9e9e", margin_top="0.5rem",
" depuis Escada",
size="1",
color="#9e9e9e",
margin_top="0.5rem",
), ),
spacing="3", spacing="3", width="100%",
width="100%",
), ),
rx.text( rx.text(
"Aucune fiche disponible. Lancez une synchronisation Escada avec l'option Fiches apprentis.", "Aucune fiche disponible. Lancez une synchronisation Escada.",
size="2", size="2", color="#666",
color="#666",
), ),
), ),
padding="1rem", padding="1rem",
@ -442,6 +844,93 @@ def fiche_page() -> rx.Component:
width="100%", 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( rx.cond(
FicheState.kpi_total > 0, FicheState.kpi_total > 0,
rx.box( rx.box(
@ -450,70 +939,49 @@ def fiche_page() -> rx.Component:
rx.icon("chevron-left", size=14), rx.icon("chevron-left", size=14),
FicheState.cal_prev_name, FicheState.cal_prev_name,
on_click=FicheState.prev_month, on_click=FicheState.prev_month,
variant="outline", variant="outline", color_scheme="gray", size="2",
color_scheme="gray",
size="2",
), ),
rx.text( rx.text(
FicheState.cal_month_name, FicheState.cal_month_name,
size="4", size="4", font_weight="700", color="#37474f",
font_weight="700", flex="1", text_align="center",
color="#37474f",
flex="1",
text_align="center",
), ),
rx.button( rx.button(
FicheState.cal_next_name, FicheState.cal_next_name,
rx.icon("chevron-right", size=14), rx.icon("chevron-right", size=14),
on_click=FicheState.next_month, on_click=FicheState.next_month,
variant="outline", variant="outline", color_scheme="gray", size="2",
color_scheme="gray",
size="2",
), ),
width="100%", width="100%", align="center", margin_bottom="0.5rem",
align="center",
margin_bottom="0.5rem",
), ),
rx.grid( rx.grid(
*[ *[
rx.text( rx.text(
h, h, size="1", color="#9e9e9e",
size="1", text_align="center", font_weight="600",
color="#9e9e9e",
text_align="center",
font_weight="600",
) )
for h in _DOW for h in _DOW
], ],
columns="7", columns="7", gap="2px", width="100%", margin_bottom="2px",
gap="2px",
width="100%",
margin_bottom="2px",
), ),
rx.grid( rx.grid(
rx.foreach(FicheState.cal_days, _cal_day_cell), rx.foreach(FicheState.cal_days, _cal_day_cell),
columns="7", columns="7", gap="2px", width="100%",
gap="2px",
width="100%",
), ),
rx.hstack( rx.hstack(
rx.box( rx.box(
width="12px", height="12px", width="12px", height="12px",
background_color="#ffebee", background_color="#ffebee", border_radius="2px",
border_radius="2px",
border="1px solid #eee", border="1px solid #eee",
), ),
rx.text("Non excusee", size="1", color="#666"), rx.text("Non excusee", size="1", color="#666"),
rx.box( rx.box(
width="12px", height="12px", width="12px", height="12px",
background_color="#e8f5e9", background_color="#e8f5e9", border_radius="2px",
border_radius="2px",
border="1px solid #eee", border="1px solid #eee",
), ),
rx.text("Excusee", size="1", color="#666"), rx.text("Excusee", size="1", color="#666"),
spacing="2", spacing="2", align="center", margin_top="0.5rem",
align="center",
margin_top="0.5rem",
), ),
padding="1rem", padding="1rem",
background_color="white", background_color="white",
@ -521,21 +989,15 @@ def fiche_page() -> rx.Component:
border="1px solid #e0e0e0", border="1px solid #e0e0e0",
width="100%", width="100%",
), ),
rx.text( rx.text("Aucune absence enregistree.", size="2", color="#666"),
"Aucune absence enregistree.",
size="2",
color="#666",
),
), ),
spacing="4", spacing="4", width="100%",
width="100%",
), ),
rx.box( rx.box(
rx.text( rx.text(
"Aucun apprenti en base. Faites d'abord un import.", "Aucun apprenti en base. Faites d'abord un import.",
size="2", size="2", color="#666",
color="#666",
), ),
padding="1rem", padding="1rem",
background_color="#e3f2fd", background_color="#e3f2fd",
@ -545,7 +1007,6 @@ def fiche_page() -> rx.Component:
), ),
), ),
spacing="5", spacing="5", width="100%",
width="100%",
) )
) )

View file

@ -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",
)
)

View file

@ -1,12 +1,229 @@
import re
import os
from pathlib import Path
import reflex as rx import reflex as rx
from ..state import AuthState
from ..sidebar import layout 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: def logs_page() -> rx.Component:
return layout( return layout(
rx.vstack( rx.vstack(
rx.heading("Logs", size="7"), # ── En-tête ──────────────────────────────────────────────────────
rx.text("Page en cours de migration..."), rx.flex(
spacing="4", 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",
) )
) )

View file

@ -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",
)
)

View file

@ -17,15 +17,12 @@ _USER_BG = "#f3f4f6" # slightly darker user section
_PAGES = [ _PAGES = [
("Tableau de bord", "/accueil", "layout-dashboard"), ("Tableau de bord", "/accueil", "layout-dashboard"),
("A traiter", "/traiter", "triangle-alert"), ("Apprentis", "/fiche", "user"),
("Fiche apprenti", "/fiche", "user"), ("Classes", "/classe", "users"),
("Vue classe", "/classe", "users"),
("Import", "/import", "upload"),
("Escada", "/escada", "globe"),
("Export", "/export", "download"),
] ]
_ADMIN_PAGES = [ _ADMIN_PAGES = [
("Escada", "/escada", "globe"),
("Logs", "/logs", "file-text"), ("Logs", "/logs", "file-text"),
("Utilisateurs", "/users", "user-cog"), ("Utilisateurs", "/users", "user-cog"),
("Parametres", "/params", "settings"), ("Parametres", "/params", "settings"),

View file

@ -8,11 +8,13 @@ DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
class AuthState(rx.State): class AuthState(rx.State):
authenticated: bool = False # Persisted in browser localStorage (survives hot reload / container restart).
username: str = "" # Note: client-side trustable only because re-validated against auth.yaml in check_auth.
name: str = "" username: str = rx.LocalStorage("", sync=True)
role: str = "user" 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_user: str = ""
login_pass: str = "" login_pass: str = ""
login_error: str = "" login_error: str = ""
@ -21,6 +23,10 @@ class AuthState(rx.State):
mobile_menu_open: bool = False mobile_menu_open: bool = False
admin_expanded: bool = True admin_expanded: bool = True
@rx.var
def authenticated(self) -> bool:
return self.username != ""
@rx.var @rx.var
def name_initials(self) -> str: def name_initials(self) -> str:
if not self.name: if not self.name:
@ -48,8 +54,21 @@ class AuthState(rx.State):
def set_login_pass(self, value: str): def set_login_pass(self, value: str):
self.login_pass = value 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): 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") return rx.redirect("/login")
def handle_login(self, form_data: dict | None = None): def handle_login(self, form_data: dict | None = None):
@ -62,7 +81,6 @@ class AuthState(rx.State):
except Exception: except Exception:
ok = False ok = False
if ok: if ok:
self.authenticated = True
self.username = self.login_user self.username = self.login_user
self.name = user.get("name", self.login_user) self.name = user.get("name", self.login_user)
self.role = user.get("role", "user") self.role = user.get("role", "user")
@ -72,9 +90,17 @@ class AuthState(rx.State):
self.login_pass = "" self.login_pass = ""
def logout(self): def logout(self):
self.reset() self._clear_session()
return rx.redirect("/login") 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 @staticmethod
def _load_users() -> dict: def _load_users() -> dict:
auth_file = DATA_DIR / "auth.yaml" auth_file = DATA_DIR / "auth.yaml"

View file

@ -220,7 +220,15 @@ class SanctionExport(Base):
def get_engine(db_url: str | None = None): def get_engine(db_url: str | None = None):
url = db_url or f"sqlite:///{DB_PATH}" 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): def init_db(engine=None):