SPB Git

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.

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 (Aug 23, 2026) parent f827502 Browse files

4 changed files +732 −10

modified frontend/index.html +236 −0
@@ -344,6 +344,41 @@ button{font-family:inherit}
344 .st-pdf-drop button+button{border-top:1px solid var(--line)} 344 .st-pdf-drop button+button{border-top:1px solid var(--line)}
345 .st-pdf-drop button:hover{background:var(--accent-soft)} 345 .st-pdf-drop button:hover{background:var(--accent-soft)}
346 .st-pdf-drop small{font-family:var(--font-mono);font-size:10px;color:var(--ink-3)} 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 .st-kpi .spark{margin-top:8px} 382 .st-kpi .spark{margin-top:8px}
348 .st-kpi .spark svg{display:block;width:100%;height:26px} 383 .st-kpi .spark svg{display:block;width:100%;height:26px}
349 .st-gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(200px,100%),1fr));gap:14px;margin-top:18px} 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 <button type="button" data-mode="donnees">Données détaillées <small>tableaux longs</small></button> 529 <button type="button" data-mode="donnees">Données détaillées <small>tableaux longs</small></button>
495 </span> 530 </span>
496 </span> 531 </span>
532+ <button type="button" class="btn btn-ghost" id="st-custom-btn">🛠 Rapport personnalisé</button>
497 </div> 533 </div>
498 </div> 534 </div>
499 <div class="st-kpis" id="st-kpis"></div> 535 <div class="st-kpis" id="st-kpis"></div>
@@ -1362,6 +1398,206 @@ function showStats(){
1362 $("st-pdf-full").onclick = e => stPdf("complet", e.currentTarget); 1398 $("st-pdf-full").onclick = e => stPdf("complet", e.currentTarget);
1363 stRenderPeriods(); 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 /* ---------- vues ---------- */ 1601 /* ---------- vues ---------- */
1366 const VIEWS = ["view-list","view-detail","view-contact","view-stats"]; 1602 const VIEWS = ["view-list","view-detail","view-contact","view-stats"];
1367 function show(id){ 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 Contrat partagé par les plateformes pour leurs pages **/stats** (tableau de 3 Contrat partagé par les plateformes pour leurs pages **/stats** (tableau de
4 bord analytique) et les **exports PDF** estampillés Groupe-KA. Le visuel suit 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 rapports PDF** au lieu de 2. Tous les nouveaux champs sont **optionnels** : 10 rapports PDF** au lieu de 2. Tous les nouveaux champs sont **optionnels** :
11 un dashboard v1 reste valide et se rend tel quel. 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 ## 1. Page /stats — structure obligatoire (dans cet ordre) 22 ## 1. Page /stats — structure obligatoire (dans cet ordre)
14 23
15 1. **Bandeau KPI** : 6–10 grandes cartes (`KpiCard`) — valeur, libellé, 24 1. **Bandeau KPI** : 6–10 grandes cartes (`KpiCard`) — valeur, libellé,
@@ -135,6 +144,58 @@ gabarit en pdfkit) :
135 - A4 portrait, marges 18 mm, typo : Helvetica (fallback sûr) ou fonts TTF du 144 - A4 portrait, marges 18 mm, typo : Helvetica (fallback sûr) ou fonts TTF du
136 DS si présentes. 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 ## 4. Spécifique par plateforme (sections métier attendues) 199 ## 4. Spécifique par plateforme (sections métier attendues)
139 200
140 - **groupe-ka** : tableau de bord maître — consolidation des plateformes 201 - **groupe-ka** : tableau de bord maître — consolidation des plateformes
modified sortika/kapdf.py +398 −8
@@ -1,7 +1,7 @@
1 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai 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 # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit 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 # complet — toutes les sections (KPI, jauges, séries + stats, multi- 5 # complet — toutes les sections (KPI, jauges, séries + stats, multi-
6 # séries, empilées, distributions, répartitions, géo, 6 # séries, empilées, distributions, répartitions, géo,
7 # heatmap horaire, tableaux, records) 7 # heatmap horaire, tableaux, records)
@@ -9,12 +9,19 @@
9 # tendances — KPI + toutes les séries temporelles + stats de séries 9 # tendances — KPI + toutes les séries temporelles + stats de séries
10 # repartitions — breakdowns, distributions, géo, activité horaire 10 # repartitions — breakdowns, distributions, géo, activité horaire
11 # donnees — tous les tableaux en version longue (400 lignes max) 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 # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque. 17 # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.
13 # Usage : 18 # Usage :
14−# from kapdf import GroupeKAReport, REPORT_MODES, filename 19+# from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename
15 # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00", 20 # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
16 # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json, 21 # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
17 # mode="complet").build() 22 # mode="complet").build()
23+# pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise",
24+# spec={"title": "Mon rapport", "blocks": [...]}).build()
18 # Dépendance : pip install fpdf2 (aucune autre) 25 # Dépendance : pip install fpdf2 (aucune autre)
19 from __future__ import annotations 26 from __future__ import annotations
20 27
@@ -40,6 +47,100 @@ REPORT_MODES = {
40 "repartitions": "Répartitions & géographie", 47 "repartitions": "Répartitions & géographie",
41 "donnees": "Données détaillées", 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 EMAILS = [ 145 EMAILS = [
45 ("contact@groupe-ka.com", "Projets, partenariats & données"), 146 ("contact@groupe-ka.com", "Projets, partenariats & données"),
@@ -127,16 +228,25 @@ class _PDF(FPDF):
127 228
128 229
129 class GroupeKAReport: 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 self.site = site 233 self.site = site
132 self.d = dashboard 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 self.accent = _hex(site.get("accent", "#d9f26b")) 237 self.accent = _hex(site.get("accent", "#d9f26b"))
135 period = dashboard.get("period", {}) or {} 238 period = dashboard.get("period", {}) or {}
136 self.period_label = period.get("label") or "toute la période" 239 self.period_label = period.get("label") or "toute la période"
137 self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label) 240 self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
138 self.toc: list[tuple[str, int]] = [] 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 # ---------- primitives ---------- 250 # ---------- primitives ----------
141 def _card(self, x, y, w, h, fill=WHITE): 251 def _card(self, x, y, w, h, fill=WHITE):
142 p = self.pdf 252 p = self.pdf
@@ -213,7 +323,7 @@ class GroupeKAReport:
213 p.set_xy(24, 100) 323 p.set_xy(24, 100)
214 p.set_font("helvetica", "", 13) 324 p.set_font("helvetica", "", 13)
215 p.set_text_color(*INK2) 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 now = datetime.now(ZoneInfo("America/Toronto")) 327 now = datetime.now(ZoneInfo("America/Toronto"))
218 per = self.d.get("period", {}) or {} 328 per = self.d.get("period", {}) or {}
219 p.set_xy(24, 125) 329 p.set_xy(24, 125)
@@ -222,7 +332,7 @@ class GroupeKAReport:
222 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")), 332 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
223 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"), 333 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
224 ("Plateforme", "https://" + self.site.get("domain", "")), 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 y = 128 337 y = 128
228 for k, v in rows: 338 for k, v in rows:
@@ -685,6 +795,271 @@ class GroupeKAReport:
685 p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF") 795 p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")
686 p.set_y(y0 + ly + 7 * chh + 5) 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 def _table(self, t, max_rows=200): 1063 def _table(self, t, max_rows=200):
689 p = self.pdf 1064 p = self.pdf
690 cols = t.get("columns") or [] 1065 cols = t.get("columns") or []
@@ -809,7 +1184,7 @@ class GroupeKAReport:
809 p = self.pdf 1184 p = self.pdf
810 p.alias_nb_pages() 1185 p.alias_nb_pages()
811 self._cover() 1186 self._cover()
812− with_toc = self.mode in ("complet", "donnees") 1187+ with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE)
813 toc_page_no = None 1188 toc_page_no = None
814 if self.mode == "synthese": 1189 if self.mode == "synthese":
815 p.add_page() 1190 p.add_page()
@@ -835,6 +1210,21 @@ class GroupeKAReport:
835 for t in self.d.get("tables") or []: 1210 for t in self.d.get("tables") or []:
836 self._table(t, max_rows=400) 1211 self._table(t, max_rows=400)
837 self._final_page() 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 else: # complet 1228 else: # complet
839 p.add_page() 1229 p.add_page()
840 toc_page_no = p.page_no() 1230 toc_page_no = p.page_no()
modified sortika/web.py +36 −1
@@ -10,7 +10,7 @@ import json
10 from datetime import date 10 from datetime import date
11 from pathlib import Path 11 from pathlib import Path
12 12
13−from fastapi import FastAPI, HTTPException, Query 13+from fastapi import Body, FastAPI, HTTPException, Query
14 from fastapi.middleware.cors import CORSMiddleware 14 from fastapi.middleware.cors import CORSMiddleware
15 from fastapi.middleware.gzip import GZipMiddleware 15 from fastapi.middleware.gzip import GZipMiddleware
16 from fastapi.responses import FileResponse, Response 16 from fastapi.responses import FileResponse, Response
@@ -219,6 +219,41 @@ def stats_report(
219 f'attachment; filename="{fname}"'}) 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 @app.get("/api/regions") 257 @app.get("/api/regions")
223 def regions() -> list[str]: 258 def regions() -> list[str]:
224 return REGIONS 259 return REGIONS
225 260
Menu

Theme