SPB Git

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

stats v3 : rapports PDF personnalisés (catalogue, rendu au choix, ReportBuilder)

Simon-Pierre Boucher committed (Aug 23, 2026) parent 23651d0 Browse files

4 changed files +718 −12

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 frontend/src/ka/stats/kacharts.tsx +214 −2
@@ -278,7 +278,7 @@ export function MultiLineChart({ ms, height = 260 }: { ms: MultiSerie; height?:
278 {hi !== null && ( 278 {hi !== null && (
279 <p className="chip" style={{ marginTop: 8, display: "inline-flex", gap: 12, flexWrap: "wrap" }}> 279 <p className="chip" style={{ marginTop: 8, display: "inline-flex", gap: 12, flexWrap: "wrap" }}>
280 <b>{ref[hi]?.t}</b> 280 <b>{ref[hi]?.t}</b>
281− {shown.map((s, i) => ( 281+ {shown.map((s) => (
282 <span key={s.label}>{s.label} : <b>{s.points[hi] ? fmtNum(s.points[hi].v) : "—"}{ms.unit ? ` ${ms.unit}` : ""}</b></span> 282 <span key={s.label}>{s.label} : <b>{s.points[hi] ? fmtNum(s.points[hi].v) : "—"}{ms.unit ? ` ${ms.unit}` : ""}</b></span>
283 ))} 283 ))}
284 </p> 284 </p>
@@ -676,10 +676,11 @@ export function RecordCard({ r }: { r: RecordFact }) {
676 ); 676 );
677 } 677 }
678 678
679−/* ---------- Menu de rapports PDF (5 rapports) ---------- */ 679+/* ---------- Menu de rapports PDF (5 rapports + personnalisé v3) ---------- */
680 export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) { 680 export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) {
681 const [open, setOpen] = useState(false); 681 const [open, setOpen] = useState(false);
682 const [busy, setBusy] = useState<string | null>(null); 682 const [busy, setBusy] = useState<string | null>(null);
683+ const [builder, setBuilder] = useState(false);
683 const box = useRef<HTMLSpanElement>(null); 684 const box = useRef<HTMLSpanElement>(null);
684 useEffect(() => { 685 useEffect(() => {
685 if (!open) return; 686 if (!open) return;
@@ -715,6 +716,13 @@ export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }:
715 aria-haspopup="menu" aria-expanded={open}> 716 aria-haspopup="menu" aria-expanded={open}>
716 Autres rapports ▾ 717 Autres rapports ▾
717 </button> 718 </button>
719+ <button type="button" className="btn btn-ghost" onClick={() => setBuilder(true)} disabled={!!busy}>
720+ 🛠 Rapport personnalisé
721+ </button>
722+ {builder && (
723+ <ReportBuilder period={period} from={from} to={to} endpoint={endpoint}
724+ onClose={() => setBuilder(false)} />
725+ )}
718 {open && ( 726 {open && (
719 <div role="menu" className="card" style={{ position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 50, minWidth: 300, padding: 6, background: "var(--surface)", boxShadow: "0 10px 28px rgba(20,24,20,0.18)" }}> 727 <div role="menu" className="card" style={{ position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 50, minWidth: 300, padding: 6, background: "var(--surface)", boxShadow: "0 10px 28px rgba(20,24,20,0.18)" }}>
720 {REPORT_MODES.map((m) => ( 728 {REPORT_MODES.map((m) => (
@@ -732,6 +740,210 @@ export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }:
732 ); 740 );
733 } 741 }
734 742
743+/* ---------- v3 : constructeur de rapports personnalisés ----------
744+ Compose un PDF bloc par bloc : catalogue dérivé du dashboard
745+ (GET /api/stats/catalog), rendu au choix par bloc, ordre libre, modèles
746+ sauvegardés en localStorage (clé ka-stats-rapports, propre au site).
747+ Contrat : SPEC.md §3bis. Rendu dans PdfButton — aucune modif des pages. */
748+export type CatalogBlock = {
749+ key: string; section: string; title: string;
750+ renders: string[]; default_render: string; count?: number;
751+};
752+type BuilderSel = { key: string; render: string };
753+type BuilderTpl = { name: string; title: string; blocks: BuilderSel[] };
754+
755+const RENDER_LABELS: Record<string, string> = {
756+ line: "Courbe", area: "Aire", bar: "Barres verticales",
757+ bars: "Barres horizontales", donut: "Anneau", lines: "Multi-courbes",
758+ stacked: "Barres empilées", histogram: "Histogramme", heatmap: "Heatmap",
759+ cards: "Cartes", gauges: "Jauges", table: "Tableau",
760+};
761+const SECTION_LABELS: Record<string, string> = {
762+ kpis: "Indicateurs", gauges: "Jauges", series: "Évolution",
763+ multiseries: "Multi-courbes", stacked: "Compositions",
764+ breakdowns: "Répartitions", distributions: "Distributions",
765+ geo: "Géographie", heatmap: "Calendrier", hourly: "Activité horaire",
766+ tables: "Tableaux", records: "Records",
767+};
768+const TPL_KEY = "ka-stats-rapports";
769+
770+function loadTemplates(): BuilderTpl[] {
771+ try { return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]"); }
772+ catch { return []; }
773+}
774+function saveTemplates(t: BuilderTpl[]) {
775+ try { localStorage.setItem(TPL_KEY, JSON.stringify(t)); } catch { /* plein/privé */ }
776+}
777+
778+export function ReportBuilder({
779+ period, from, to, endpoint = "/api/stats/report", onClose,
780+}: {
781+ period: string; from?: string; to?: string; endpoint?: string; onClose: () => void;
782+}) {
783+ const [cat, setCat] = useState<CatalogBlock[] | null>(null);
784+ const [err, setErr] = useState("");
785+ const [sel, setSel] = useState<BuilderSel[]>([]);
786+ const [title, setTitle] = useState("");
787+ const [busy, setBusy] = useState(false);
788+ const [tpls, setTpls] = useState<BuilderTpl[]>(loadTemplates);
789+ const catalogUrl = endpoint.replace(/\/report$/, "/catalog");
790+
791+ useEffect(() => {
792+ const p = new URLSearchParams({ period });
793+ if (from) p.set("from", from);
794+ if (to) p.set("to", to);
795+ fetch(`${catalogUrl}?${p}`)
796+ .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
797+ .then((d) => setCat(d.blocks ?? []))
798+ .catch(() => setErr("Catalogue indisponible — réessayez plus tard."));
799+ }, [period, from, to, catalogUrl]);
800+
801+ useEffect(() => {
802+ const esc = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
803+ document.addEventListener("keydown", esc);
804+ const prev = document.body.style.overflow;
805+ document.body.style.overflow = "hidden";
806+ return () => { document.removeEventListener("keydown", esc); document.body.style.overflow = prev; };
807+ }, [onClose]);
808+
809+ const add = (b: CatalogBlock) =>
810+ setSel((s) => s.some((x) => x.key === b.key && x.render === b.default_render)
811+ ? s : [...s, { key: b.key, render: b.default_render }]);
812+ const move = (i: number, d: number) => setSel((s) => {
813+ const j = i + d;
814+ if (j < 0 || j >= s.length) return s;
815+ const n = [...s]; [n[i], n[j]] = [n[j], n[i]]; return n;
816+ });
817+
818+ const generate = async () => {
819+ if (busy || !sel.length) return;
820+ setBusy(true); setErr("");
821+ try {
822+ const body: Record<string, unknown> = { title, period, blocks: sel };
823+ if (from && to) { body.from = from; body.to = to; }
824+ const r = await fetch(`${endpoint}/custom`, {
825+ method: "POST", headers: { "Content-Type": "application/json" },
826+ body: JSON.stringify(body),
827+ });
828+ if (!r.ok) throw new Error(String(r.status));
829+ const blob = await r.blob();
830+ const m = (r.headers.get("Content-Disposition") ?? "").match(/filename="?([^";]+)/);
831+ const a = document.createElement("a");
832+ a.href = URL.createObjectURL(blob);
833+ a.download = m ? m[1] : "rapport-personnalise.pdf";
834+ document.body.appendChild(a); a.click(); a.remove();
835+ setTimeout(() => URL.revokeObjectURL(a.href), 4000);
836+ } catch {
837+ setErr("La génération a échoué — réessayez.");
838+ }
839+ setBusy(false);
840+ };
841+
842+ const groups: [string, CatalogBlock[]][] = [];
843+ for (const b of cat ?? []) {
844+ const g = groups.find(([s]) => s === b.section);
845+ if (g) g[1].push(b); else groups.push([b.section, [b]]);
846+ }
847+ const selKeys = new Set(sel.map((s) => s.key));
848+ const mono: React.CSSProperties = { fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em" };
849+
850+ return (
851+ <div role="dialog" aria-modal="true" aria-label="Rapport personnalisé"
852+ onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
853+ style={{ position: "fixed", inset: 0, zIndex: "var(--z-modal, 900)" as never, background: "rgba(20,24,20,0.45)", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "4vh 14px", overflow: "auto" }}>
854+ <div className="card" style={{ width: "min(980px,100%)", maxHeight: "92vh", display: "flex", flexDirection: "column", background: "var(--surface)", padding: 0, textAlign: "left", cursor: "default" }}>
855+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, padding: "16px 20px", borderBottom: "1px solid var(--line)" }}>
856+ <b style={{ fontFamily: "var(--font-display)", fontSize: 18 }}>
857+ Rapport personnalisé <span className="klabel">· période : {from && to ? `${from} → ${to}` : (PERIODS.find((p) => p.id === period)?.label ?? period)}</span>
858+ </b>
859+ <button type="button" className="btn btn-ghost" onClick={onClose}>✕ Fermer</button>
860+ </div>
861+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", overflow: "auto", flex: 1 }}>
862+ <div style={{ padding: "14px 20px", minWidth: 0, borderRight: "1px solid var(--line)" }}>
863+ <h3 style={{ ...mono, color: "var(--ink-3)", margin: "4px 0 10px" }}>Blocs disponibles ({cat?.length ?? "…"})</h3>
864+ {!cat && !err && <p className="klabel">Chargement du catalogue…</p>}
865+ {groups.map(([secId, bs]) => (
866+ <div key={secId}>
867+ <p style={{ ...mono, color: "var(--ink-2)", margin: "12px 0 6px" }}>{SECTION_LABELS[secId] ?? secId}</p>
868+ {bs.map((b) => (
869+ <div key={b.key} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, padding: "7px 10px", border: "1px solid var(--line)", borderRadius: 8, marginBottom: 6, fontSize: 13, opacity: selKeys.has(b.key) ? 0.45 : 1 }}>
870+ <span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={b.title}>{b.title}</span>
871+ <button type="button" className="btn btn-ghost" onClick={() => add(b)} aria-label={`Ajouter ${b.title}`} style={{ flex: "none" }}>+</button>
872+ </div>
873+ ))}
874+ </div>
875+ ))}
876+ </div>
877+ <div style={{ padding: "14px 20px", minWidth: 0 }}>
878+ <h3 style={{ ...mono, color: "var(--ink-3)", margin: "4px 0 10px" }}>Composition du rapport ({sel.length})</h3>
879+ <label className="klabel" htmlFor="rb-title">Titre du rapport</label>
880+ <input id="rb-title" className="input" style={{ width: "100%", margin: "4px 0 12px", boxSizing: "border-box" }}
881+ maxLength={80} placeholder="Ex. : Revue mensuelle" value={title} onChange={(e) => setTitle(e.target.value)} />
882+ {sel.length ? sel.map((s, i) => {
883+ const b = (cat ?? []).find((x) => x.key === s.key) ?? { title: s.key, renders: [s.render] } as CatalogBlock;
884+ return (
885+ <div key={`${s.key}:${s.render}:${i}`} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", border: "1px solid var(--ink)", borderRadius: 8, marginBottom: 6, background: "var(--surface-2)", fontSize: 13 }}>
886+ <button type="button" onClick={() => move(i, -1)} disabled={i === 0} aria-label="Monter" style={{ border: 0, background: "none", cursor: "pointer", opacity: i === 0 ? 0.25 : 1 }}>▲</button>
887+ <button type="button" onClick={() => move(i, 1)} disabled={i === sel.length - 1} aria-label="Descendre" style={{ border: 0, background: "none", cursor: "pointer", opacity: i === sel.length - 1 ? 0.25 : 1 }}>▼</button>
888+ <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={b.title}><b>{i + 1}.</b> {b.title}</span>
889+ {b.renders.length > 1 ? (
890+ <select className="input" value={s.render} aria-label="Rendu" style={{ maxWidth: 150, padding: "4px 6px", fontSize: 12 }}
891+ onChange={(e) => setSel((xs) => xs.map((x, j) => j === i ? { ...x, render: e.target.value } : x))}>
892+ {b.renders.map((r) => <option key={r} value={r}>{RENDER_LABELS[r] ?? r}</option>)}
893+ </select>
894+ ) : <span className="klabel">{RENDER_LABELS[s.render] ?? s.render}</span>}
895+ <button type="button" onClick={() => setSel((xs) => xs.filter((_, j) => j !== i))} aria-label="Retirer" style={{ border: 0, background: "none", cursor: "pointer" }}>✕</button>
896+ </div>
897+ );
898+ }) : (
899+ <div style={{ border: "1px dashed var(--line)", borderRadius: 8, padding: 16, color: "var(--ink-3)", fontSize: 13, textAlign: "center" }}>
900+ Aucun bloc — ajoutez des blocs depuis la colonne de gauche, ou chargez un modèle ci-dessous.
901+ </div>
902+ )}
903+ <p style={{ display: "flex", gap: 8, margin: "10px 0 0" }}>
904+ <button type="button" className="btn btn-ghost" disabled={!cat?.length}
905+ onClick={() => setSel((cat ?? []).map((b) => ({ key: b.key, render: b.default_render })))}>Tout ajouter</button>
906+ <button type="button" className="btn btn-ghost" disabled={!sel.length} onClick={() => setSel([])}>Vider</button>
907+ </p>
908+ {err && <p style={{ color: "var(--danger)", fontSize: 12.5, margin: "6px 0 0" }}>{err}</p>}
909+ </div>
910+ </div>
911+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, flexWrap: "wrap", padding: "14px 20px", borderTop: "1px solid var(--line)" }}>
912+ <span style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
913+ <select className="input" aria-label="Modèles sauvegardés" style={{ maxWidth: 210 }} value=""
914+ onChange={(e) => {
915+ const t = tpls[Number(e.target.value)];
916+ if (!t) return;
917+ setTitle(t.title || t.name);
918+ setSel((t.blocks ?? []).filter((s) => (cat ?? []).some((b) => b.key === s.key)).map((s) => ({ ...s })));
919+ }}>
920+ <option value="">Modèles ({tpls.length})…</option>
921+ {tpls.map((t, i) => <option key={t.name} value={i}>{t.name}</option>)}
922+ </select>
923+ <button type="button" className="btn btn-ghost" disabled={!sel.length}
924+ onClick={() => {
925+ const name = window.prompt("Nom du modèle :", title || "Mon rapport");
926+ if (!name) return;
927+ const next = [...tpls.filter((t) => t.name !== name), { name, title, blocks: sel.map((s) => ({ ...s })) }];
928+ setTpls(next); saveTemplates(next);
929+ }}>💾 Sauvegarder</button>
930+ <button type="button" className="btn btn-ghost" disabled={!tpls.length}
931+ onClick={() => {
932+ const name = window.prompt(`Nom du modèle à supprimer :\n${tpls.map((t) => `· ${t.name}`).join("\n")}`);
933+ if (!name) return;
934+ const next = tpls.filter((t) => t.name !== name);
935+ setTpls(next); saveTemplates(next);
936+ }}>🗑 Supprimer</button>
937+ </span>
938+ <button type="button" className="btn btn-primary" disabled={!sel.length || busy} onClick={generate}>
939+ {busy ? "Génération…" : "⬇ Générer le PDF"}
940+ </button>
941+ </div>
942+ </div>
943+ </div>
944+ );
945+}
946+
735 /* ---------- États ---------- */ 947 /* ---------- États ---------- */
736 export function EmptyBlock({ title }: { title: string }) { 948 export function EmptyBlock({ title }: { title: string }) {
737 return ( 949 return (
modified restoka/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 restoka/web.py +44 −1
@@ -13,7 +13,7 @@ import statistics
13 import threading 13 import threading
14 from pathlib import Path 14 from pathlib import Path
15 15
16−from fastapi import BackgroundTasks, FastAPI, HTTPException, Query 16+from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query
17 from fastapi.middleware.cors import CORSMiddleware 17 from fastapi.middleware.cors import CORSMiddleware
18 from fastapi.middleware.gzip import GZipMiddleware 18 from fastapi.middleware.gzip import GZipMiddleware
19 from fastapi.responses import FileResponse, Response 19 from fastapi.responses import FileResponse, Response
@@ -435,6 +435,49 @@ def stats_report(
435 f'attachment; filename="{fname}"'}) 435 f'attachment; filename="{fname}"'})
436 436
437 437
438+@app.get("/api/stats/catalog")
439+def stats_catalog(
440+ period: str = "30j",
441+ from_: str | None = Query(None, alias="from"),
442+ to: str | None = Query(None),
443+):
444+ """v3 — blocs composables pour le constructeur de rapports personnalisés."""
445+ from . import kapdf
446+ from . import stats as kastats
447+ if period not in kastats.PERIOD_LABELS and not (from_ and to):
448+ period = "30j"
449+ dash = kastats.dashboard(period, from_, to)
450+ return {"updated": dash.get("updated"), "period": dash.get("period"),
451+ "blocks": kapdf.catalog(dash)}
452+
453+
454+@app.post("/api/stats/report/custom")
455+def stats_report_custom(spec: dict = Body(...)):
456+ """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",
457+ "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""
458+ from . import kapdf
459+ from . import stats as kastats
460+ period = str(spec.get("period") or "30j")
461+ from_ = str(spec.get("from") or "") or None
462+ to = str(spec.get("to") or "") or None
463+ if period not in kastats.PERIOD_LABELS and not (from_ and to):
464+ period = "30j"
465+ dash = kastats.dashboard(period, from_, to)
466+ known = {b["key"] for b in kapdf.catalog(dash)}
467+ blocks = [b for b in (spec.get("blocks") or [])
468+ if isinstance(b, dict) and b.get("key") in known][:40]
469+ if not blocks:
470+ raise HTTPException(400, "Aucun bloc valide dans la composition")
471+ pdf = kapdf.GroupeKAReport(
472+ site=_SITE, dashboard=dash, mode=kapdf.CUSTOM_MODE,
473+ spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},
474+ ).build()
475+ fname = kapdf.filename("resto-ka", period, kapdf.CUSTOM_MODE)
476+ return Response(content=pdf, media_type="application/pdf",
477+ headers={"Content-Disposition":
478+ f'attachment; filename="{fname}"'})
479+
480+
438 @app.get("/api/stats") 481 @app.get("/api/stats")
439 def stats(): 482 def stats():
440 con = db.connect() 483 con = db.connect()
441 484
Menu

Theme