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)
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 | |