chore: untrack runtime cache from git
This commit is contained in:
parent
d468ec32c9
commit
0182188de5
15 changed files with 2943 additions and 219 deletions
|
|
@ -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 . .
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) */
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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">—</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">—</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 — {nm.classe_mp} — {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 = ("⚠ " if _insuf else "") + _br["branche"]
|
||||||
|
_moy_html = (
|
||||||
|
f'<span style="font-weight:700;color:{_mc}">{_moy}</span>'
|
||||||
|
+ (f' <span style="font-size:0.8em;color:#888">({_moy_prov})</span>'
|
||||||
|
if _moy_prov is not None else "")
|
||||||
|
) if _moy is not None else "—"
|
||||||
|
_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 : {_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">—</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
|
|
@ -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",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
@ -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">—</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">—</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 — {nm.classe_mp} — {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 = ("⚠ " if _insuf else "") + _br["branche"]
|
||||||
|
_moy_html = (
|
||||||
|
f'<span style="font-weight:700;color:{_mc}">{_moy}</span>'
|
||||||
|
+ (f' <span style="font-size:0.8em;color:#888">({_moy_prov})</span>'
|
||||||
|
if _moy_prov is not None else "")
|
||||||
|
) if _moy is not None else "—"
|
||||||
|
_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 : {_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">—</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%",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
10
src/db.py
10
src/db.py
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue