SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%

stats v3 : rapports PDF personnalisés (catalogue de blocs, rendu au choix, constructeur + modèles)

- kapdf.py v3 : catalog(dash), mode 'personnalise' (spec title+blocks,
  rendu par bloc avec conversions tableau, heatmap calendrier vectorielle)
- web.py : GET /api/stats/catalog + POST /api/stats/report/custom
- frontend : constructeur vanilla (modal 2 colonnes, ordre ↑↓, sélecteur
  de rendu, modèles localStorage, téléchargement blob)
- SPEC.md v3 (§3bis)
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent f827502

4 changed files +732 −10

modified frontend/index.html +236 −0
@@ -344,6 +344,41 @@ button{font-family:inherit}
344 344 .st-pdf-drop button+button{border-top:1px solid var(--line)}
345 345 .st-pdf-drop button:hover{background:var(--accent-soft)}
346 346 .st-pdf-drop small{font-family:var(--font-mono);font-size:10px;color:var(--ink-3)}
347 +/* ---------- v3 : constructeur de rapports personnalisés ---------- */
348 +.stb-overlay{position:fixed;inset:0;z-index:var(--z-modal,900);background:rgba(20,24,20,.45);
349 + display:flex;align-items:flex-start;justify-content:center;padding:4vh 14px;overflow:auto}
350 +.stb-panel{background:var(--surface);border:1px solid var(--ink);border-radius:12px;
351 + width:min(980px,100%);max-height:92vh;display:flex;flex-direction:column;
352 + box-shadow:0 18px 48px rgba(20,24,20,.3)}
353 +.stb-head{display:flex;justify-content:space-between;align-items:center;gap:10px;
354 + padding:16px 20px;border-bottom:1px solid var(--line)}
355 +.stb-head h2{margin:0;font-size:18px}
356 +.stb-body{display:grid;grid-template-columns:1fr 1fr;gap:0;overflow:auto;flex:1}
357 +@media(max-width:760px){.stb-body{grid-template-columns:1fr}}
358 +.stb-col{padding:14px 20px;min-width:0}
359 +.stb-col+.stb-col{border-left:1px solid var(--line)}
360 +@media(max-width:760px){.stb-col+.stb-col{border-left:0;border-top:1px solid var(--line)}}
361 +.stb-col h3{margin:4px 0 10px;font-size:12px;font-family:var(--font-mono);
362 + text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3)}
363 +.stb-group{margin:0 0 6px;font-family:var(--font-mono);font-size:10.5px;font-weight:700;
364 + text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);margin-top:12px}
365 +.stb-av{display:flex;justify-content:space-between;align-items:center;gap:8px;
366 + padding:7px 10px;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;font-size:13px}
367 +.stb-av button{flex:none}
368 +.stb-av.stb-in{opacity:.45}
369 +.stb-item{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--ink);
370 + border-radius:8px;margin-bottom:6px;background:var(--surface-2);font-size:13px}
371 +.stb-item .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
372 +.stb-item select{border:1px solid var(--line);border-radius:6px;background:var(--surface);
373 + padding:4px 6px;font-size:12px;max-width:150px}
374 +.stb-item .mv{border:0;background:none;cursor:pointer;font-size:14px;padding:2px 4px;min-height:0}
375 +.stb-item .mv:disabled{opacity:.25;cursor:default}
376 +.stb-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;
377 + padding:14px 20px;border-top:1px solid var(--line)}
378 +.stb-tpl{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
379 +.stb-err{color:var(--danger);font-size:12.5px;margin:6px 0 0}
380 +.stb-empty{border:1px dashed var(--line);border-radius:8px;padding:16px;color:var(--ink-3);
381 + font-size:13px;text-align:center}
347 382 .st-kpi .spark{margin-top:8px}
348 383 .st-kpi .spark svg{display:block;width:100%;height:26px}
349 384 .st-gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(200px,100%),1fr));gap:14px;margin-top:18px}
@@ -494,6 +529,7 @@ button{font-family:inherit}
494 529 <button type="button" data-mode="donnees">Données détaillées <small>tableaux longs</small></button>
495 530 </span>
496 531 </span>
532 + <button type="button" class="btn btn-ghost" id="st-custom-btn">🛠 Rapport personnalisé</button>
497 533 </div>
498 534 </div>
499 535 <div class="st-kpis" id="st-kpis"></div>
@@ -1362,6 +1398,206 @@ function showStats(){
1362 1398 $("st-pdf-full").onclick = e => stPdf("complet", e.currentTarget);
1363 1399 stRenderPeriods();
1364 1400
1401 +/* ==================== v3 : constructeur de rapports personnalisés ==========
1402 + Compose un PDF bloc par bloc : choix des données (catalogue du dashboard),
1403 + du rendu par bloc (courbe/aire/barres/anneau/heatmap/tableau…), de l'ordre,
1404 + et modèles sauvegardés (localStorage, propre au site = origine). */
1405 +const SB = { cat:null, sel:[], title:"", busy:false, err:"" };
1406 +const SB_TPL_KEY = "ka-stats-rapports";
1407 +const SB_RENDERS = { line:"Courbe", area:"Aire", bar:"Barres verticales",
1408 + bars:"Barres horizontales", donut:"Anneau", lines:"Multi-courbes",
1409 + stacked:"Barres empilées", histogram:"Histogramme", heatmap:"Heatmap",
1410 + cards:"Cartes", gauges:"Jauges", table:"Tableau" };
1411 +const SB_SECTIONS = { kpis:"Indicateurs", gauges:"Jauges", series:"Évolution",
1412 + multiseries:"Multi-courbes", stacked:"Compositions", breakdowns:"Répartitions",
1413 + distributions:"Distributions", geo:"Géographie", heatmap:"Calendrier",
1414 + hourly:"Activité horaire", tables:"Tableaux", records:"Records" };
1415 +
1416 +function sbTemplates(){
1417 + try{ return JSON.parse(localStorage.getItem(SB_TPL_KEY) || "[]"); }
1418 + catch(e){ return []; }
1419 +}
1420 +function sbSaveTemplates(t){ localStorage.setItem(SB_TPL_KEY, JSON.stringify(t)); }
1421 +
1422 +async function sbOpen(){
1423 + let ov = $("st-builder");
1424 + if(!ov){
1425 + ov = document.createElement("div");
1426 + ov.id = "st-builder";
1427 + ov.className = "stb-overlay hidden";
1428 + ov.onclick = e => { if(e.target === ov) sbClose(); };
1429 + document.body.appendChild(ov);
1430 + }
1431 + ov.classList.remove("hidden");
1432 + document.body.style.overflow = "hidden";
1433 + SB.err = "";
1434 + if(!SB.cat){
1435 + ov.innerHTML = `<div class="stb-panel"><div class="stb-head">
1436 + <h2>Rapport personnalisé</h2></div>
1437 + <div class="stb-col"><p class="klabel">Chargement du catalogue…</p></div></div>`;
1438 + try{
1439 + SB.cat = (await (await fetch(`/api/stats/catalog?${stParams()}`)).json()).blocks || [];
1440 + }catch(e){
1441 + ov.innerHTML = `<div class="stb-panel"><div class="stb-head"><h2>Rapport personnalisé</h2>
1442 + <button type="button" class="btn btn-ghost" onclick="sbClose()">✕ Fermer</button></div>
1443 + <div class="stb-col"><p class="stb-err">Catalogue indisponible — réessayez plus tard.</p></div></div>`;
1444 + return;
1445 + }
1446 + }
1447 + sbRender();
1448 +}
1449 +function sbClose(){
1450 + const ov = $("st-builder");
1451 + if(ov) ov.classList.add("hidden");
1452 + document.body.style.overflow = "";
1453 +}
1454 +
1455 +function sbAdd(key){
1456 + const b = SB.cat.find(x => x.key === key);
1457 + if(!b || SB.sel.some(s => s.key === key && s.render === b.default_render)) return;
1458 + SB.sel.push({ key, render: b.default_render });
1459 + sbRender();
1460 +}
1461 +function sbDel(i){ SB.sel.splice(i, 1); sbRender(); }
1462 +function sbMove(i, d){
1463 + const j = i + d;
1464 + if(j < 0 || j >= SB.sel.length) return;
1465 + [SB.sel[i], SB.sel[j]] = [SB.sel[j], SB.sel[i]];
1466 + sbRender();
1467 +}
1468 +function sbAll(){
1469 + SB.sel = SB.cat.map(b => ({ key: b.key, render: b.default_render }));
1470 + sbRender();
1471 +}
1472 +
1473 +function sbRender(){
1474 + const ov = $("st-builder");
1475 + const groups = {};
1476 + SB.cat.forEach(b => (groups[b.section] = groups[b.section] || []).push(b));
1477 + const selKeys = new Set(SB.sel.map(s => s.key));
1478 + const perLabel = (ST.from && ST.to) ? `${ST.from} → ${ST.to}`
1479 + : (ST_PERIODS.find(p => p[0] === ST.period) || ["","30 jours"])[1];
1480 + const tpls = sbTemplates();
1481 +
1482 + ov.innerHTML = `<div class="stb-panel">
1483 + <div class="stb-head">
1484 + <h2>Rapport personnalisé <span class="klabel">· période : ${esc(perLabel)}</span></h2>
1485 + <button type="button" class="btn btn-ghost" id="stb-close">✕ Fermer</button>
1486 + </div>
1487 + <div class="stb-body">
1488 + <div class="stb-col">
1489 + <h3>Blocs disponibles (${SB.cat.length})</h3>
1490 + <p class="klabel" style="margin:0 0 10px">Cliquez « + » pour ajouter un bloc au rapport.
1491 + Un même bloc peut être ajouté sous plusieurs rendus.</p>
1492 + ${Object.entries(groups).map(([sec, bs]) => `
1493 + <p class="stb-group">${esc(SB_SECTIONS[sec] || sec)}</p>
1494 + ${bs.map(b => `<div class="stb-av${selKeys.has(b.key) ? " stb-in" : ""}">
1495 + <span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
1496 + title="${esc(b.title)}">${esc(b.title)}</span>
1497 + <button type="button" class="btn btn-ghost" data-add="${esc(b.key)}"
1498 + aria-label="Ajouter ${esc(b.title)}">+</button></div>`).join("")}`).join("")}
1499 + </div>
1500 + <div class="stb-col">
1501 + <h3>Composition du rapport (${SB.sel.length})</h3>
1502 + <label class="klabel" for="stb-title">Titre du rapport</label>
1503 + <input class="input" id="stb-title" style="width:100%;margin:4px 0 12px"
1504 + maxlength="80" placeholder="Ex. : Revue mensuelle" value="${esc(SB.title)}">
1505 + ${SB.sel.length ? SB.sel.map((s, i) => {
1506 + const b = SB.cat.find(x => x.key === s.key) || { title: s.key, renders: [s.render] };
1507 + return `<div class="stb-item">
1508 + <button type="button" class="mv" data-up="${i}" ${i === 0 ? "disabled" : ""} aria-label="Monter">▲</button>
1509 + <button type="button" class="mv" data-dn="${i}" ${i === SB.sel.length - 1 ? "disabled" : ""} aria-label="Descendre">▼</button>
1510 + <span class="t" title="${esc(b.title)}"><b>${i + 1}.</b> ${esc(b.title)}</span>
1511 + ${b.renders.length > 1 ? `<select data-rd="${i}" aria-label="Rendu">
1512 + ${b.renders.map(r => `<option value="${r}"${r === s.render ? " selected" : ""}>${SB_RENDERS[r] || r}</option>`).join("")}
1513 + </select>` : `<span class="klabel">${SB_RENDERS[s.render] || s.render}</span>`}
1514 + <button type="button" class="mv" data-del="${i}" aria-label="Retirer">✕</button>
1515 + </div>`;
1516 + }).join("") : `<div class="stb-empty">Aucun bloc — ajoutez des blocs depuis la colonne de gauche,
1517 + ou chargez un modèle ci-dessous.</div>`}
1518 + <p style="display:flex;gap:8px;margin:10px 0 0">
1519 + <button type="button" class="btn btn-ghost" id="stb-all">Tout ajouter</button>
1520 + <button type="button" class="btn btn-ghost" id="stb-clear" ${SB.sel.length ? "" : "disabled"}>Vider</button>
1521 + </p>
1522 + ${SB.err ? `<p class="stb-err">${esc(SB.err)}</p>` : ""}
1523 + </div>
1524 + </div>
1525 + <div class="stb-foot">
1526 + <span class="stb-tpl">
1527 + <select class="input" id="stb-tpl" aria-label="Modèles sauvegardés" style="max-width:210px">
1528 + <option value="">Modèles (${tpls.length})…</option>
1529 + ${tpls.map((t, i) => `<option value="${i}">${esc(t.name)}</option>`).join("")}
1530 + </select>
1531 + <button type="button" class="btn btn-ghost" id="stb-save" ${SB.sel.length ? "" : "disabled"}>💾 Sauvegarder</button>
1532 + <button type="button" class="btn btn-ghost" id="stb-tpl-del" ${tpls.length ? "" : "disabled"}>🗑 Supprimer</button>
1533 + </span>
1534 + <button type="button" class="btn btn-primary" id="stb-go" ${SB.sel.length && !SB.busy ? "" : "disabled"}>
1535 + ${SB.busy ? "Génération…" : "⬇ Générer le PDF"}</button>
1536 + </div>
1537 + </div>`;
1538 +
1539 + $("stb-close").onclick = sbClose;
1540 + $("stb-all").onclick = sbAll;
1541 + $("stb-clear").onclick = () => { SB.sel = []; sbRender(); };
1542 + $("stb-title").oninput = e => { SB.title = e.target.value; };
1543 + ov.querySelectorAll("[data-add]").forEach(b => b.onclick = () => sbAdd(b.dataset.add));
1544 + ov.querySelectorAll("[data-up]").forEach(b => b.onclick = () => sbMove(+b.dataset.up, -1));
1545 + ov.querySelectorAll("[data-dn]").forEach(b => b.onclick = () => sbMove(+b.dataset.dn, 1));
1546 + ov.querySelectorAll("[data-del]").forEach(b => b.onclick = () => sbDel(+b.dataset.del));
1547 + ov.querySelectorAll("[data-rd]").forEach(sl => sl.onchange = () => {
1548 + SB.sel[+sl.dataset.rd].render = sl.value;
1549 + });
1550 + $("stb-save").onclick = () => {
1551 + const name = prompt("Nom du modèle :", SB.title || "Mon rapport");
1552 + if(!name) return;
1553 + const tpl = sbTemplates().filter(t => t.name !== name);
1554 + tpl.push({ name, title: SB.title, blocks: SB.sel.map(s => ({...s})) });
1555 + sbSaveTemplates(tpl);
1556 + sbRender();
1557 + };
1558 + $("stb-tpl").onchange = e => {
1559 + const t = sbTemplates()[+e.target.value];
1560 + if(!t) return;
1561 + SB.title = t.title || t.name;
1562 + SB.sel = (t.blocks || []).filter(s => SB.cat.some(b => b.key === s.key)).map(s => ({...s}));
1563 + sbRender();
1564 + };
1565 + $("stb-tpl-del").onclick = () => {
1566 + const sel = $("stb-tpl");
1567 + const t = sbTemplates()[+sel.value];
1568 + if(!t || !confirm(`Supprimer le modèle « ${t.name} » ?`)) return;
1569 + sbSaveTemplates(sbTemplates().filter(x => x.name !== t.name));
1570 + sbRender();
1571 + };
1572 + $("stb-go").onclick = sbGenerate;
1573 +}
1574 +
1575 +async function sbGenerate(){
1576 + if(SB.busy || !SB.sel.length) return;
1577 + SB.busy = true; SB.err = ""; sbRender();
1578 + try{
1579 + const body = { title: SB.title, period: ST.period, blocks: SB.sel };
1580 + if(ST.from && ST.to){ body.from = ST.from; body.to = ST.to; }
1581 + const r = await fetch("/api/stats/report/custom", {
1582 + method: "POST", headers: { "Content-Type": "application/json" },
1583 + body: JSON.stringify(body) });
1584 + if(!r.ok) throw new Error(`HTTP ${r.status}`);
1585 + const blob = await r.blob();
1586 + const cd = r.headers.get("Content-Disposition") || "";
1587 + const m = cd.match(/filename="?([^";]+)/);
1588 + const a = document.createElement("a");
1589 + a.href = URL.createObjectURL(blob);
1590 + a.download = m ? m[1] : "rapport-personnalise.pdf";
1591 + document.body.appendChild(a); a.click(); a.remove();
1592 + setTimeout(() => URL.revokeObjectURL(a.href), 4000);
1593 + }catch(e){
1594 + SB.err = "La génération a échoué — réessayez.";
1595 + }
1596 + SB.busy = false; sbRender();
1597 +}
1598 +
1599 +$("st-custom-btn").onclick = () => { SB.cat = null; SB.err = ""; sbOpen(); };
1600 +
1365 1601 /* ---------- vues ---------- */
1366 1602 const VIEWS = ["view-list","view-detail","view-contact","view-stats"];
1367 1603 function show(id){
modified frontend/src/ka/stats/SPEC.md +62 −1
@@ -1,4 +1,4 @@
1 −# ka-stats — module Stats commun Groupe KA (spec v2)
1 +# ka-stats — module Stats commun Groupe KA (spec v3)
2 2
3 3 Contrat partagé par les plateformes pour leurs pages **/stats** (tableau de
4 4 bord analytique) et les **exports PDF** estampillés Groupe-KA. Le visuel suit
@@ -10,6 +10,15 @@ sur les répartitions, statistiques de séries (min/max/moy/méd/σ), et **5
10 10 rapports PDF** au lieu de 2. Tous les nouveaux champs sont **optionnels** :
11 11 un dashboard v1 reste valide et se rend tel quel.
12 12
13 +**v3 (2026-08-23)** : **rapports personnalisés** — l'utilisateur compose son
14 +propre rapport PDF bloc par bloc : choix des données (catalogue dérivé du
15 +dashboard), du **rendu par bloc** (courbe/aire/barres/anneau/histogramme/
16 +heatmap/tableau…), de l'ordre, avec **modèles sauvegardés** (localStorage du
17 +site). Deux endpoints (`/api/stats/catalog`, `POST /api/stats/report/custom`,
18 +voir §3bis) + un constructeur dans la page /stats (`ReportBuilder` du kit —
19 +bouton « 🛠 Rapport personnalisé » à côté du menu PDF). Le PDF garde le
20 +gabarit estampillé Groupe-KA avec l'accent de la plateforme. v2 inchangée.
21 +
13 22 ## 1. Page /stats — structure obligatoire (dans cet ordre)
14 23
15 24 1. **Bandeau KPI** : 6–10 grandes cartes (`KpiCard`) — valeur, libellé,
@@ -135,6 +144,58 @@ gabarit en pdfkit) :
135 144 - A4 portrait, marges 18 mm, typo : Helvetica (fallback sûr) ou fonts TTF du
136 145 DS si présentes.
137 146
147 +## 3bis. Rapports personnalisés (v3)
148 +
149 +### Catalogue
150 +
151 +`GET /api/stats/catalog?period=…&from=&to=` →
152 +
153 +```jsonc
154 +{ "updated": "…", "period": { … },
155 + "blocks": [ { "key": "series:ajouts", // section[:id] — clé stable
156 + "section": "series",
157 + "title": "Événements ajoutés par jour",
158 + "renders": ["line","area","bar","table"], // rendus compatibles
159 + "default_render": "line",
160 + "count": 30 } ] } // taille indicative (optionnel)
161 +```
162 +
163 +Sections → rendus : `kpis` cards|table · `gauges` gauges|table ·
164 +`series:<id>` line|area|bar|table · `multiseries:<id>` lines|table ·
165 +`stacked:<id>` stacked|table · `breakdowns:<id>` donut|bars|table ·
166 +`distributions:<id>` histogram|table · `geo` bars|table ·
167 +`heatmap` heatmap|table · `hourly` heatmap|table · `tables:<id>` table ·
168 +`records` cards|table. **Toute donnée a un équivalent tableau.** Le catalogue
169 +est dérivé du dashboard (implémentation : `kapdf.catalog(dash)` /
170 +`catalogFromDashboard()` en TS) — zéro maintenance quand une métrique s'ajoute.
171 +
172 +### Génération
173 +
174 +`POST /api/stats/report/custom` — corps JSON :
175 +
176 +```jsonc
177 +{ "title": "Revue mensuelle", // ≤ 80 car., affiché en couverture
178 + "period": "30j", "from": "", "to": "", // mêmes règles que le dashboard
179 + "blocks": [ { "key": "kpis", "render": "cards" },
180 + { "key": "series:ajouts", "render": "bar" } ] } // ordre = ordre du PDF
181 +```
182 +
183 +→ `application/pdf`, filename `groupe-ka_<plateforme>_stats_<periode>_personnalise_<date>.pdf`.
184 +Clés inconnues ignorées ; rendu incompatible → rendu par défaut ; aucun bloc
185 +valide → **400**. Maximum 40 blocs. Couverture : type = « Rapport
186 +personnalisé — {title} » ; sommaire ; page de fin habituelle. Un même bloc
187 +peut apparaître plusieurs fois (ex. graphique + tableau).
188 +
189 +### Constructeur (front, kit)
190 +
191 +`ReportBuilder` (kacharts.tsx ; port vanilla pour les SPA sans React) :
192 +panneau modal 2 colonnes — catalogue groupé par section à gauche, composition
193 +ordonnée à droite (↑ ↓ ✕, sélecteur de rendu par bloc, titre). **Modèles** :
194 +sauvegarde/chargement/suppression nommés en localStorage (clé
195 +`ka-stats-rapports`, propre à l'origine du site). Bouton « Générer le PDF »
196 +→ POST + téléchargement blob. États busy/erreur propres, tactile ≥ 44 px,
197 +z-index `var(--z-modal, 900)`.
198 +
138 199 ## 4. Spécifique par plateforme (sections métier attendues)
139 200
140 201 - **groupe-ka** : tableau de bord maître — consolidation des plateformes
modified sortika/kapdf.py +398 −8
@@ -1,7 +1,7 @@
1 1 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 −# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v2
2 +# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v3
3 3 # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4 −# les rapports estampillés Groupe-KA. 5 modes :
4 +# les rapports estampillés Groupe-KA. 5 modes fixes :
5 5 # complet — toutes les sections (KPI, jauges, séries + stats, multi-
6 6 # séries, empilées, distributions, répartitions, géo,
7 7 # heatmap horaire, tableaux, records)
@@ -9,12 +9,19 @@
9 9 # tendances — KPI + toutes les séries temporelles + stats de séries
10 10 # repartitions — breakdowns, distributions, géo, activité horaire
11 11 # donnees — tous les tableaux en version longue (400 lignes max)
12 +# v3 : mode « personnalise » — l'utilisateur compose son rapport bloc par
13 +# bloc (choix des données ET du rendu par bloc : courbe/aire/barres/anneau/
14 +# heatmap/tableau…). catalog(dash) expose les blocs disponibles ; le rapport
15 +# suit une spec {"title": str, "blocks": [{"key": "series:ajouts",
16 +# "render": "bar"}, …]} et respecte l'ordre demandé.
12 17 # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.
13 18 # Usage :
14 −# from kapdf import GroupeKAReport, REPORT_MODES, filename
19 +# from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename
15 20 # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
16 21 # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
17 22 # mode="complet").build()
23 +# pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise",
24 +# spec={"title": "Mon rapport", "blocks": [...]}).build()
18 25 # Dépendance : pip install fpdf2 (aucune autre)
19 26 from __future__ import annotations
20 27
@@ -40,6 +47,100 @@ REPORT_MODES = {
40 47 "repartitions": "Répartitions & géographie",
41 48 "donnees": "Données détaillées",
42 49 }
50 +# v3 — mode composé par l'utilisateur (jamais dans le menu des modes fixes)
51 +CUSTOM_MODE = "personnalise"
52 +CUSTOM_LABEL = "Rapport personnalisé"
53 +
54 +# v3 — rendus proposés par type de bloc (le 1er est le rendu par défaut ;
55 +# « table » est toujours offert : toute donnée a un équivalent tableau)
56 +RENDER_LABELS = {
57 + "line": "Courbe", "area": "Aire", "bar": "Barres verticales",
58 + "bars": "Barres horizontales", "donut": "Anneau",
59 + "lines": "Multi-courbes", "stacked": "Barres empilées",
60 + "histogram": "Histogramme", "heatmap": "Heatmap",
61 + "cards": "Cartes", "gauges": "Jauges", "table": "Tableau",
62 +}
63 +SECTION_LABELS = {
64 + "kpis": "Indicateurs", "gauges": "Taux & couvertures",
65 + "series": "Évolution", "multiseries": "Comparaisons",
66 + "stacked": "Compositions", "breakdowns": "Répartitions",
67 + "distributions": "Distributions", "geo": "Géographie",
68 + "heatmap": "Calendrier", "hourly": "Activité horaire",
69 + "tables": "Tableaux", "records": "Records",
70 +}
71 +
72 +
73 +def catalog(dash: dict) -> list[dict]:
74 + """v3 — blocs composables d'un dashboard : ce que le constructeur de
75 + rapports personnalisés peut inclure, avec les rendus compatibles.
76 + key = section[:id] ; l'ordre renvoyé = ordre naturel du dashboard."""
77 + out: list[dict] = []
78 +
79 + def add(key, title, renders, default=None, count=None):
80 + b = {"key": key, "section": key.split(":")[0], "title": title,
81 + "renders": renders, "default_render": default or renders[0]}
82 + if count is not None:
83 + b["count"] = count
84 + out.append(b)
85 +
86 + if dash.get("kpis"):
87 + add("kpis", "Indicateurs clés (KPI)", ["cards", "table"],
88 + count=len(dash["kpis"]))
89 + gs = [g for g in (dash.get("gauges") or [])
90 + if isinstance(g.get("value"), (int, float)) and g.get("max")]
91 + if gs:
92 + add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"],
93 + count=len(gs))
94 + for s in dash.get("series") or []:
95 + if len(s.get("points") or []) < 2:
96 + continue
97 + kind = s.get("kind") or "line"
98 + default = kind if kind in ("line", "area", "bar") else "line"
99 + add(f"series:{s.get('id')}", s.get("title", ""),
100 + ["line", "area", "bar", "table"], default,
101 + len(s.get("points") or []))
102 + for ms in dash.get("multiseries") or []:
103 + if not (ms.get("series") or []):
104 + continue
105 + add(f"multiseries:{ms.get('id')}", ms.get("title", ""),
106 + ["lines", "table"], count=len(ms["series"]))
107 + for st in dash.get("stacked") or []:
108 + if not (st.get("points") or []):
109 + continue
110 + add(f"stacked:{st.get('id')}", st.get("title", ""),
111 + ["stacked", "table"], count=len(st.get("keys") or []))
112 + for b in dash.get("breakdowns") or []:
113 + if not (b.get("items") or []):
114 + continue
115 + default = "donut" if b.get("kind") == "donut" else "bars"
116 + add(f"breakdowns:{b.get('id')}", b.get("title", ""),
117 + ["donut", "bars", "table"], default, len(b["items"]))
118 + for d in dash.get("distributions") or []:
119 + if not (d.get("bins") or []):
120 + continue
121 + add(f"distributions:{d.get('id')}", d.get("title", ""),
122 + ["histogram", "table"], count=len(d["bins"]))
123 + geo = dash.get("geo") or {}
124 + if geo.get("items"):
125 + add("geo", geo.get("title", "Répartition géographique"),
126 + ["bars", "table"], count=len(geo["items"]))
127 + hm = dash.get("heatmap") or {}
128 + if hm.get("cells"):
129 + add("heatmap", hm.get("title", "Calendrier d'activité"),
130 + ["heatmap", "table"])
131 + hr = dash.get("hourly") or {}
132 + if hr.get("cells"):
133 + add("hourly", hr.get("title", "Activité par jour et heure"),
134 + ["heatmap", "table"])
135 + for t in dash.get("tables") or []:
136 + if not (t.get("rows") or []):
137 + continue
138 + add(f"tables:{t.get('id')}", t.get("title", ""), ["table"],
139 + count=len(t["rows"]))
140 + if dash.get("records"):
141 + add("records", "Records & faits marquants", ["cards", "table"],
142 + count=len(dash["records"]))
143 + return out
43 144
44 145 EMAILS = [
45 146 ("contact@groupe-ka.com", "Projets, partenariats & données"),
@@ -127,16 +228,25 @@ class _PDF(FPDF):
127 228
128 229
129 230 class GroupeKAReport:
130 − def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
231 + def __init__(self, site: dict, dashboard: dict, mode: str = "complet",
232 + spec: dict | None = None):
131 233 self.site = site
132 234 self.d = dashboard
133 − self.mode = mode if mode in REPORT_MODES else "complet"
235 + self.mode = mode if (mode in REPORT_MODES or mode == CUSTOM_MODE) else "complet"
236 + self.spec = spec or {}
134 237 self.accent = _hex(site.get("accent", "#d9f26b"))
135 238 period = dashboard.get("period", {}) or {}
136 239 self.period_label = period.get("label") or "toute la période"
137 240 self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
138 241 self.toc: list[tuple[str, int]] = []
139 242
243 + @property
244 + def mode_label(self) -> str:
245 + if self.mode == CUSTOM_MODE:
246 + t = str(self.spec.get("title") or "").strip()
247 + return f"{CUSTOM_LABEL} — {t}" if t else CUSTOM_LABEL
248 + return REPORT_MODES[self.mode]
249 +
140 250 # ---------- primitives ----------
141 251 def _card(self, x, y, w, h, fill=WHITE):
142 252 p = self.pdf
@@ -213,7 +323,7 @@ class GroupeKAReport:
213 323 p.set_xy(24, 100)
214 324 p.set_font("helvetica", "", 13)
215 325 p.set_text_color(*INK2)
216 − p.multi_cell(150, 7, f"{REPORT_MODES[self.mode]} — {wm}")
326 + p.multi_cell(150, 7, f"{self.mode_label} — {wm}")
217 327 now = datetime.now(ZoneInfo("America/Toronto"))
218 328 per = self.d.get("period", {}) or {}
219 329 p.set_xy(24, 125)
@@ -222,7 +332,7 @@ class GroupeKAReport:
222 332 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
223 333 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
224 334 ("Plateforme", "https://" + self.site.get("domain", "")),
225 − ("Type de rapport", REPORT_MODES[self.mode]),
335 + ("Type de rapport", self.mode_label),
226 336 ]
227 337 y = 128
228 338 for k, v in rows:
@@ -685,6 +795,271 @@ class GroupeKAReport:
685 795 p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")
686 796 p.set_y(y0 + ly + 7 * chh + 5)
687 797
798 + def _calheat(self, hm):
799 + """v3 — calendrier de chaleur 26 semaines (équivalent PDF du
800 + CalendarHeatmap du kit front) : colonnes = semaines, lignes = jours."""
801 + from datetime import date as _date, timedelta as _td
802 + cells = hm.get("cells") or []
803 + vals = {c.get("date"): c.get("value") or 0 for c in cells if c.get("date")}
804 + if not vals:
805 + return
806 + p = self.pdf
807 + if p.get_y() > 215:
808 + p.add_page()
809 + self._chart_title(hm.get("title", "Calendrier d'activité"))
810 + try:
811 + end = _date.fromisoformat(max(vals))
812 + except ValueError:
813 + return
814 + weeks = 26
815 + start = end - _td(days=weeks * 7 - 1)
816 + start -= _td(days=start.weekday()) # lundi
817 + vmax = max(vals.values()) or 1
818 + x0, y0 = p.l_margin, p.get_y()
819 + cw, lx, ly = 6.3, 10, 4
820 + dows = ["Lun", "", "Mer", "", "Ven", "", "Dim"]
821 + p.set_font("helvetica", "", 5.8)
822 + p.set_text_color(*INK3)
823 + for d in range(7):
824 + if dows[d]:
825 + p.set_xy(x0, y0 + ly + d * cw + 1.2)
826 + p.cell(lx - 1, 3, dows[d], align="R")
827 + for w in range(weeks):
828 + monday = start + _td(days=7 * w)
829 + if monday.day <= 7: # étiquette de mois à la 1re semaine du mois
830 + p.set_xy(x0 + lx + w * cw, y0)
831 + p.cell(cw * 4, 3, monday.strftime("%m"))
832 + for d in range(7):
833 + day = monday + _td(days=d)
834 + v = vals.get(day.isoformat(), 0)
835 + f = 0.15 + 0.85 * (v / vmax) if v else 0.0
836 + col = (tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f)
837 + for j in range(3)) if v else (235, 233, 228))
838 + p.set_fill_color(*col)
839 + p.set_draw_color(215, 213, 207)
840 + p.set_line_width(0.1)
841 + p.rect(x0 + lx + w * cw, y0 + ly + d * cw, cw - 0.5, cw - 0.5,
842 + style="DF")
843 + p.set_y(y0 + ly + 7 * cw + 5)
844 +
845 + # ---------- v3 : conversions bloc → tableau ----------
846 + @staticmethod
847 + def _serie_as_table(s):
848 + unit = s.get("unit") or "Valeur"
849 + cols = ["Date", unit.capitalize()]
850 + cmp_ = s.get("compare") or []
851 + if cmp_:
852 + cols.append("Période comparée")
853 + rows = []
854 + for i, pt in enumerate(s.get("points") or []):
855 + row = [str(pt.get("t", "")), pt.get("v", "")]
856 + if cmp_:
857 + row.append(cmp_[i]["v"] if i < len(cmp_) else "")
858 + rows.append(row)
859 + return {"id": s.get("id"), "title": s.get("title", ""),
860 + "columns": cols, "rows": rows}
861 +
862 + @staticmethod
863 + def _multi_as_table(ms):
864 + labels = [s.get("label", "") for s in (ms.get("series") or [])][:4]
865 + by_t: dict[str, dict] = {}
866 + for s in (ms.get("series") or [])[:4]:
867 + for pt in s.get("points") or []:
868 + by_t.setdefault(str(pt.get("t", "")), {})[s.get("label", "")] = pt.get("v")
869 + rows = [[t] + [by_t[t].get(lbl, "") for lbl in labels]
870 + for t in sorted(by_t)]
871 + return {"id": ms.get("id"), "title": ms.get("title", ""),
872 + "columns": ["Date"] + labels, "rows": rows}
873 +
874 + @staticmethod
875 + def _stacked_as_table(st):
876 + keys = (st.get("keys") or [])[:6]
877 + rows = []
878 + for pt in st.get("points") or []:
879 + vs = [(pt.get("values") or [])[j] if j < len(pt.get("values") or []) else 0
880 + for j in range(len(keys))]
881 + rows.append([str(pt.get("t", ""))] + vs + [sum(v or 0 for v in vs)])
882 + return {"id": st.get("id"), "title": st.get("title", ""),
883 + "columns": ["Date"] + list(keys) + ["Total"], "rows": rows}
884 +
885 + @staticmethod
886 + def _items_as_table(id_, title, items, label_col="Libellé"):
887 + items = items or []
888 + with_delta = any(it.get("delta_pct") is not None for it in items)
889 + cols = [label_col, "Valeur"] + (["delta %"] if with_delta else [])
890 + rows = []
891 + for it in items:
892 + row = [str(it.get("label", "")), it.get("value", "")]
893 + if with_delta:
894 + d = it.get("delta_pct")
895 + row.append("" if d is None else f"{'+' if d >= 0 else ''}{d} %")
896 + rows.append(row)
897 + return {"id": id_, "title": title, "columns": cols, "rows": rows}
898 +
899 + def _kpis_as_table(self):
900 + rows = []
901 + for k in self.d.get("kpis") or []:
902 + v = k.get("value")
903 + val = (_fr(v) if isinstance(v, (int, float)) else str(v)) + \
904 + ((" " + k["unit"]) if k.get("unit") else "")
905 + d = k.get("delta_pct")
906 + rows.append([str(k.get("label", "")), val,
907 + "" if d is None else f"{'+' if d >= 0 else ''}{d} %"])
908 + return {"id": "kpis", "title": "Indicateurs clés",
909 + "columns": ["Indicateur", "Valeur", "delta %"], "rows": rows}
910 +
911 + def _gauges_as_table(self):
912 + rows = [[str(g.get("label", "")),
913 + f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}",
914 + _fr(g["max"]), f"{100.0 * g['value'] / g['max']:.0f} %"]
915 + for g in self.d.get("gauges") or []
916 + if isinstance(g.get("value"), (int, float)) and g.get("max")]
917 + return {"id": "gauges", "title": "Taux & couvertures",
918 + "columns": ["Mesure", "Valeur", "Max", "Part"], "rows": rows}
919 +
920 + def _records_as_table(self):
921 + rows = [[str(r.get("label", "")), str(r.get("value", "")),
922 + str(r.get("date", "") or "")]
923 + for r in self.d.get("records") or []]
924 + return {"id": "records", "title": "Records & faits marquants",
925 + "columns": ["Fait marquant", "Valeur", "Date"], "rows": rows}
926 +
927 + @staticmethod
928 + def _heatmap_as_table(hm, title):
929 + cells = sorted((hm.get("cells") or []),
930 + key=lambda c: -(c.get("value") or 0))[:40]
931 + return {"id": "heatmap", "title": title + " — jours les plus chargés",
932 + "columns": ["Date", "Valeur"],
933 + "rows": [[c.get("date", ""), c.get("value") or 0] for c in cells]}
934 +
935 + @staticmethod
936 + def _hourly_as_table(hr, title):
937 + days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi",
938 + "Dimanche"]
939 + cells = sorted((hr.get("cells") or []),
940 + key=lambda c: -(c.get("value") or 0))[:40]
941 + return {"id": "hourly", "title": title + " — créneaux les plus actifs",
942 + "columns": ["Jour", "Heure", "Valeur"],
943 + "rows": [[days[c["dow"]] if 0 <= c.get("dow", -1) <= 6 else "?",
944 + f"{c.get('hour', '?')} h", c.get("value") or 0]
945 + for c in cells]}
946 +
947 + # ---------- v3 : rendu d'un bloc du rapport personnalisé ----------
948 + def _find(self, coll: str, id_: str):
949 + for it in self.d.get(coll) or []:
950 + if str(it.get("id")) == id_:
951 + return it
952 + return None
953 +
954 + def _toc_mark(self, title: str):
955 + """Blocs graphiques du mode personnalisé : entrée de sommaire sans
956 + _section_title (le graphique porte déjà son titre)."""
957 + if self.pdf.get_y() > 235:
958 + self.pdf.add_page()
959 + self.toc.append((title, self.pdf.page_no()))
960 +
961 + def _render_block(self, key: str, render: str):
962 + section, _, id_ = key.partition(":")
963 + if section == "kpis":
964 + self._table(self._kpis_as_table()) if render == "table" else self._kpis()
965 + elif section == "gauges":
966 + self._table(self._gauges_as_table()) if render == "table" else self._gauges()
967 + elif section == "records":
968 + self._table(self._records_as_table()) if render == "table" else self._records()
969 + elif section == "series":
970 + s = self._find("series", id_)
971 + if not s:
972 + return
973 + if render == "table":
974 + self._table(self._serie_as_table(s), max_rows=400)
975 + else:
976 + s2 = dict(s)
977 + if render in ("line", "area", "bar"):
978 + s2["kind"] = render
979 + self._toc_mark(s2.get("title", ""))
980 + if s2.get("kind") == "bar":
981 + self._vbars(s2)
982 + else:
983 + self._line_chart(s2, with_stats=True)
984 + elif section == "multiseries":
985 + ms = self._find("multiseries", id_)
986 + if not ms:
987 + return
988 + if render == "table":
989 + self._table(self._multi_as_table(ms), max_rows=400)
990 + else:
991 + self._toc_mark(ms.get("title", ""))
992 + self._multiline(ms)
993 + elif section == "stacked":
994 + st = self._find("stacked", id_)
995 + if not st:
996 + return
997 + if render == "table":
998 + self._table(self._stacked_as_table(st), max_rows=400)
999 + else:
1000 + self._toc_mark(st.get("title", ""))
1001 + self._stacked(st)
1002 + elif section == "breakdowns":
1003 + b = self._find("breakdowns", id_)
1004 + if not b:
1005 + return
1006 + if render == "table":
1007 + self._table(self._items_as_table(id_, b.get("title", ""),
1008 + b.get("items")), max_rows=400)
1009 + else:
1010 + self._toc_mark(b.get("title", ""))
1011 + if render == "donut":
1012 + self._donut(b)
1013 + else:
1014 + self._bars(b.get("title", ""), b.get("items"))
1015 + elif section == "distributions":
1016 + d = self._find("distributions", id_)
1017 + if not d:
1018 + return
1019 + if render == "table":
1020 + bins = [{"label": bn.get("label"), "value": bn.get("value")}
1021 + for bn in d.get("bins") or []]
1022 + self._table(self._items_as_table(id_, d.get("title", ""), bins,
1023 + label_col="Tranche"))
1024 + else:
1025 + self._toc_mark(d.get("title", ""))
1026 + self._vbars(d)
1027 + elif section == "geo":
1028 + geo = self.d.get("geo") or {}
1029 + if not geo.get("items"):
1030 + return
1031 + title = geo.get("title", "Répartition géographique")
1032 + if render == "table":
1033 + self._table(self._items_as_table("geo", title, geo["items"],
1034 + label_col="Zone"), max_rows=400)
1035 + else:
1036 + self._toc_mark(title)
1037 + self._bars(title, geo["items"])
1038 + elif section == "heatmap":
1039 + hm = self.d.get("heatmap") or {}
1040 + if not hm.get("cells"):
1041 + return
1042 + title = hm.get("title", "Calendrier d'activité")
1043 + if render == "table":
1044 + self._table(self._heatmap_as_table(hm, title))
1045 + else:
1046 + self._toc_mark(title)
1047 + self._calheat(hm)
1048 + elif section == "hourly":
1049 + hr = self.d.get("hourly") or {}
1050 + if not hr.get("cells"):
1051 + return
1052 + title = hr.get("title", "Activité par jour et heure")
1053 + if render == "table":
1054 + self._table(self._hourly_as_table(hr, title))
1055 + else:
1056 + self._toc_mark(title)
1057 + self._hourly()
1058 + elif section == "tables":
1059 + t = self._find("tables", id_)
1060 + if t:
1061 + self._table(t, max_rows=400)
1062 +
688 1063 def _table(self, t, max_rows=200):
689 1064 p = self.pdf
690 1065 cols = t.get("columns") or []
@@ -809,7 +1184,7 @@ class GroupeKAReport:
809 1184 p = self.pdf
810 1185 p.alias_nb_pages()
811 1186 self._cover()
812 − with_toc = self.mode in ("complet", "donnees")
1187 + with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE)
813 1188 toc_page_no = None
814 1189 if self.mode == "synthese":
815 1190 p.add_page()
@@ -835,6 +1210,21 @@ class GroupeKAReport:
835 1210 for t in self.d.get("tables") or []:
836 1211 self._table(t, max_rows=400)
837 1212 self._final_page()
1213 + elif self.mode == CUSTOM_MODE:
1214 + p.add_page()
1215 + toc_page_no = p.page_no()
1216 + p.add_page()
1217 + known = {b["key"]: b for b in catalog(self.d)}
1218 + for blk in self.spec.get("blocks") or []:
1219 + key = str(blk.get("key", ""))
1220 + b = known.get(key)
1221 + if not b:
1222 + continue
1223 + render = str(blk.get("render") or "")
1224 + if render not in b["renders"]:
1225 + render = b["default_render"]
1226 + self._render_block(key, render)
1227 + self._final_page()
838 1228 else: # complet
839 1229 p.add_page()
840 1230 toc_page_no = p.page_no()
modified sortika/web.py +36 −1
@@ -10,7 +10,7 @@ import json
10 10 from datetime import date
11 11 from pathlib import Path
12 12
13 −from fastapi import FastAPI, HTTPException, Query
13 +from fastapi import Body, FastAPI, HTTPException, Query
14 14 from fastapi.middleware.cors import CORSMiddleware
15 15 from fastapi.middleware.gzip import GZipMiddleware
16 16 from fastapi.responses import FileResponse, Response
@@ -219,6 +219,41 @@ def stats_report(
219 219 f'attachment; filename="{fname}"'})
220 220
221 221
222 +@app.get("/api/stats/catalog")
223 +def stats_catalog(
224 + period: str = Query("30j"),
225 + date_from: str = Query("", alias="from"),
226 + date_to: str = Query("", alias="to"),
227 +) -> dict:
228 + """v3 — blocs composables pour le constructeur de rapports personnalisés."""
229 + dash = kstats.dashboard(period, date_from, date_to)
230 + return {"updated": dash.get("updated"), "period": dash.get("period"),
231 + "blocks": kapdf.catalog(dash)}
232 +
233 +
234 +@app.post("/api/stats/report/custom")
235 +def stats_report_custom(spec: dict = Body(...)) -> Response:
236 + """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",
237 + "blocks": [{"key": "series:ajouts", "render": "bar"}, …]}."""
238 + period = str(spec.get("period") or "30j")
239 + dfrom = str(spec.get("from") or "")
240 + dto = str(spec.get("to") or "")
241 + dash = kstats.dashboard(period, dfrom, dto)
242 + known = {b["key"] for b in kapdf.catalog(dash)}
243 + blocks = [b for b in (spec.get("blocks") or [])
244 + if isinstance(b, dict) and b.get("key") in known][:40]
245 + if not blocks:
246 + raise HTTPException(400, "Aucun bloc valide dans la composition")
247 + pdf = kapdf.GroupeKAReport(
248 + site=SITE, dashboard=dash, mode=kapdf.CUSTOM_MODE,
249 + spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},
250 + ).build()
251 + fname = kapdf.filename("sorti-ka", period, kapdf.CUSTOM_MODE)
252 + return Response(pdf, media_type="application/pdf",
253 + headers={"Content-Disposition":
254 + f'attachment; filename="{fname}"'})
255 +
256 +
222 257 @app.get("/api/regions")
223 258 def regions() -> list[str]:
224 259 return REGIONS
225 260