tuiles sanction et webmanifest
This commit is contained in:
parent
f17041be18
commit
41c050d2d4
17 changed files with 1318 additions and 132 deletions
BIN
assets/apple-touch-icon.png
Normal file
BIN
assets/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/icon-192.png
Normal file
BIN
assets/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/icon-512.png
Normal file
BIN
assets/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
25
assets/manifest.webmanifest
Normal file
25
assets/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "EPTM Dashboard",
|
||||||
|
"short_name": "Dashboard",
|
||||||
|
"description": "Gestion des absences et notes — EPTM Sion / Monthey",
|
||||||
|
"start_url": "/accueil",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#f8f9fa",
|
||||||
|
"theme_color": "#dc000e",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,20 @@ body, html {
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Desktop: réserve la largeur de la sidebar (240px ou 68px collapsed) */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.content-area {
|
||||||
|
margin-left: 240px;
|
||||||
|
width: calc(100% - 240px);
|
||||||
|
max-width: calc(100% - 240px);
|
||||||
|
}
|
||||||
|
.content-area.sidebar-collapsed {
|
||||||
|
margin-left: 68px;
|
||||||
|
width: calc(100% - 68px);
|
||||||
|
max-width: calc(100% - 68px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Allow flex/grid descendants to shrink below their content size,
|
/* Allow flex/grid descendants to shrink below their content size,
|
||||||
* preventing horizontal overflow from long text or wide tables.
|
* preventing horizontal overflow from long text or wide tables.
|
||||||
* Inline `min-width` styles still win (higher specificity). */
|
* Inline `min-width` styles still win (higher specificity). */
|
||||||
|
|
@ -44,6 +58,12 @@ body, html {
|
||||||
padding-right: 0.75rem !important;
|
padding-right: 0.75rem !important;
|
||||||
padding-bottom: 0.75rem !important;
|
padding-bottom: 0.75rem !important;
|
||||||
}
|
}
|
||||||
|
/* Tuiles d'avis de sanction : plein écran sur mobile */
|
||||||
|
.sanction-tile {
|
||||||
|
max-width: 100% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
flex: 1 1 100% !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tablet */
|
/* Tablet */
|
||||||
|
|
@ -144,6 +164,43 @@ img {
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Logs viewer (page /logs) ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.log-content {
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.log-line {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
.log-line.log-blank { height: 0.4rem; }
|
||||||
|
.log-ts { color: #64748b; font-weight: 500; margin-right: 0.15rem; }
|
||||||
|
.log-indent { padding-left: 1rem; opacity: 0.85; }
|
||||||
|
|
||||||
|
.log-info { color: #cbd5e1; }
|
||||||
|
.log-debug { color: #94a3b8; opacity: 0.85; }
|
||||||
|
.log-success { color: #4ade80; }
|
||||||
|
.log-warn { color: #fbbf24; }
|
||||||
|
.log-error { color: #f87171; font-weight: 500; }
|
||||||
|
|
||||||
|
.log-prefix {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.92em;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
.log-prefix.prefix-default { background: #334155; color: #cbd5e1; }
|
||||||
|
.log-prefix.prefix-abs { background: rgba(96, 165, 250, 0.16); color: #93c5fd; }
|
||||||
|
.log-prefix.prefix-sync { background: rgba(6, 182, 212, 0.16); color: #67e8f9; }
|
||||||
|
.log-prefix.prefix-push { background: rgba(192, 132, 252, 0.16); color: #d8b4fe; }
|
||||||
|
.log-prefix.prefix-cron { background: rgba(250, 204, 21, 0.16); color: #fde047; }
|
||||||
|
|
||||||
/* ── Documentation rendered markdown ───────────────────────────────────── */
|
/* ── Documentation rendered markdown ───────────────────────────────────── */
|
||||||
|
|
||||||
.doc-content { line-height: 1.65; color: #1f2937; }
|
.doc-content { line-height: 1.65; color: #1f2937; }
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,4 @@ credentials:
|
||||||
name: test
|
name: test
|
||||||
password: $2b$12$nYZqG/bStQwweDjvR/8RNOqP6AnUDh1Dictx3BCZ2RalIyWDbre42
|
password: $2b$12$nYZqG/bStQwweDjvR/8RNOqP6AnUDh1Dictx3BCZ2RalIyWDbre42
|
||||||
role: user
|
role: user
|
||||||
totp_secret: null
|
totp_secret: TCH5IQCRIAVPZEFFUABEVXUCV7TOL5XP
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,46 @@
|
||||||
{
|
{
|
||||||
"EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=30066257-3dad-4b00-857b-9ab60a5d8581",
|
"CFTI-AU 1A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4ec9bbbd-7d12-4073-9fd3-ac275dd0894e",
|
||||||
"EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b9363b96-2d6e-4009-a495-f26c036cc088",
|
"CFTI-AU 1B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=e960b23a-088d-4b57-9a09-3955c899b264",
|
||||||
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b51ec970-5bf4-4982-a05e-80546bb7421f",
|
"CFTI-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=e62f261c-736f-4d71-9392-cd42b36088b2",
|
||||||
"MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=aaacc343-c248-4f21-93f6-5d9e3079aa5d",
|
"EM-AU 1A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=545536ad-71b5-45bd-81c9-408b4a75d6aa",
|
||||||
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=18c1ddbe-471f-44f6-bde6-8619adc3b767",
|
"EM-AU 1B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=19c5ad0e-db24-437d-8976-b998f13da902",
|
||||||
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a4c4c187-920c-4c91-9620-7f153cf3738a",
|
"EM-AU 2A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=e8a84837-ea42-4872-bbd1-362d0eb10775",
|
||||||
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=901ba9c8-5bb8-4170-a28e-ad1bdc8dccac",
|
"EM-AU 2B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=047a36ce-b8e1-40ae-9ca1-358edfee933c",
|
||||||
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=553e320e-a5e6-484f-bbf3-989301a15449",
|
"EM-AU 3A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=eef4f04b-7f26-4a4b-87d5-3129a22b4f15",
|
||||||
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=dd16c8ed-fde7-4aa6-bbce-9cba960b2863",
|
"EM-AU 3B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4f51056e-ec72-4101-b6de-a2b4246632fb",
|
||||||
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=dcae994d-7c4e-4843-aa1c-0d44929b277c",
|
"MI-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=fb412b92-9458-4ca9-8c76-718889c0bd23",
|
||||||
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=77f8b395-5054-4414-97d1-96d9d1cba981",
|
"MI-AU CG 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=314dcc1e-f4e3-43da-aa7d-f817c3db80be",
|
||||||
"CFTI-AU 1A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=1e858971-1c2f-4d8a-9a41-63c66716ee45"
|
"MI-AU CG 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=435f31f9-4f3a-4755-825a-55df5ff8a571",
|
||||||
|
"MP1-TASV 1A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a15d17d9-2a6d-4872-9b78-8b51bbf7215a",
|
||||||
|
"MP1-TASV 1B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3192aa8f-6137-4187-811a-df247c4f3f14",
|
||||||
|
"MP1-TASV 1C:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=0242982b-45ae-4059-9cdb-6eda123f60e4",
|
||||||
|
"MP1-TASV 1D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7404bc4d-e559-4961-83f3-28d90dcb2112",
|
||||||
|
"MP1-TASV 1E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=94f976f1-1d81-4097-9072-b8601c058cde",
|
||||||
|
"MP1-TASV 2A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=df9859d8-5c04-4600-b73f-22b8a8e06992",
|
||||||
|
"MP1-TASV 2B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7f11d6d5-bef6-4d5d-a039-a0bb49b85de4",
|
||||||
|
"MP1-TASV 2C:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=82525d35-26a9-4320-9551-37b4dd0ed479",
|
||||||
|
"MP1-TASV 2D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=21be0157-f248-47af-ab95-027995a0269e",
|
||||||
|
"MP1-TASV 2E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=21e967af-520f-46d2-874f-5dbd61ef24b1",
|
||||||
|
"MP1-TASV 3A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=80d5e7d6-ff54-4ce8-8b32-677b0fdf9a74",
|
||||||
|
"MP1-TASV 3B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=84de9bd4-949c-4472-97e1-0508a3034c6f",
|
||||||
|
"MP1-TASV 3C:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f1b20579-73dc-499a-a4cd-5709fcfc56ab",
|
||||||
|
"MP1-TASV 3D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=88e89d2b-e754-4501-9ba5-c56e1c14818e",
|
||||||
|
"MP1-TASV 3E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=14627c7d-1da2-45ce-b930-1c2de3af25bb",
|
||||||
|
"MP1-TASV 4A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f5fa281e-6a7b-4eed-b433-6e92f1ea61fc",
|
||||||
|
"MP1-TASV 4B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=62596263-cbb3-442a-a390-9279cceed4df",
|
||||||
|
"MP1-TASV 4C:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=1fdeb3aa-4f39-4382-ae98-1784c99a7f51",
|
||||||
|
"MP1-TASV 4D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3bc53c77-bda8-43a4-ab47-08c2eb84a917",
|
||||||
|
"MP1-TASV 4E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7673335b-a2aa-42de-9cab-6b95ffc90d7c",
|
||||||
|
"Z-IT Test 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f0707cc3-4511-403b-a2a6-d79f2da4fd99",
|
||||||
|
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=0d1cdaa2-83f0-49b7-90d1-9bb10e4b23b9",
|
||||||
|
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f6c1e52c-27b8-4e77-a4bf-13ddf31fb139",
|
||||||
|
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=0c361e5b-b97b-4916-9e2b-fbc476a44e9a",
|
||||||
|
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=c0548ed6-df7c-4455-a6f5-155b86ec3418",
|
||||||
|
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=1d860fc3-3128-4328-9670-29589fdc63e8",
|
||||||
|
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3dae8968-e44f-461b-8fb0-e918895dc4b7",
|
||||||
|
"EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=33b92c8d-1e93-47cf-b954-4596b2b9b59e",
|
||||||
|
"EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f613a72a-2d22-4093-bb29-f6a1d639466e",
|
||||||
|
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4f15ebd5-6f2f-40e5-9942-24ce8750553e",
|
||||||
|
"MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4aa19e25-fcf7-4142-8d1d-95ac0d8d1f30",
|
||||||
|
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=585c5f01-c8e2-4dd9-91a0-057080a350a9"
|
||||||
}
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ from .pages.logs import logs_page, LogsState
|
||||||
from .pages.cron import cron_page, CronState
|
from .pages.cron import cron_page, CronState
|
||||||
from .pages.users import users_page, UsersState
|
from .pages.users import users_page, UsersState
|
||||||
from .pages.params import params_page, ParamsState
|
from .pages.params import params_page, ParamsState
|
||||||
|
from .pages.purge import purge_page, PurgeState
|
||||||
from .pages.doc import doc_page, DocState
|
from .pages.doc import doc_page, DocState
|
||||||
|
|
||||||
TITLE = "EPTM Dashboard"
|
TITLE = "EPTM Dashboard"
|
||||||
|
|
@ -17,6 +18,15 @@ app = rx.App(
|
||||||
stylesheets=["/responsive.css"],
|
stylesheets=["/responsive.css"],
|
||||||
head_components=[
|
head_components=[
|
||||||
rx.el.link(rel="icon", type="image/png", href="/favicon.png"),
|
rx.el.link(rel="icon", type="image/png", href="/favicon.png"),
|
||||||
|
# iOS Safari : icône utilisée pour "Sur l'écran d'accueil"
|
||||||
|
rx.el.link(rel="apple-touch-icon", href="/apple-touch-icon.png"),
|
||||||
|
# Android Chrome / PWA : manifest avec icônes 192/512
|
||||||
|
rx.el.link(rel="manifest", href="/manifest.webmanifest"),
|
||||||
|
# Couleur de la barre d'adresse (Android) + barre de statut (iOS standalone)
|
||||||
|
rx.el.meta(name="theme-color", content="#dc000e"),
|
||||||
|
rx.el.meta(name="apple-mobile-web-app-capable", content="yes"),
|
||||||
|
rx.el.meta(name="apple-mobile-web-app-status-bar-style", content="default"),
|
||||||
|
rx.el.meta(name="apple-mobile-web-app-title", content="EPTM"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -35,4 +45,5 @@ app.add_page(logs_page, route="/logs", on_load=[AuthState.check_auth,
|
||||||
app.add_page(cron_page, route="/cron", on_load=[AuthState.check_auth, CronState.load_data], title=TITLE)
|
app.add_page(cron_page, route="/cron", on_load=[AuthState.check_auth, CronState.load_data], title=TITLE)
|
||||||
app.add_page(users_page, route="/users", on_load=[AuthState.check_auth, UsersState.load_data], title=TITLE)
|
app.add_page(users_page, route="/users", on_load=[AuthState.check_auth, UsersState.load_data], title=TITLE)
|
||||||
app.add_page(params_page, route="/params", on_load=[AuthState.check_auth, ParamsState.load_data], title=TITLE)
|
app.add_page(params_page, route="/params", on_load=[AuthState.check_auth, ParamsState.load_data], title=TITLE)
|
||||||
|
app.add_page(purge_page, route="/purge", on_load=[AuthState.check_auth, PurgeState.load_data], title=TITLE)
|
||||||
app.add_page(doc_page, route="/doc", on_load=[AuthState.check_auth, DocState.load_data], title=TITLE)
|
app.add_page(doc_page, route="/doc", on_load=[AuthState.check_auth, DocState.load_data], title=TITLE)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,28 @@
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
sys.path.insert(0, "/opt/eptm-dashboard")
|
sys.path.insert(0, "/opt/eptm-dashboard")
|
||||||
|
|
||||||
import reflex as rx
|
import reflex as rx
|
||||||
from src.db import get_session
|
from src.db import get_session
|
||||||
from src.stats import kpis, alertes_quota_absences
|
from src.stats import kpis, alertes_quota_absences
|
||||||
|
from src.sanction_pdf import generate_avis_pdf
|
||||||
|
from src.logger import app_log
|
||||||
from ..state import AuthState
|
from ..state import AuthState
|
||||||
from ..sidebar import layout
|
from ..sidebar import layout
|
||||||
|
from .fiche import FicheState
|
||||||
|
from .classe import ClasseState
|
||||||
|
|
||||||
|
|
||||||
class AccueilState(AuthState):
|
class AccueilState(AuthState):
|
||||||
kpi_mois: int = 0
|
kpi_mois: int = 0
|
||||||
kpi_total: int = 0
|
kpi_total: int = 0
|
||||||
kpi_traiter: int = 0
|
kpi_traiter: int = 0
|
||||||
sanctions: list[dict] = []
|
# Liste à plat (compteur global)
|
||||||
|
sanctions_total: int = 0
|
||||||
|
classes_total: int = 0
|
||||||
|
# Groupement par classe pour l'affichage en tuiles
|
||||||
|
sanctions_groups: list[dict] = []
|
||||||
|
|
||||||
def load_data(self):
|
def load_data(self):
|
||||||
if not self.authenticated:
|
if not self.authenticated:
|
||||||
|
|
@ -27,7 +36,7 @@ class AccueilState(AuthState):
|
||||||
self.kpi_traiter = k["n_a_traiter"]
|
self.kpi_traiter = k["n_a_traiter"]
|
||||||
|
|
||||||
df = alertes_quota_absences(sess, seuil=5)
|
df = alertes_quota_absences(sess, seuil=5)
|
||||||
self.sanctions = [
|
items = [
|
||||||
{
|
{
|
||||||
"id": int(row["_id"]),
|
"id": int(row["_id"]),
|
||||||
"nom": str(row["Nom"]),
|
"nom": str(row["Nom"]),
|
||||||
|
|
@ -37,11 +46,65 @@ class AccueilState(AuthState):
|
||||||
}
|
}
|
||||||
for _, row in df.iterrows()
|
for _, row in df.iterrows()
|
||||||
]
|
]
|
||||||
|
# Groupement par classe (tri alphabétique des classes,
|
||||||
|
# puis par nom dans chaque classe).
|
||||||
|
grouped: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
for it in items:
|
||||||
|
grouped[it["classe"]].append(it)
|
||||||
|
self.sanctions_groups = [
|
||||||
|
{
|
||||||
|
"classe": c,
|
||||||
|
"count": len(grouped[c]),
|
||||||
|
"items": sorted(grouped[c], key=lambda x: (x["nom"], x["prenom"])),
|
||||||
|
}
|
||||||
|
for c in sorted(grouped.keys())
|
||||||
|
]
|
||||||
|
self.sanctions_total = len(items)
|
||||||
|
self.classes_total = len(self.sanctions_groups)
|
||||||
finally:
|
finally:
|
||||||
sess.close()
|
sess.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[accueil] erreur: {e}")
|
print(f"[accueil] erreur: {e}")
|
||||||
|
|
||||||
|
# ── Navigation cross-page (pré-sélection) ────────────────────────────────
|
||||||
|
|
||||||
|
def open_fiche(self, apprenti_id: int):
|
||||||
|
return [
|
||||||
|
FicheState.navigate_to(apprenti_id),
|
||||||
|
rx.redirect("/fiche"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def open_classe(self, classe: str):
|
||||||
|
return [
|
||||||
|
ClasseState.set_class(classe),
|
||||||
|
rx.redirect("/classe"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Téléchargement de l'avis de sanction ─────────────────────────────────
|
||||||
|
|
||||||
|
def download_avis(self, apprenti_id: int, nom: str, prenom: str, classe: str):
|
||||||
|
sess = get_session()
|
||||||
|
try:
|
||||||
|
data = generate_avis_pdf(
|
||||||
|
sess, apprenti_id, prof_name=self.name or self.username,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
sess.close()
|
||||||
|
if data is None:
|
||||||
|
return rx.toast.error(
|
||||||
|
"Template introuvable. Vérifiez data/templates/GF_FO_Avis_de_sanction.pdf"
|
||||||
|
)
|
||||||
|
app_log(
|
||||||
|
f"[avis] {self.username or '?'} : avis de sanction généré pour "
|
||||||
|
f"{nom} {prenom} ({classe})"
|
||||||
|
)
|
||||||
|
safe_nom = "".join(c if c.isalnum() else "_" for c in nom)
|
||||||
|
safe_prenom = "".join(c if c.isalnum() else "_" for c in prenom)
|
||||||
|
filename = f"Avis_sanction_{safe_nom}_{safe_prenom}.pdf"
|
||||||
|
return rx.download(data=data, filename=filename)
|
||||||
|
|
||||||
|
|
||||||
|
# ── UI ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _kpi_card(label: str, value: rx.Var) -> rx.Component:
|
def _kpi_card(label: str, value: rx.Var) -> rx.Component:
|
||||||
return rx.box(
|
return rx.box(
|
||||||
|
|
@ -58,40 +121,111 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sanction_card(s: dict) -> rx.Component:
|
def _sanction_tile(item: rx.Var) -> rx.Component:
|
||||||
return rx.box(
|
return rx.box(
|
||||||
rx.vstack(
|
rx.vstack(
|
||||||
rx.hstack(
|
rx.flex(
|
||||||
rx.link(
|
rx.text(
|
||||||
rx.text(s["nom"], " ", s["prenom"], size="3", font_weight="700"),
|
item["nom"], " ", item["prenom"],
|
||||||
href="/fiche",
|
size="3", weight="bold", color="#1a237e",
|
||||||
text_decoration="none",
|
|
||||||
color="inherit",
|
|
||||||
),
|
),
|
||||||
|
rx.spacer(),
|
||||||
rx.box(
|
rx.box(
|
||||||
rx.text("🔴 ", s["absences"], " abs.",
|
rx.flex(
|
||||||
size="1", color="#B71C1C", font_weight="700"),
|
rx.icon("triangle-alert", size=12, color="#B71C1C"),
|
||||||
background_color="#ffcccc",
|
rx.text(
|
||||||
padding_x="0.5rem",
|
item["absences"], " abs.",
|
||||||
padding_y="0.1rem",
|
size="1", color="#B71C1C", weight="bold",
|
||||||
border_radius="10px",
|
),
|
||||||
|
gap="0.25rem", align="center",
|
||||||
|
),
|
||||||
|
background_color="#ffe5e5",
|
||||||
|
padding="0.15rem 0.5rem",
|
||||||
|
border_radius="9999px",
|
||||||
|
flex_shrink="0",
|
||||||
),
|
),
|
||||||
align="center",
|
width="100%", align="center", gap="0.5rem", wrap="wrap",
|
||||||
spacing="2",
|
|
||||||
wrap="wrap",
|
|
||||||
),
|
),
|
||||||
rx.text(s["classe"], size="1", color="#999999"),
|
rx.button(
|
||||||
spacing="1",
|
rx.icon("file-down", size=13),
|
||||||
|
"PDF avis de sanction",
|
||||||
|
on_click=AccueilState.download_avis(
|
||||||
|
item["id"], item["nom"], item["prenom"], item["classe"],
|
||||||
|
).stop_propagation,
|
||||||
|
size="1",
|
||||||
|
color_scheme="gray",
|
||||||
|
variant="soft",
|
||||||
|
),
|
||||||
|
spacing="2",
|
||||||
align="start",
|
align="start",
|
||||||
|
width="100%",
|
||||||
),
|
),
|
||||||
|
on_click=AccueilState.open_fiche(item["id"]),
|
||||||
|
cursor="pointer",
|
||||||
|
padding="0.85rem 1rem",
|
||||||
background_color="white",
|
background_color="white",
|
||||||
border="1px solid #f5c6cb",
|
border="1px solid #e0e0e0",
|
||||||
border_left="4px solid #dc3545",
|
|
||||||
border_radius="8px",
|
border_radius="8px",
|
||||||
padding="0.625rem 0.875rem",
|
flex="1 1 240px",
|
||||||
margin_y="0.15rem",
|
min_width="220px",
|
||||||
|
max_width="320px",
|
||||||
|
class_name="hover-lift sanction-tile",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _class_group(group: rx.Var) -> rx.Component:
|
||||||
|
return rx.box(
|
||||||
|
# En-tête de classe (cliquable → page Classes pré-sélectionnée)
|
||||||
|
rx.flex(
|
||||||
|
rx.icon("users", size=15, color="#37474f"),
|
||||||
|
rx.text(group["classe"], size="3", weight="bold", color="#37474f"),
|
||||||
|
on_click=AccueilState.open_classe(group["classe"]),
|
||||||
|
cursor="pointer",
|
||||||
|
padding="0.5rem 0.75rem",
|
||||||
|
border_radius="6px",
|
||||||
|
background_color="#f8f9fa",
|
||||||
|
border="1px solid #e9ecef",
|
||||||
|
_hover={"background_color": "#eef2f6"},
|
||||||
|
width="100%",
|
||||||
|
align="center",
|
||||||
|
gap="0.5rem",
|
||||||
|
class_name="smooth-transition",
|
||||||
|
margin_bottom="0.6rem",
|
||||||
|
),
|
||||||
|
# Tuiles apprentis
|
||||||
|
rx.flex(
|
||||||
|
rx.foreach(group["items"].to(list[dict]), _sanction_tile),
|
||||||
|
gap="0.6rem",
|
||||||
|
flex_wrap="wrap",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
width="100%",
|
width="100%",
|
||||||
class_name="hover-lift",
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanctions_section() -> rx.Component:
|
||||||
|
return rx.cond(
|
||||||
|
AccueilState.sanctions_total == 0,
|
||||||
|
rx.box(
|
||||||
|
rx.flex(
|
||||||
|
rx.icon("circle-check-big", size=18, color="#2e7d32"),
|
||||||
|
rx.text(
|
||||||
|
"Aucun apprenti n'a atteint le quota de 5 absences.",
|
||||||
|
size="2", color="#2e7d32",
|
||||||
|
),
|
||||||
|
gap="0.5rem", align="center",
|
||||||
|
),
|
||||||
|
background_color="#f1f8f1",
|
||||||
|
border="1px solid #c8e6c9",
|
||||||
|
border_radius="6px",
|
||||||
|
padding="0.85rem 1rem",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
rx.vstack(
|
||||||
|
rx.foreach(AccueilState.sanctions_groups, _class_group),
|
||||||
|
spacing="4",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -102,9 +236,9 @@ def accueil_page() -> rx.Component:
|
||||||
|
|
||||||
# KPIs
|
# KPIs
|
||||||
rx.hstack(
|
rx.hstack(
|
||||||
_kpi_card("Absences ce mois", AccueilState.kpi_mois),
|
_kpi_card("Avis de sanction pour absences", AccueilState.sanctions_total),
|
||||||
_kpi_card("Total absences", AccueilState.kpi_total),
|
_kpi_card("Total périodes d'absence", AccueilState.kpi_total),
|
||||||
_kpi_card("À traiter", AccueilState.kpi_traiter),
|
_kpi_card("Périodes à traiter", AccueilState.kpi_traiter),
|
||||||
spacing="3",
|
spacing="3",
|
||||||
width="100%",
|
width="100%",
|
||||||
wrap="wrap",
|
wrap="wrap",
|
||||||
|
|
@ -113,44 +247,25 @@ def accueil_page() -> rx.Component:
|
||||||
|
|
||||||
rx.divider(),
|
rx.divider(),
|
||||||
|
|
||||||
rx.heading("🚨 Avis de sanction — quota atteint", size="5"),
|
rx.flex(
|
||||||
rx.box(
|
rx.icon("triangle-alert", size=20, color="#c62828"),
|
||||||
rx.cond(
|
rx.heading("Avis de sanction (> de 5 absences)", size="5"),
|
||||||
AccueilState.sanctions.length() == 0,
|
gap="0.5rem", align="center",
|
||||||
rx.box(
|
|
||||||
rx.text("✓ Aucun apprenti n'a atteint le quota de 5 absences.",
|
|
||||||
color="#2e7d32", size="2"),
|
|
||||||
background_color="#f1f8f1",
|
|
||||||
border="1px solid #c8e6c9",
|
|
||||||
border_radius="6px",
|
|
||||||
padding="0.75rem 1rem",
|
|
||||||
width="100%",
|
|
||||||
),
|
|
||||||
rx.vstack(
|
|
||||||
rx.box(
|
|
||||||
rx.text("⚠ ", AccueilState.sanctions.length(),
|
|
||||||
" apprenti(s) en avis de sanction",
|
|
||||||
color="#B71C1C", size="2", font_weight="600"),
|
|
||||||
background_color="#fff5f5",
|
|
||||||
border="1px solid #ffcccc",
|
|
||||||
border_radius="6px",
|
|
||||||
padding="0.75rem 1rem",
|
|
||||||
width="100%",
|
|
||||||
),
|
|
||||||
rx.foreach(AccueilState.sanctions, _sanction_card),
|
|
||||||
width="100%",
|
|
||||||
spacing="1",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
width="100%",
|
|
||||||
),
|
),
|
||||||
|
_sanctions_section(),
|
||||||
|
|
||||||
rx.divider(),
|
rx.divider(),
|
||||||
|
|
||||||
rx.heading("📉 Notes insuffisantes (BN / Matu < 4.0)", size="5"),
|
rx.heading("Notes insuffisantes (BN / Matu < 4.0)", size="5"),
|
||||||
rx.box(
|
rx.box(
|
||||||
rx.text("ℹ Migration en cours — disponible prochainement.",
|
rx.flex(
|
||||||
color="#1565c0", size="2"),
|
rx.icon("info", size=16, color="#1565c0"),
|
||||||
|
rx.text(
|
||||||
|
"Migration en cours — disponible prochainement.",
|
||||||
|
color="#1565c0", size="2",
|
||||||
|
),
|
||||||
|
gap="0.5rem", align="center",
|
||||||
|
),
|
||||||
background_color="#e3f2fd",
|
background_color="#e3f2fd",
|
||||||
border="1px solid #90caf9",
|
border="1px solid #90caf9",
|
||||||
border_radius="6px",
|
border_radius="6px",
|
||||||
|
|
@ -161,7 +276,7 @@ def accueil_page() -> rx.Component:
|
||||||
spacing="5",
|
spacing="5",
|
||||||
width="100%",
|
width="100%",
|
||||||
max_width="100%",
|
max_width="100%",
|
||||||
align="start",
|
align="stretch",
|
||||||
padding_bottom="2rem",
|
padding_bottom="2rem",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import asyncio
|
||||||
|
import html as _html
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -14,17 +16,103 @@ _LOG_FILE = DATA_DIR / "logs" / "operations.log"
|
||||||
_CRON_DIR = Path(os.getenv("CRON_LOG_DIR", "/logs/cron"))
|
_CRON_DIR = Path(os.getenv("CRON_LOG_DIR", "/logs/cron"))
|
||||||
|
|
||||||
|
|
||||||
|
def _background(fn):
|
||||||
|
fn._reflex_background_task = True
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
# ── Colorisation des logs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_RE_TS_PROD = re.compile(r"^(\[\d{2}:\d{2}:\d{2}\])\s+(?!\s)(.*)$")
|
||||||
|
_RE_TS_DEBUG = re.compile(r"^(\[\d{2}:\d{2}:\d{2}\])\s{2,}(.*)$")
|
||||||
|
_RE_PREFIX = re.compile(r"^\[([a-z_-]+)\]\s+(.*)$", re.IGNORECASE)
|
||||||
|
|
||||||
|
_LEVEL_PATTERNS = [
|
||||||
|
("error", re.compile(r"\b(erreur|error|exception|traceback|failed|échou|echou|invalid|timeout)\b", re.IGNORECASE)),
|
||||||
|
("warn", re.compile(r"\b(warning|warn|attention|skip|ignor)\b", re.IGNORECASE)),
|
||||||
|
("success", re.compile(r"\b(ok|succès|success|terminé|terminée|all_done|push_done|importé|importée|réussi)\b", re.IGNORECASE)),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Couleurs par préfixe de catégorie
|
||||||
|
_PREFIX_CLASS = {
|
||||||
|
"abs": "prefix-abs",
|
||||||
|
"sync": "prefix-sync",
|
||||||
|
"push": "prefix-push",
|
||||||
|
"cron": "prefix-cron",
|
||||||
|
"refresh": "prefix-sync",
|
||||||
|
"run_imports": "prefix-sync",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_level(text: str) -> str:
|
||||||
|
for level, pat in _LEVEL_PATTERNS:
|
||||||
|
if pat.search(text):
|
||||||
|
return level
|
||||||
|
return "info"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_line(line: str) -> str:
|
||||||
|
"""Convertit une ligne brute en HTML stylé avec span colorés."""
|
||||||
|
if not line.strip():
|
||||||
|
return '<div class="log-line log-blank"></div>'
|
||||||
|
|
||||||
|
# Ligne debug indentée (PROD: filtrée ; DEBUG: visible)
|
||||||
|
m_dbg = _RE_TS_DEBUG.match(line)
|
||||||
|
if m_dbg:
|
||||||
|
ts, content = m_dbg.group(1), m_dbg.group(2)
|
||||||
|
body, prefix_html = _extract_prefix(content)
|
||||||
|
return (
|
||||||
|
f'<div class="log-line log-debug">'
|
||||||
|
f'<span class="log-ts">{_html.escape(ts)}</span> '
|
||||||
|
f'<span class="log-indent">{prefix_html}{_html.escape(body)}</span>'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
m_prod = _RE_TS_PROD.match(line)
|
||||||
|
if m_prod:
|
||||||
|
ts, content = m_prod.group(1), m_prod.group(2)
|
||||||
|
body, prefix_html = _extract_prefix(content)
|
||||||
|
level = _detect_level(content)
|
||||||
|
return (
|
||||||
|
f'<div class="log-line log-{level}">'
|
||||||
|
f'<span class="log-ts">{_html.escape(ts)}</span> '
|
||||||
|
f'{prefix_html}{_html.escape(body)}'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ligne sans timestamp
|
||||||
|
level = _detect_level(line)
|
||||||
|
return f'<div class="log-line log-{level}">{_html.escape(line)}</div>'
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_prefix(content: str) -> tuple[str, str]:
|
||||||
|
"""Retourne (corps_sans_préfixe, html_du_préfixe_stylé). Préfixe = '[xxx] '."""
|
||||||
|
m = _RE_PREFIX.match(content)
|
||||||
|
if not m:
|
||||||
|
return content, ""
|
||||||
|
name = m.group(1).lower()
|
||||||
|
body = m.group(2)
|
||||||
|
cls = _PREFIX_CLASS.get(name, "prefix-default")
|
||||||
|
label_html = f'<span class="log-prefix {cls}">[{_html.escape(m.group(1))}]</span> '
|
||||||
|
return body, label_html
|
||||||
|
|
||||||
|
|
||||||
|
def _to_html(lines: list[str]) -> str:
|
||||||
|
return "\n".join(_format_line(ln) for ln in lines)
|
||||||
|
|
||||||
|
|
||||||
# ── State ──────────────────────────────────────────────────────────────────────
|
# ── State ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class LogsState(AuthState):
|
class LogsState(AuthState):
|
||||||
# Source: "ops" | "cron:<filename>"
|
# Source: "ops" | "cron:<filename>"
|
||||||
source: str = "ops"
|
source: str = "ops"
|
||||||
log_level: str = "PROD"
|
log_level: str = "PROD"
|
||||||
log_content: str = ""
|
log_html: str = ""
|
||||||
log_total: int = 0
|
log_total: int = 0
|
||||||
log_shown: int = 0
|
log_shown: int = 0
|
||||||
log_empty: bool = True
|
log_empty: bool = True
|
||||||
confirm_clear: bool = False
|
confirm_clear: bool = False
|
||||||
|
live_mode: bool = False
|
||||||
|
|
||||||
# Liste des logs cron disponibles (filenames seulement)
|
# Liste des logs cron disponibles (filenames seulement)
|
||||||
cron_logs: list[dict] = []
|
cron_logs: list[dict] = []
|
||||||
|
|
@ -52,7 +140,7 @@ class LogsState(AuthState):
|
||||||
def _read_ops_log(self):
|
def _read_ops_log(self):
|
||||||
if not _LOG_FILE.exists() or _LOG_FILE.stat().st_size == 0:
|
if not _LOG_FILE.exists() or _LOG_FILE.stat().st_size == 0:
|
||||||
self.log_empty = True
|
self.log_empty = True
|
||||||
self.log_content = ""
|
self.log_html = ""
|
||||||
self.log_total = 0
|
self.log_total = 0
|
||||||
self.log_shown = 0
|
self.log_shown = 0
|
||||||
return
|
return
|
||||||
|
|
@ -65,28 +153,27 @@ class LogsState(AuthState):
|
||||||
ln for ln in lines
|
ln for ln in lines
|
||||||
if re.match(r"^\[\d{2}:\d{2}:\d{2}\] [^ ]", ln) or not ln.strip()
|
if re.match(r"^\[\d{2}:\d{2}:\d{2}\] [^ ]", ln) or not ln.strip()
|
||||||
]
|
]
|
||||||
self.log_content = "\n".join(filtered)
|
self.log_html = _to_html(filtered)
|
||||||
self.log_shown = len(filtered)
|
self.log_shown = len(filtered)
|
||||||
else:
|
else:
|
||||||
self.log_content = raw
|
self.log_html = _to_html(lines)
|
||||||
self.log_shown = self.log_total
|
self.log_shown = self.log_total
|
||||||
|
|
||||||
def _read_cron_log(self, filename: str):
|
def _read_cron_log(self, filename: str):
|
||||||
# Sanitize : forcer fichier dans _CRON_DIR
|
|
||||||
target = (_CRON_DIR / filename).resolve()
|
target = (_CRON_DIR / filename).resolve()
|
||||||
if not str(target).startswith(str(_CRON_DIR.resolve())):
|
if not str(target).startswith(str(_CRON_DIR.resolve())):
|
||||||
self.log_empty = True
|
self.log_empty = True
|
||||||
self.log_content = "Chemin invalide."
|
self.log_html = '<div class="log-line log-error">Chemin invalide.</div>'
|
||||||
return
|
return
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
self.log_empty = True
|
self.log_empty = True
|
||||||
self.log_content = ""
|
self.log_html = ""
|
||||||
self.log_total = 0
|
self.log_total = 0
|
||||||
self.log_shown = 0
|
self.log_shown = 0
|
||||||
return
|
return
|
||||||
raw = target.read_text(encoding="utf-8", errors="replace")
|
raw = target.read_text(encoding="utf-8", errors="replace")
|
||||||
lines = raw.splitlines()
|
lines = raw.splitlines()
|
||||||
self.log_content = raw
|
self.log_html = _to_html(lines)
|
||||||
self.log_total = len(lines)
|
self.log_total = len(lines)
|
||||||
self.log_shown = len(lines)
|
self.log_shown = len(lines)
|
||||||
self.log_empty = len(lines) == 0
|
self.log_empty = len(lines) == 0
|
||||||
|
|
@ -100,6 +187,8 @@ class LogsState(AuthState):
|
||||||
def load_data(self):
|
def load_data(self):
|
||||||
if not self.authenticated:
|
if not self.authenticated:
|
||||||
return rx.redirect("/login")
|
return rx.redirect("/login")
|
||||||
|
# Toujours désactiver le live mode quand on (re)charge la page
|
||||||
|
self.live_mode = False
|
||||||
self._refresh_cron_list()
|
self._refresh_cron_list()
|
||||||
self._read_log()
|
self._read_log()
|
||||||
|
|
||||||
|
|
@ -139,9 +228,67 @@ class LogsState(AuthState):
|
||||||
if _LOG_FILE.exists():
|
if _LOG_FILE.exists():
|
||||||
return rx.download(data=_LOG_FILE.read_bytes(), filename="operations.log")
|
return rx.download(data=_LOG_FILE.read_bytes(), filename="operations.log")
|
||||||
|
|
||||||
|
# ── Live mode ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def toggle_live(self):
|
||||||
|
if self.live_mode:
|
||||||
|
self.live_mode = False
|
||||||
|
return
|
||||||
|
self.live_mode = True
|
||||||
|
return LogsState.live_loop
|
||||||
|
|
||||||
|
@_background
|
||||||
|
async def live_loop(self):
|
||||||
|
"""Polling toutes les 2s tant que live_mode est True."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
async with self:
|
||||||
|
if not self.live_mode:
|
||||||
|
return
|
||||||
|
self._read_log()
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ── UI ─────────────────────────────────────────────────────────────────────────
|
# ── UI ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Script JS : auto-scroll à chaque mutation du conteneur de logs.
|
||||||
|
_AUTOSCROLL_JS = """
|
||||||
|
(() => {
|
||||||
|
const setup = () => {
|
||||||
|
const el = document.getElementById('log-viewer');
|
||||||
|
if (!el) return false;
|
||||||
|
if (el.__autoscrollSetup) return true;
|
||||||
|
el.__autoscrollSetup = true;
|
||||||
|
// Scroll initial
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
// Auto-scroll : toujours en mode live, sinon si on était proche du bas
|
||||||
|
const obs = new MutationObserver(() => {
|
||||||
|
const isLive = el.dataset.live === '1';
|
||||||
|
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
|
||||||
|
if (isLive || nearBottom) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
obs.observe(el, {childList: true, subtree: true, characterData: true});
|
||||||
|
// Au passage en live mode, scroller immédiatement en bas
|
||||||
|
new MutationObserver((muts) => {
|
||||||
|
for (const m of muts) {
|
||||||
|
if (m.attributeName === 'data-live' && el.dataset.live === '1') {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).observe(el, {attributes: true, attributeFilter: ['data-live']});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if (!setup()) {
|
||||||
|
const retry = setInterval(() => { if (setup()) clearInterval(retry); }, 250);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _clear_zone() -> rx.Component:
|
def _clear_zone() -> rx.Component:
|
||||||
return rx.cond(
|
return rx.cond(
|
||||||
LogsState.confirm_clear,
|
LogsState.confirm_clear,
|
||||||
|
|
@ -176,22 +323,54 @@ def _clear_zone() -> rx.Component:
|
||||||
|
|
||||||
|
|
||||||
def _caption() -> rx.Component:
|
def _caption() -> rx.Component:
|
||||||
return rx.cond(
|
return rx.hstack(
|
||||||
LogsState.log_level == "PROD",
|
rx.cond(
|
||||||
rx.text(
|
LogsState.log_level == "PROD",
|
||||||
LogsState.log_shown,
|
rx.text(
|
||||||
" ligne(s) affichée(s) / ",
|
LogsState.log_shown,
|
||||||
LogsState.log_total,
|
" ligne(s) affichée(s) / ",
|
||||||
" total — mode PROD (lignes de synthèse uniquement)",
|
LogsState.log_total,
|
||||||
size="1",
|
" total — mode PROD (lignes de synthèse)",
|
||||||
color="gray",
|
size="1",
|
||||||
|
color="gray",
|
||||||
|
),
|
||||||
|
rx.text(
|
||||||
|
LogsState.log_total,
|
||||||
|
" ligne(s) — mode DEBUG (toutes lignes)",
|
||||||
|
size="1",
|
||||||
|
color="gray",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
rx.text(
|
rx.cond(
|
||||||
LogsState.log_total,
|
LogsState.live_mode,
|
||||||
" ligne(s) — mode DEBUG (tous les logs)",
|
rx.flex(
|
||||||
size="1",
|
rx.box(
|
||||||
color="gray",
|
width="6px", height="6px",
|
||||||
|
border_radius="9999px",
|
||||||
|
background_color="#22c55e",
|
||||||
|
class_name="anim-pulse",
|
||||||
|
),
|
||||||
|
rx.text("Live", size="1", color="#22c55e", weight="medium"),
|
||||||
|
gap="0.35rem", align="center",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
gap="0.75rem",
|
||||||
|
align="center",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _live_button() -> rx.Component:
|
||||||
|
return rx.button(
|
||||||
|
rx.cond(
|
||||||
|
LogsState.live_mode,
|
||||||
|
rx.icon("pause", size=13),
|
||||||
|
rx.icon("play", size=13),
|
||||||
|
),
|
||||||
|
rx.cond(LogsState.live_mode, "Stop live", "Live"),
|
||||||
|
on_click=LogsState.toggle_live,
|
||||||
|
size="1",
|
||||||
|
color_scheme=rx.cond(LogsState.live_mode, "green", "gray"),
|
||||||
|
variant=rx.cond(LogsState.live_mode, "solid", "soft"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -208,23 +387,16 @@ def _log_display() -> rx.Component:
|
||||||
rx.vstack(
|
rx.vstack(
|
||||||
_caption(),
|
_caption(),
|
||||||
rx.box(
|
rx.box(
|
||||||
rx.el.pre(
|
rx.html(LogsState.log_html, class_name="log-content"),
|
||||||
LogsState.log_content,
|
id="log-viewer",
|
||||||
style={
|
custom_attrs={"data-live": rx.cond(LogsState.live_mode, "1", "0")},
|
||||||
"fontFamily": "'Courier New', Courier, monospace",
|
background="#1a1d23",
|
||||||
"fontSize": "0.72rem",
|
padding="0.75rem 1rem",
|
||||||
"whiteSpace": "pre-wrap",
|
|
||||||
"wordBreak": "break-all",
|
|
||||||
"color": "#abb2bf",
|
|
||||||
"margin": "0",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
background="#1e2228",
|
|
||||||
padding="1rem",
|
|
||||||
border_radius="6px",
|
border_radius="6px",
|
||||||
overflow_y="auto",
|
overflow_y="auto",
|
||||||
max_height="70vh",
|
max_height="70vh",
|
||||||
width="100%",
|
width="100%",
|
||||||
|
border="1px solid #2a2f37",
|
||||||
),
|
),
|
||||||
width="100%",
|
width="100%",
|
||||||
gap="0.375rem",
|
gap="0.375rem",
|
||||||
|
|
@ -283,6 +455,7 @@ def logs_page() -> rx.Component:
|
||||||
align="center",
|
align="center",
|
||||||
gap="0.375rem",
|
gap="0.375rem",
|
||||||
),
|
),
|
||||||
|
_live_button(),
|
||||||
rx.button(
|
rx.button(
|
||||||
rx.icon("refresh-cw", size=13),
|
rx.icon("refresh-cw", size=13),
|
||||||
"Rafraîchir",
|
"Rafraîchir",
|
||||||
|
|
@ -290,6 +463,7 @@ def logs_page() -> rx.Component:
|
||||||
size="1",
|
size="1",
|
||||||
color_scheme="gray",
|
color_scheme="gray",
|
||||||
variant="soft",
|
variant="soft",
|
||||||
|
disabled=LogsState.live_mode,
|
||||||
),
|
),
|
||||||
rx.button(
|
rx.button(
|
||||||
rx.icon("download", size=13),
|
rx.icon("download", size=13),
|
||||||
|
|
@ -317,8 +491,8 @@ def logs_page() -> rx.Component:
|
||||||
|
|
||||||
rx.divider(),
|
rx.divider(),
|
||||||
|
|
||||||
# ── Contenu ──────────────────────────────────────────────────────
|
|
||||||
_log_display(),
|
_log_display(),
|
||||||
|
rx.script(_AUTOSCROLL_JS),
|
||||||
|
|
||||||
width="100%",
|
width="100%",
|
||||||
align="start",
|
align="start",
|
||||||
|
|
|
||||||
616
eptm_dashboard/pages/purge.py
Normal file
616
eptm_dashboard/pages/purge.py
Normal file
|
|
@ -0,0 +1,616 @@
|
||||||
|
"""Page /purge — suppression complète des données d'une classe (admin)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import reflex as rx
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
# Path setup pour imports src/
|
||||||
|
_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
if str(_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_ROOT))
|
||||||
|
|
||||||
|
from src.db import ( # noqa: E402
|
||||||
|
get_session, Apprenti, Absence, EscadaPending,
|
||||||
|
Import, ImportBN, NotesBulletin, NotesMatu, NotesExamen,
|
||||||
|
ApprentiFiche, SanctionExport,
|
||||||
|
)
|
||||||
|
from src.logger import app_log # noqa: E402
|
||||||
|
|
||||||
|
from ..state import AuthState
|
||||||
|
from ..sidebar import layout
|
||||||
|
from ..components import empty_state
|
||||||
|
|
||||||
|
DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
|
||||||
|
PDFS_DIR = DATA_DIR / "pdfs"
|
||||||
|
|
||||||
|
|
||||||
|
# ── State ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PurgeState(AuthState):
|
||||||
|
classes: list[str] = []
|
||||||
|
selected_class: str = ""
|
||||||
|
class_search: str = ""
|
||||||
|
class_select_open: bool = False
|
||||||
|
|
||||||
|
# Aperçu (avant suppression) — Vars individuelles plutôt qu'un dict
|
||||||
|
# pour permettre comparaisons numériques côté Reflex.
|
||||||
|
has_preview: bool = False
|
||||||
|
pv_apprentis: int = 0
|
||||||
|
pv_absences: int = 0
|
||||||
|
pv_pendings: int = 0
|
||||||
|
pv_bn: int = 0
|
||||||
|
pv_matu: int = 0
|
||||||
|
pv_notes_examen: int = 0
|
||||||
|
pv_fiches: int = 0
|
||||||
|
pv_sanctions: int = 0
|
||||||
|
pv_imports: int = 0
|
||||||
|
pv_imports_bn: int = 0
|
||||||
|
pv_pdfs: int = 0
|
||||||
|
pv_pdf_files: list[str] = []
|
||||||
|
|
||||||
|
# Confirmation
|
||||||
|
confirm_text: str = ""
|
||||||
|
is_purging: bool = False
|
||||||
|
|
||||||
|
# Résultat
|
||||||
|
has_result: bool = False
|
||||||
|
res_classe: str = ""
|
||||||
|
res_apprentis: int = 0
|
||||||
|
res_absences: int = 0
|
||||||
|
res_pendings: int = 0
|
||||||
|
res_bn: int = 0
|
||||||
|
res_matu: int = 0
|
||||||
|
res_notes_examen: int = 0
|
||||||
|
res_fiches: int = 0
|
||||||
|
res_sanctions: int = 0
|
||||||
|
res_imports: int = 0
|
||||||
|
res_imports_bn: int = 0
|
||||||
|
res_pdfs: int = 0
|
||||||
|
|
||||||
|
@rx.var
|
||||||
|
def filtered_classes(self) -> list[str]:
|
||||||
|
q = self.class_search.lower().strip()
|
||||||
|
if not q:
|
||||||
|
return self.classes
|
||||||
|
return [c for c in self.classes if q in c.lower()]
|
||||||
|
|
||||||
|
@rx.var
|
||||||
|
def confirm_match(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.selected_class != ""
|
||||||
|
and self.confirm_text.strip() == self.selected_class
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_data(self):
|
||||||
|
if not self.authenticated:
|
||||||
|
return rx.redirect("/login")
|
||||||
|
if self.role != "admin":
|
||||||
|
return rx.redirect("/accueil")
|
||||||
|
sess = get_session()
|
||||||
|
classes = sess.execute(
|
||||||
|
select(Apprenti.classe).distinct().order_by(Apprenti.classe)
|
||||||
|
).scalars().all()
|
||||||
|
sess.close()
|
||||||
|
self.classes = [c for c in classes if c]
|
||||||
|
self.has_preview = False
|
||||||
|
self.has_result = False
|
||||||
|
self.confirm_text = ""
|
||||||
|
|
||||||
|
# ── Selector ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_class_search(self, v: str):
|
||||||
|
self.class_search = v
|
||||||
|
|
||||||
|
def set_class_select_open(self, v: bool):
|
||||||
|
self.class_select_open = v
|
||||||
|
if not v:
|
||||||
|
self.class_search = ""
|
||||||
|
|
||||||
|
def select_class(self, classe: str):
|
||||||
|
self.selected_class = classe
|
||||||
|
self.class_select_open = False
|
||||||
|
self.class_search = ""
|
||||||
|
self.confirm_text = ""
|
||||||
|
self.has_result = False
|
||||||
|
self._compute_preview()
|
||||||
|
|
||||||
|
def class_search_keydown(self, key: str):
|
||||||
|
if key == "Enter":
|
||||||
|
results = self.filtered_classes
|
||||||
|
if results:
|
||||||
|
return PurgeState.select_class(results[0])
|
||||||
|
elif key == "Escape":
|
||||||
|
self.class_select_open = False
|
||||||
|
self.class_search = ""
|
||||||
|
|
||||||
|
def _compute_preview(self):
|
||||||
|
sess = get_session()
|
||||||
|
try:
|
||||||
|
apprenti_ids = list(sess.execute(
|
||||||
|
select(Apprenti.id).where(Apprenti.classe == self.selected_class)
|
||||||
|
).scalars().all())
|
||||||
|
self.pv_apprentis = len(apprenti_ids)
|
||||||
|
if apprenti_ids:
|
||||||
|
self.pv_absences = len(sess.execute(
|
||||||
|
select(Absence.id).where(Absence.apprenti_id.in_(apprenti_ids))
|
||||||
|
).all())
|
||||||
|
self.pv_pendings = len(sess.execute(
|
||||||
|
select(EscadaPending.id).where(EscadaPending.apprenti_id.in_(apprenti_ids))
|
||||||
|
).all())
|
||||||
|
self.pv_bn = len(sess.execute(
|
||||||
|
select(NotesBulletin.id).where(NotesBulletin.apprenti_id.in_(apprenti_ids))
|
||||||
|
).all())
|
||||||
|
self.pv_matu = len(sess.execute(
|
||||||
|
select(NotesMatu.id).where(NotesMatu.apprenti_id.in_(apprenti_ids))
|
||||||
|
).all())
|
||||||
|
self.pv_notes_examen = len(sess.execute(
|
||||||
|
select(NotesExamen.id).where(NotesExamen.apprenti_id.in_(apprenti_ids))
|
||||||
|
).all())
|
||||||
|
self.pv_fiches = len(sess.execute(
|
||||||
|
select(ApprentiFiche.id).where(ApprentiFiche.apprenti_id.in_(apprenti_ids))
|
||||||
|
).all())
|
||||||
|
self.pv_sanctions = len(sess.execute(
|
||||||
|
select(SanctionExport.id).where(SanctionExport.apprenti_id.in_(apprenti_ids))
|
||||||
|
).all())
|
||||||
|
else:
|
||||||
|
self.pv_absences = 0
|
||||||
|
self.pv_pendings = 0
|
||||||
|
self.pv_bn = 0
|
||||||
|
self.pv_matu = 0
|
||||||
|
self.pv_notes_examen = 0
|
||||||
|
self.pv_fiches = 0
|
||||||
|
self.pv_sanctions = 0
|
||||||
|
self.pv_imports = len(sess.execute(
|
||||||
|
select(Import.id).where(Import.classe == self.selected_class)
|
||||||
|
).all())
|
||||||
|
self.pv_imports_bn = len(sess.execute(
|
||||||
|
select(ImportBN.id).where(ImportBN.classe == self.selected_class)
|
||||||
|
).all())
|
||||||
|
# PDFs : chemins déclarés + canoniques
|
||||||
|
pdf_set: set[str] = set()
|
||||||
|
for fichier in sess.execute(
|
||||||
|
select(Import.fichier).where(Import.classe == self.selected_class)
|
||||||
|
).scalars().all():
|
||||||
|
if fichier:
|
||||||
|
pdf_set.add(fichier)
|
||||||
|
for fichier in sess.execute(
|
||||||
|
select(ImportBN.fichier).where(ImportBN.classe == self.selected_class)
|
||||||
|
).scalars().all():
|
||||||
|
if fichier:
|
||||||
|
pdf_set.add(fichier)
|
||||||
|
classe_normalized = self.selected_class.replace(" ", "_")
|
||||||
|
for canonical in (
|
||||||
|
f"esacada_{classe_normalized}.pdf",
|
||||||
|
f"bn_{classe_normalized}.pdf",
|
||||||
|
f"notes_{classe_normalized}.pdf",
|
||||||
|
):
|
||||||
|
pdf_set.add(canonical)
|
||||||
|
existing_pdfs = sorted(f for f in pdf_set if (PDFS_DIR / f).exists())
|
||||||
|
self.pv_pdfs = len(existing_pdfs)
|
||||||
|
self.pv_pdf_files = existing_pdfs
|
||||||
|
self.has_preview = True
|
||||||
|
finally:
|
||||||
|
sess.close()
|
||||||
|
|
||||||
|
# ── Setters ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_confirm_text(self, v: str):
|
||||||
|
self.confirm_text = v
|
||||||
|
|
||||||
|
# ── Suppression ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def purge(self):
|
||||||
|
if not self.confirm_match:
|
||||||
|
return rx.toast.error("Confirmation invalide.")
|
||||||
|
classe = self.selected_class
|
||||||
|
user = self.username or "?"
|
||||||
|
self.is_purging = True
|
||||||
|
sess = get_session()
|
||||||
|
try:
|
||||||
|
apprenti_ids = list(sess.execute(
|
||||||
|
select(Apprenti.id).where(Apprenti.classe == classe)
|
||||||
|
).scalars().all())
|
||||||
|
|
||||||
|
n_pendings = n_abs = n_bn = n_matu = n_notes_ex = 0
|
||||||
|
n_fiches = n_sanctions = 0
|
||||||
|
if apprenti_ids:
|
||||||
|
n_pendings = sess.execute(
|
||||||
|
delete(EscadaPending).where(EscadaPending.apprenti_id.in_(apprenti_ids))
|
||||||
|
).rowcount or 0
|
||||||
|
n_abs = sess.execute(
|
||||||
|
delete(Absence).where(Absence.apprenti_id.in_(apprenti_ids))
|
||||||
|
).rowcount or 0
|
||||||
|
n_bn = sess.execute(
|
||||||
|
delete(NotesBulletin).where(NotesBulletin.apprenti_id.in_(apprenti_ids))
|
||||||
|
).rowcount or 0
|
||||||
|
n_matu = sess.execute(
|
||||||
|
delete(NotesMatu).where(NotesMatu.apprenti_id.in_(apprenti_ids))
|
||||||
|
).rowcount or 0
|
||||||
|
n_notes_ex = sess.execute(
|
||||||
|
delete(NotesExamen).where(NotesExamen.apprenti_id.in_(apprenti_ids))
|
||||||
|
).rowcount or 0
|
||||||
|
n_fiches = sess.execute(
|
||||||
|
delete(ApprentiFiche).where(ApprentiFiche.apprenti_id.in_(apprenti_ids))
|
||||||
|
).rowcount or 0
|
||||||
|
n_sanctions = sess.execute(
|
||||||
|
delete(SanctionExport).where(SanctionExport.apprenti_id.in_(apprenti_ids))
|
||||||
|
).rowcount or 0
|
||||||
|
|
||||||
|
# Récupération des fichiers PDF avant suppression des imports
|
||||||
|
pdf_set: set[str] = set()
|
||||||
|
for fichier in sess.execute(
|
||||||
|
select(Import.fichier).where(Import.classe == classe)
|
||||||
|
).scalars().all():
|
||||||
|
if fichier:
|
||||||
|
pdf_set.add(fichier)
|
||||||
|
for fichier in sess.execute(
|
||||||
|
select(ImportBN.fichier).where(ImportBN.classe == classe)
|
||||||
|
).scalars().all():
|
||||||
|
if fichier:
|
||||||
|
pdf_set.add(fichier)
|
||||||
|
|
||||||
|
n_imports = sess.execute(
|
||||||
|
delete(Import).where(Import.classe == classe)
|
||||||
|
).rowcount or 0
|
||||||
|
n_imports_bn = sess.execute(
|
||||||
|
delete(ImportBN).where(ImportBN.classe == classe)
|
||||||
|
).rowcount or 0
|
||||||
|
|
||||||
|
n_apprentis = sess.execute(
|
||||||
|
delete(Apprenti).where(Apprenti.classe == classe)
|
||||||
|
).rowcount or 0
|
||||||
|
|
||||||
|
sess.commit()
|
||||||
|
|
||||||
|
# Suppression des PDFs (canoniques + référencés dans les imports)
|
||||||
|
classe_normalized = classe.replace(" ", "_")
|
||||||
|
for canonical in (
|
||||||
|
f"esacada_{classe_normalized}.pdf",
|
||||||
|
f"bn_{classe_normalized}.pdf",
|
||||||
|
f"notes_{classe_normalized}.pdf",
|
||||||
|
):
|
||||||
|
pdf_set.add(canonical)
|
||||||
|
n_pdfs = 0
|
||||||
|
for fname in pdf_set:
|
||||||
|
fpath = PDFS_DIR / fname
|
||||||
|
if fpath.exists():
|
||||||
|
try:
|
||||||
|
fpath.unlink()
|
||||||
|
n_pdfs += 1
|
||||||
|
except Exception as e:
|
||||||
|
app_log(f"[purge] échec suppression PDF {fname} : {e}")
|
||||||
|
|
||||||
|
app_log(
|
||||||
|
f"[purge] {user} : suppression complète classe '{classe}' — "
|
||||||
|
f"{n_apprentis} appr., {n_abs} abs, {n_bn} BN, {n_matu} matu, "
|
||||||
|
f"{n_notes_ex} notes, {n_fiches} fiches, {n_pendings} pendings, "
|
||||||
|
f"{n_imports + n_imports_bn} imports, {n_pdfs} PDFs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sauver les résultats
|
||||||
|
self.res_classe = classe
|
||||||
|
self.res_apprentis = n_apprentis
|
||||||
|
self.res_absences = n_abs
|
||||||
|
self.res_pendings = n_pendings
|
||||||
|
self.res_bn = n_bn
|
||||||
|
self.res_matu = n_matu
|
||||||
|
self.res_notes_examen = n_notes_ex
|
||||||
|
self.res_fiches = n_fiches
|
||||||
|
self.res_sanctions = n_sanctions
|
||||||
|
self.res_imports = n_imports
|
||||||
|
self.res_imports_bn = n_imports_bn
|
||||||
|
self.res_pdfs = n_pdfs
|
||||||
|
except Exception as e:
|
||||||
|
sess.rollback()
|
||||||
|
self.is_purging = False
|
||||||
|
app_log(f"[purge] {user} : ERREUR purge classe '{classe}' : {e}")
|
||||||
|
return rx.toast.error(f"Erreur lors de la suppression : {e}")
|
||||||
|
finally:
|
||||||
|
sess.close()
|
||||||
|
|
||||||
|
self.is_purging = False
|
||||||
|
self.has_result = True
|
||||||
|
self.has_preview = False
|
||||||
|
self.confirm_text = ""
|
||||||
|
self.selected_class = ""
|
||||||
|
# Recharger la liste des classes
|
||||||
|
sess2 = get_session()
|
||||||
|
try:
|
||||||
|
classes = sess2.execute(
|
||||||
|
select(Apprenti.classe).distinct().order_by(Apprenti.classe)
|
||||||
|
).scalars().all()
|
||||||
|
self.classes = [c for c in classes if c]
|
||||||
|
finally:
|
||||||
|
sess2.close()
|
||||||
|
|
||||||
|
return rx.toast.success(
|
||||||
|
f"Classe '{classe}' supprimée — {self.res_apprentis} apprentis, "
|
||||||
|
f"{self.res_absences} absences, {self.res_pdfs} PDFs."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── UI ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _classe_option(classe: rx.Var) -> rx.Component:
|
||||||
|
return rx.box(
|
||||||
|
rx.text(classe, size="2"),
|
||||||
|
padding="0.45rem 0.75rem",
|
||||||
|
cursor="pointer",
|
||||||
|
on_click=PurgeState.select_class(classe),
|
||||||
|
_hover={"background_color": "var(--gray-3)"},
|
||||||
|
width="100%",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _classe_selector() -> rx.Component:
|
||||||
|
return rx.popover.root(
|
||||||
|
rx.popover.trigger(
|
||||||
|
rx.box(
|
||||||
|
rx.flex(
|
||||||
|
rx.cond(
|
||||||
|
PurgeState.selected_class != "",
|
||||||
|
rx.text(PurgeState.selected_class, size="2"),
|
||||||
|
rx.text("Sélectionner une classe…", size="2", color="var(--gray-9)"),
|
||||||
|
),
|
||||||
|
rx.spacer(),
|
||||||
|
rx.icon("chevron-down", size=18, color="var(--gray-9)"),
|
||||||
|
align="center",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
padding="0.5rem 0.75rem",
|
||||||
|
border="1px solid var(--gray-7)",
|
||||||
|
border_radius="6px",
|
||||||
|
background_color="white",
|
||||||
|
cursor="pointer",
|
||||||
|
width="100%",
|
||||||
|
custom_attrs={"data-shortcut": "purge-search"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rx.popover.content(
|
||||||
|
rx.vstack(
|
||||||
|
rx.input(
|
||||||
|
placeholder="Rechercher une classe…",
|
||||||
|
value=PurgeState.class_search,
|
||||||
|
on_change=PurgeState.set_class_search,
|
||||||
|
on_key_down=PurgeState.class_search_keydown,
|
||||||
|
size="2",
|
||||||
|
width="100%",
|
||||||
|
auto_focus=True,
|
||||||
|
),
|
||||||
|
rx.cond(
|
||||||
|
PurgeState.filtered_classes.length() > 0,
|
||||||
|
rx.box(
|
||||||
|
rx.foreach(PurgeState.filtered_classes, _classe_option),
|
||||||
|
max_height="280px",
|
||||||
|
overflow_y="auto",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
rx.box(
|
||||||
|
rx.text("Aucun résultat", size="2", color="var(--gray-9)"),
|
||||||
|
padding="0.5rem 0.75rem",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
spacing="2",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
min_width="280px",
|
||||||
|
max_width="400px",
|
||||||
|
padding="0.5rem",
|
||||||
|
),
|
||||||
|
open=PurgeState.class_select_open,
|
||||||
|
on_open_change=PurgeState.set_class_select_open,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _kpi(label: str, value, color: str = "#37474f") -> rx.Component:
|
||||||
|
return rx.box(
|
||||||
|
rx.text(label, size="1", color="#666"),
|
||||||
|
rx.text(value, size="5", font_weight="700", color=color),
|
||||||
|
padding="0.6rem 0.85rem",
|
||||||
|
background_color="white",
|
||||||
|
border="1px solid #e0e0e0",
|
||||||
|
border_radius="6px",
|
||||||
|
min_width="110px",
|
||||||
|
text_align="center",
|
||||||
|
flex="1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_panel() -> rx.Component:
|
||||||
|
return rx.cond(
|
||||||
|
PurgeState.has_preview,
|
||||||
|
rx.vstack(
|
||||||
|
rx.text(
|
||||||
|
"Données qui seront supprimées :",
|
||||||
|
size="2", weight="bold", color="#37474f",
|
||||||
|
),
|
||||||
|
rx.flex(
|
||||||
|
_kpi("Apprentis", PurgeState.pv_apprentis, "#c62828"),
|
||||||
|
_kpi("Absences", PurgeState.pv_absences, "#c62828"),
|
||||||
|
_kpi("Pendings", PurgeState.pv_pendings, "#b45309"),
|
||||||
|
_kpi("BN", PurgeState.pv_bn),
|
||||||
|
_kpi("Matu", PurgeState.pv_matu),
|
||||||
|
_kpi("Notes ex.", PurgeState.pv_notes_examen),
|
||||||
|
_kpi("Fiches", PurgeState.pv_fiches),
|
||||||
|
_kpi("Sanctions", PurgeState.pv_sanctions),
|
||||||
|
_kpi("Imports", PurgeState.pv_imports),
|
||||||
|
_kpi("Imports BN", PurgeState.pv_imports_bn),
|
||||||
|
_kpi("PDFs", PurgeState.pv_pdfs),
|
||||||
|
gap="0.5rem",
|
||||||
|
flex_wrap="wrap",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
rx.cond(
|
||||||
|
PurgeState.pv_pdfs > 0,
|
||||||
|
rx.box(
|
||||||
|
rx.text(
|
||||||
|
"Fichiers PDF qui seront effacés :",
|
||||||
|
size="1", color="#666", weight="medium",
|
||||||
|
margin_bottom="0.25rem",
|
||||||
|
),
|
||||||
|
rx.foreach(
|
||||||
|
PurgeState.pv_pdf_files,
|
||||||
|
lambda f: rx.text("• ", f, size="1", color="#666"),
|
||||||
|
),
|
||||||
|
padding="0.6rem 0.75rem",
|
||||||
|
background_color="#fafafa",
|
||||||
|
border_radius="6px",
|
||||||
|
border="1px solid #eee",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
spacing="3",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm_panel() -> rx.Component:
|
||||||
|
return rx.cond(
|
||||||
|
PurgeState.has_preview,
|
||||||
|
rx.box(
|
||||||
|
rx.vstack(
|
||||||
|
rx.flex(
|
||||||
|
rx.icon("triangle-alert", size=18, color="#92400e"),
|
||||||
|
rx.text(
|
||||||
|
"Confirmation requise",
|
||||||
|
size="3", weight="bold", color="#92400e",
|
||||||
|
),
|
||||||
|
gap="0.5rem", align="center",
|
||||||
|
),
|
||||||
|
rx.text(
|
||||||
|
"Cette action est définitive. Pour confirmer, recopie le nom exact de la classe ci-dessous :",
|
||||||
|
size="2", color="#78350f",
|
||||||
|
),
|
||||||
|
rx.code(PurgeState.selected_class, size="3"),
|
||||||
|
rx.input(
|
||||||
|
placeholder="Nom de la classe à recopier…",
|
||||||
|
value=PurgeState.confirm_text,
|
||||||
|
on_change=PurgeState.set_confirm_text,
|
||||||
|
size="2",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
rx.flex(
|
||||||
|
rx.button(
|
||||||
|
rx.icon("trash-2", size=14),
|
||||||
|
"Supprimer définitivement",
|
||||||
|
on_click=PurgeState.purge,
|
||||||
|
color_scheme="red",
|
||||||
|
size="2",
|
||||||
|
disabled=~PurgeState.confirm_match | PurgeState.is_purging,
|
||||||
|
loading=PurgeState.is_purging,
|
||||||
|
),
|
||||||
|
gap="0.5rem",
|
||||||
|
align="center",
|
||||||
|
),
|
||||||
|
spacing="3",
|
||||||
|
align="start",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
padding="1rem",
|
||||||
|
background_color="#fef3c7",
|
||||||
|
border="1px solid #fcd34d",
|
||||||
|
border_radius="8px",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _result_panel() -> rx.Component:
|
||||||
|
return rx.cond(
|
||||||
|
PurgeState.has_result,
|
||||||
|
rx.box(
|
||||||
|
rx.vstack(
|
||||||
|
rx.flex(
|
||||||
|
rx.icon("circle-check-big", size=18, color="#15803d"),
|
||||||
|
rx.text(
|
||||||
|
"Suppression terminée — ",
|
||||||
|
PurgeState.res_classe,
|
||||||
|
size="3", weight="bold", color="#15803d",
|
||||||
|
),
|
||||||
|
gap="0.5rem", align="center",
|
||||||
|
),
|
||||||
|
rx.text(
|
||||||
|
PurgeState.res_apprentis, " apprentis · ",
|
||||||
|
PurgeState.res_absences, " absences · ",
|
||||||
|
PurgeState.res_pendings, " pendings · ",
|
||||||
|
PurgeState.res_bn, " BN · ",
|
||||||
|
PurgeState.res_matu, " matu · ",
|
||||||
|
PurgeState.res_notes_examen, " notes · ",
|
||||||
|
PurgeState.res_fiches, " fiches · ",
|
||||||
|
PurgeState.res_sanctions, " sanctions · ",
|
||||||
|
PurgeState.res_imports, " + ",
|
||||||
|
PurgeState.res_imports_bn, " imports · ",
|
||||||
|
PurgeState.res_pdfs, " PDFs",
|
||||||
|
size="2", color="#166534",
|
||||||
|
),
|
||||||
|
spacing="2",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
padding="1rem",
|
||||||
|
background_color="#dcfce7",
|
||||||
|
border="1px solid #86efac",
|
||||||
|
border_radius="8px",
|
||||||
|
width="100%",
|
||||||
|
class_name="anim-fade",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def purge_page() -> rx.Component:
|
||||||
|
return layout(
|
||||||
|
rx.vstack(
|
||||||
|
rx.heading("Supprimer une classe", size="6"),
|
||||||
|
rx.box(
|
||||||
|
rx.flex(
|
||||||
|
rx.icon("triangle-alert", size=18, color="#b91c1c"),
|
||||||
|
rx.vstack(
|
||||||
|
rx.text(
|
||||||
|
"Action destructive",
|
||||||
|
size="2", weight="bold", color="#7f1d1d",
|
||||||
|
),
|
||||||
|
rx.text(
|
||||||
|
"Supprime définitivement toutes les données liées à une classe : "
|
||||||
|
"apprentis, absences, pendings, bulletins de notes, notes de matu, "
|
||||||
|
"notes d'examen, fiches personnelles, sanctions, traces d'imports, "
|
||||||
|
"et les PDFs sur disque. Cette opération est irréversible.",
|
||||||
|
size="1", color="#991b1b",
|
||||||
|
),
|
||||||
|
spacing="1", align="start",
|
||||||
|
),
|
||||||
|
gap="0.65rem", align="start",
|
||||||
|
),
|
||||||
|
padding="0.85rem 1rem",
|
||||||
|
background_color="#fee2e2",
|
||||||
|
border="1px solid #fca5a5",
|
||||||
|
border_radius="8px",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
|
||||||
|
rx.cond(
|
||||||
|
PurgeState.classes.length() > 0,
|
||||||
|
rx.vstack(
|
||||||
|
_classe_selector(),
|
||||||
|
_preview_panel(),
|
||||||
|
_confirm_panel(),
|
||||||
|
_result_panel(),
|
||||||
|
spacing="4",
|
||||||
|
width="100%",
|
||||||
|
),
|
||||||
|
empty_state(
|
||||||
|
icon="database",
|
||||||
|
title="Aucune classe en base",
|
||||||
|
description="Il n'y a aucune classe à supprimer.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
spacing="4",
|
||||||
|
width="100%",
|
||||||
|
padding="1rem",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -32,6 +32,7 @@ _ADMIN_PAGES = [
|
||||||
("Logs", "/logs", "file-text"),
|
("Logs", "/logs", "file-text"),
|
||||||
("Utilisateurs", "/users", "user-cog"),
|
("Utilisateurs", "/users", "user-cog"),
|
||||||
("Paramètres", "/params", "settings"),
|
("Paramètres", "/params", "settings"),
|
||||||
|
("Purger classe","/purge", "trash-2"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -348,8 +349,8 @@ def sidebar() -> rx.Component:
|
||||||
padding_y="0.5rem",
|
padding_y="0.5rem",
|
||||||
),
|
),
|
||||||
|
|
||||||
_doc_section(),
|
|
||||||
_admin_section(),
|
_admin_section(),
|
||||||
|
_doc_section(),
|
||||||
rx.spacer(),
|
rx.spacer(),
|
||||||
|
|
||||||
# User
|
# User
|
||||||
|
|
@ -390,10 +391,7 @@ def _mobile_topbar() -> rx.Component:
|
||||||
# Bar row
|
# Bar row
|
||||||
rx.hstack(
|
rx.hstack(
|
||||||
rx.box(
|
rx.box(
|
||||||
rx.image(src="/logo.png", height="40px", object_fit="contain"),
|
rx.image(src="/logo.png", height="48px", object_fit="contain"),
|
||||||
background_color="white",
|
|
||||||
border_radius="5px",
|
|
||||||
padding="4px 8px",
|
|
||||||
display="flex",
|
display="flex",
|
||||||
align_items="center",
|
align_items="center",
|
||||||
justify_content="center",
|
justify_content="center",
|
||||||
|
|
@ -425,8 +423,8 @@ def _mobile_topbar() -> rx.Component:
|
||||||
spacing="1", width="100%",
|
spacing="1", width="100%",
|
||||||
padding_x="0", padding_y="0.5rem",
|
padding_x="0", padding_y="0.5rem",
|
||||||
),
|
),
|
||||||
_doc_section(mobile=True),
|
|
||||||
_admin_section(mobile=True),
|
_admin_section(mobile=True),
|
||||||
|
_doc_section(mobile=True),
|
||||||
rx.box(height="1px", width="100%", background_color=_BORDER),
|
rx.box(height="1px", width="100%", background_color=_BORDER),
|
||||||
rx.box(
|
rx.box(
|
||||||
_user_widget(collapsed=False),
|
_user_widget(collapsed=False),
|
||||||
|
|
@ -483,20 +481,13 @@ def layout(content: rx.Component) -> rx.Component:
|
||||||
_mobile_topbar(),
|
_mobile_topbar(),
|
||||||
rx.box(
|
rx.box(
|
||||||
content,
|
content,
|
||||||
class_name="content-area",
|
class_name=rx.cond(
|
||||||
|
AuthState.sidebar_collapsed,
|
||||||
|
"content-area sidebar-collapsed",
|
||||||
|
"content-area",
|
||||||
|
),
|
||||||
padding=rx.cond(AuthState.sidebar_collapsed, "1rem", "1.5rem"),
|
padding=rx.cond(AuthState.sidebar_collapsed, "1rem", "1.5rem"),
|
||||||
background_color="var(--gray-2)",
|
background_color="var(--gray-2)",
|
||||||
margin_left=rx.cond(AuthState.sidebar_collapsed, RAIL_W, FULL_W),
|
|
||||||
width=rx.cond(
|
|
||||||
AuthState.sidebar_collapsed,
|
|
||||||
f"calc(100% - {RAIL_W})",
|
|
||||||
f"calc(100% - {FULL_W})",
|
|
||||||
),
|
|
||||||
max_width=rx.cond(
|
|
||||||
AuthState.sidebar_collapsed,
|
|
||||||
f"calc(100% - {RAIL_W})",
|
|
||||||
f"calc(100% - {FULL_W})",
|
|
||||||
),
|
|
||||||
overflow_x="hidden",
|
overflow_x="hidden",
|
||||||
transition="margin-left 0.22s ease, width 0.22s ease",
|
transition="margin-left 0.22s ease, width 0.22s ease",
|
||||||
box_sizing="border-box",
|
box_sizing="border-box",
|
||||||
|
|
|
||||||
20
src/db.py
20
src/db.py
|
|
@ -377,7 +377,27 @@ def find_or_create_apprenti(
|
||||||
le prénom est compatible (l'un est un préfixe-mot de l'autre).
|
le prénom est compatible (l'un est un préfixe-mot de l'autre).
|
||||||
Ne fusionne que s'il y a exactement un candidat.
|
Ne fusionne que s'il y a exactement un candidat.
|
||||||
3. Sinon : crée un nouvel Apprenti.
|
3. Sinon : crée un nouvel Apprenti.
|
||||||
|
|
||||||
|
Garde-fou : refuse la création pour les classes MP/MI. Les MP servent
|
||||||
|
uniquement au matching Matu (lookup par nom dans une classe régulière) ;
|
||||||
|
les MI sont totalement ignorées. Lève ValueError si on tente de créer
|
||||||
|
une nouvelle entrée dans ces classes.
|
||||||
"""
|
"""
|
||||||
|
if not classe or not classe.strip():
|
||||||
|
# Empêche les orphelins quand le parser PDF n'arrive pas à extraire
|
||||||
|
# la classe du header "Liste des absences de NOM, classe CODE".
|
||||||
|
raise ValueError(
|
||||||
|
f"Création d'apprenti refusée : classe vide pour {nom!r} {prenom!r}. "
|
||||||
|
f"Vérifier le PDF source (header de page incomplet)."
|
||||||
|
)
|
||||||
|
if classe.startswith(("MP", "MI")):
|
||||||
|
# Pour MP/MI : on retourne None implicitement via une exception. L'appelant
|
||||||
|
# (importer.py) doit avoir filtré au préalable. Cette garde évite tout
|
||||||
|
# nouvel import accidentel.
|
||||||
|
raise ValueError(
|
||||||
|
f"Création d'apprenti refusée pour la classe '{classe}' "
|
||||||
|
f"(MP/MI réservées au matching Matu via classes régulières)."
|
||||||
|
)
|
||||||
# 1. Exact
|
# 1. Exact
|
||||||
apprenti = session.execute(
|
apprenti = session.execute(
|
||||||
select(Apprenti).where(
|
select(Apprenti).where(
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,17 @@ def import_pdf(
|
||||||
semestre = data["semestre"]
|
semestre = data["semestre"]
|
||||||
apprentis_data = data["apprentis"]
|
apprentis_data = data["apprentis"]
|
||||||
|
|
||||||
|
# Garde-fou : on n'importe JAMAIS d'absences pour les classes MP/MI.
|
||||||
|
# Les MP servent uniquement au matching Matu (via NotesMatu, lié à des
|
||||||
|
# apprentis déjà présents dans une classe régulière). Les MI sont
|
||||||
|
# totalement ignorées.
|
||||||
|
if classe.startswith(("MP", "MI")):
|
||||||
|
return ImportResult(
|
||||||
|
import_id=0,
|
||||||
|
classe=classe, semestre=semestre, fichier=pdf_path.name,
|
||||||
|
nb_apprentis=0, nb_absences_nouvelles=0, nb_absences_doublons=0,
|
||||||
|
)
|
||||||
|
|
||||||
nb_nouvelles = 0
|
nb_nouvelles = 0
|
||||||
nb_doublons = 0
|
nb_doublons = 0
|
||||||
nb_mises_a_jour = 0
|
nb_mises_a_jour = 0
|
||||||
|
|
@ -75,9 +86,14 @@ def import_pdf(
|
||||||
seen_keys: set[tuple] = set()
|
seen_keys: set[tuple] = set()
|
||||||
|
|
||||||
for a_data in apprentis_data:
|
for a_data in apprentis_data:
|
||||||
apprenti = find_or_create_apprenti(
|
try:
|
||||||
session, a_data["nom"], a_data["prenom"], a_data["classe"]
|
apprenti = find_or_create_apprenti(
|
||||||
)
|
session, a_data["nom"], a_data["prenom"], a_data["classe"]
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# Apprenti rejeté par le garde-fou (classe vide / MP / MI) :
|
||||||
|
# on saute cette page du PDF sans interrompre tout l'import.
|
||||||
|
continue
|
||||||
|
|
||||||
for ab in a_data["absences"]:
|
for ab in a_data["absences"]:
|
||||||
key = (apprenti.id, ab["date"], ab["periode"])
|
key = (apprenti.id, ab["date"], ab["periode"])
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,18 @@ def import_bn(pdf_path: Path, session: Session, imported_by: str) -> ImportBN:
|
||||||
"""
|
"""
|
||||||
data = parse_bn_pdf(pdf_path)
|
data = parse_bn_pdf(pdf_path)
|
||||||
|
|
||||||
|
# Garde-fou : pas d'import BN pour les classes MP/MI.
|
||||||
|
# Les BN sont liés au cursus de la classe régulière.
|
||||||
|
if data["classe"].startswith(("MP", "MI")):
|
||||||
|
# Retourne un placeholder non-persisté (id=None)
|
||||||
|
return ImportBN(
|
||||||
|
fichier=pdf_path.name,
|
||||||
|
classe=data["classe"],
|
||||||
|
type_classe=data.get("type_classe", ""),
|
||||||
|
nb_apprentis=0,
|
||||||
|
imported_by=imported_by,
|
||||||
|
)
|
||||||
|
|
||||||
# Supprimer les anciens batches pour cette classe
|
# Supprimer les anciens batches pour cette classe
|
||||||
old_imports = session.execute(
|
old_imports = session.execute(
|
||||||
select(ImportBN).where(ImportBN.classe == data["classe"])
|
select(ImportBN).where(ImportBN.classe == data["classe"])
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,10 @@ def import_notes_pdf(pdf_path: Path, sess: Session, classe: str | None = None) -
|
||||||
if classe is None:
|
if classe is None:
|
||||||
classe = p.stem.replace("notes_", "").replace("_", " ")
|
classe = p.stem.replace("notes_", "").replace("_", " ")
|
||||||
|
|
||||||
|
# Garde-fou : pas de notes d'examen pour les classes MP/MI.
|
||||||
|
if classe.startswith(("MP", "MI")):
|
||||||
|
return {"classe": classe, "nb": 0}
|
||||||
|
|
||||||
apprentis = sess.execute(
|
apprentis = sess.execute(
|
||||||
select(Apprenti).where(Apprenti.classe == classe)
|
select(Apprenti).where(Apprenti.classe == classe)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
|
|
||||||
113
src/sanction_pdf.py
Normal file
113
src/sanction_pdf.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""Génération d'avis de sanction à partir du template AcroForm officiel.
|
||||||
|
|
||||||
|
Le template est `data/templates/GF_FO_Avis_de_sanction.pdf`. Il contient
|
||||||
|
9 champs de formulaire qu'on remplit programmatiquement avec pypdf, sans
|
||||||
|
aplatir (les champs restent éditables après téléchargement).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pypdf
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.db import Apprenti, ApprentiFiche
|
||||||
|
|
||||||
|
_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
_DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
|
||||||
|
_TEMPLATE_PATH = _DATA_DIR / "templates" / "GF_FO_Avis_de_sanction.pdf"
|
||||||
|
_SETTINGS_PATH = _DATA_DIR / "settings.json"
|
||||||
|
|
||||||
|
# Mêmes valeurs par défaut que la page Paramètres (pages/params.py).
|
||||||
|
_DEFAULT_TEXTE_SANCTION = (
|
||||||
|
"Selon le règlement de l'EM, l'apprenti a dépassé le nombre d'absences limite."
|
||||||
|
)
|
||||||
|
_DEFAULT_CHEF_SECTION = "Patrick Rausis"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_settings() -> dict:
|
||||||
|
if _SETTINGS_PATH.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(_SETTINGS_PATH.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_avis_pdf(
|
||||||
|
sess: Session,
|
||||||
|
apprenti_id: int,
|
||||||
|
prof_name: str = "",
|
||||||
|
) -> Optional[bytes]:
|
||||||
|
"""Renvoie les bytes d'un PDF d'avis de sanction pré-rempli pour l'apprenti.
|
||||||
|
|
||||||
|
Champs remplis depuis ApprentiFiche.entreprise_* (adresse, NPA-Ville et
|
||||||
|
NomParents = nom entreprise) puisque les parents ne sont pas stockés.
|
||||||
|
Texte de description et chef de section depuis data/settings.json.
|
||||||
|
|
||||||
|
Renvoie None si le template est introuvable ou l'apprenti n'existe pas.
|
||||||
|
"""
|
||||||
|
if not _TEMPLATE_PATH.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
apprenti = sess.get(Apprenti, apprenti_id)
|
||||||
|
if apprenti is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
fiche: Optional[ApprentiFiche] = apprenti.fiche
|
||||||
|
settings = _load_settings()
|
||||||
|
|
||||||
|
# Construction des valeurs
|
||||||
|
npa_ville = ""
|
||||||
|
if fiche:
|
||||||
|
cp = (fiche.entreprise_code_postal or "").strip()
|
||||||
|
loc = (fiche.entreprise_localite or "").strip()
|
||||||
|
npa_ville = f"{cp} {loc}".strip()
|
||||||
|
|
||||||
|
field_values: dict[str, str] = {
|
||||||
|
"NomApprenti": f"{apprenti.prenom} {apprenti.nom}".strip(),
|
||||||
|
"Classe": apprenti.classe or "",
|
||||||
|
"NomParents": (fiche.entreprise_nom if fiche else "") or "",
|
||||||
|
"Adresse": (fiche.entreprise_adresse if fiche else "") or "",
|
||||||
|
"NPA-Ville": npa_ville,
|
||||||
|
"Date": date.today().strftime("%d.%m.%Y"),
|
||||||
|
"TexteDescription": settings.get("texte_sanction") or _DEFAULT_TEXTE_SANCTION,
|
||||||
|
"Prof": prof_name or "",
|
||||||
|
"CS": settings.get("chef_section") or _DEFAULT_CHEF_SECTION,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Lecture du template + clone vers writer (préserve la structure AcroForm)
|
||||||
|
reader = pypdf.PdfReader(str(_TEMPLATE_PATH))
|
||||||
|
writer = pypdf.PdfWriter(clone_from=reader)
|
||||||
|
|
||||||
|
# Remplissage des champs sur chaque page (AcroForm peut être réparti).
|
||||||
|
# auto_regenerate=False : conserve les valeurs même si Reader recalcule
|
||||||
|
# les apparences (Acrobat les redessine à l'ouverture).
|
||||||
|
for page in writer.pages:
|
||||||
|
try:
|
||||||
|
writer.update_page_form_field_values(
|
||||||
|
page, field_values, auto_regenerate=False
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Champ peut-être absent de cette page : ignore et continue
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Force les champs comme NeedAppearances pour que les viewers redessinent
|
||||||
|
# correctement les valeurs à l'ouverture.
|
||||||
|
try:
|
||||||
|
if "/AcroForm" in writer._root_object:
|
||||||
|
writer._root_object["/AcroForm"].update(
|
||||||
|
{pypdf.generic.NameObject("/NeedAppearances"): pypdf.generic.BooleanObject(True)}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
writer.write(buf)
|
||||||
|
return buf.getvalue()
|
||||||
Loading…
Add table
Reference in a new issue