SPB Git

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

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

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

3 changed files +696 −9

modified src/api/kapdf.py +398 −8
@@ -7,9 +7,9 @@
7 # Date : 2026-08-19 7 # Date : 2026-08-19
8 # ============================================ 8 # ============================================
9 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai 9 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
10−# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v2 10+# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v3
11 # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit 11 # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
12−# les rapports estampillés Groupe-KA. 5 modes : 12+# les rapports estampillés Groupe-KA. 5 modes fixes :
13 # complet — toutes les sections (KPI, jauges, séries + stats, multi- 13 # complet — toutes les sections (KPI, jauges, séries + stats, multi-
14 # séries, empilées, distributions, répartitions, géo, 14 # séries, empilées, distributions, répartitions, géo,
15 # heatmap horaire, tableaux, records) 15 # heatmap horaire, tableaux, records)
@@ -17,12 +17,19 @@
17 # tendances — KPI + toutes les séries temporelles + stats de séries 17 # tendances — KPI + toutes les séries temporelles + stats de séries
18 # repartitions — breakdowns, distributions, géo, activité horaire 18 # repartitions — breakdowns, distributions, géo, activité horaire
19 # donnees — tous les tableaux en version longue (400 lignes max) 19 # donnees — tous les tableaux en version longue (400 lignes max)
20+# v3 : mode « personnalise » — l'utilisateur compose son rapport bloc par
21+# bloc (choix des données ET du rendu par bloc : courbe/aire/barres/anneau/
22+# heatmap/tableau…). catalog(dash) expose les blocs disponibles ; le rapport
23+# suit une spec {"title": str, "blocks": [{"key": "series:ajouts",
24+# "render": "bar"}, …]} et respecte l'ordre demandé.
20 # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque. 25 # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.
21 # Usage : 26 # Usage :
22−# from kapdf import GroupeKAReport, REPORT_MODES, filename 27+# from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename
23 # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00", 28 # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
24 # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json, 29 # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
25 # mode="complet").build() 30 # mode="complet").build()
31+# pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise",
32+# spec={"title": "Mon rapport", "blocks": [...]}).build()
26 # Dépendance : pip install fpdf2 (aucune autre) 33 # Dépendance : pip install fpdf2 (aucune autre)
27 from __future__ import annotations 34 from __future__ import annotations
28 35
@@ -48,6 +55,100 @@ REPORT_MODES = {
48 "repartitions": "Répartitions & géographie", 55 "repartitions": "Répartitions & géographie",
49 "donnees": "Données détaillées", 56 "donnees": "Données détaillées",
50 } 57 }
58+# v3 — mode composé par l'utilisateur (jamais dans le menu des modes fixes)
59+CUSTOM_MODE = "personnalise"
60+CUSTOM_LABEL = "Rapport personnalisé"
61+
62+# v3 — rendus proposés par type de bloc (le 1er est le rendu par défaut ;
63+# « table » est toujours offert : toute donnée a un équivalent tableau)
64+RENDER_LABELS = {
65+ "line": "Courbe", "area": "Aire", "bar": "Barres verticales",
66+ "bars": "Barres horizontales", "donut": "Anneau",
67+ "lines": "Multi-courbes", "stacked": "Barres empilées",
68+ "histogram": "Histogramme", "heatmap": "Heatmap",
69+ "cards": "Cartes", "gauges": "Jauges", "table": "Tableau",
70+}
71+SECTION_LABELS = {
72+ "kpis": "Indicateurs", "gauges": "Taux & couvertures",
73+ "series": "Évolution", "multiseries": "Comparaisons",
74+ "stacked": "Compositions", "breakdowns": "Répartitions",
75+ "distributions": "Distributions", "geo": "Géographie",
76+ "heatmap": "Calendrier", "hourly": "Activité horaire",
77+ "tables": "Tableaux", "records": "Records",
78+}
79+
80+
81+def catalog(dash: dict) -> list[dict]:
82+ """v3 — blocs composables d'un dashboard : ce que le constructeur de
83+ rapports personnalisés peut inclure, avec les rendus compatibles.
84+ key = section[:id] ; l'ordre renvoyé = ordre naturel du dashboard."""
85+ out: list[dict] = []
86+
87+ def add(key, title, renders, default=None, count=None):
88+ b = {"key": key, "section": key.split(":")[0], "title": title,
89+ "renders": renders, "default_render": default or renders[0]}
90+ if count is not None:
91+ b["count"] = count
92+ out.append(b)
93+
94+ if dash.get("kpis"):
95+ add("kpis", "Indicateurs clés (KPI)", ["cards", "table"],
96+ count=len(dash["kpis"]))
97+ gs = [g for g in (dash.get("gauges") or [])
98+ if isinstance(g.get("value"), (int, float)) and g.get("max")]
99+ if gs:
100+ add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"],
101+ count=len(gs))
102+ for s in dash.get("series") or []:
103+ if len(s.get("points") or []) < 2:
104+ continue
105+ kind = s.get("kind") or "line"
106+ default = kind if kind in ("line", "area", "bar") else "line"
107+ add(f"series:{s.get('id')}", s.get("title", ""),
108+ ["line", "area", "bar", "table"], default,
109+ len(s.get("points") or []))
110+ for ms in dash.get("multiseries") or []:
111+ if not (ms.get("series") or []):
112+ continue
113+ add(f"multiseries:{ms.get('id')}", ms.get("title", ""),
114+ ["lines", "table"], count=len(ms["series"]))
115+ for st in dash.get("stacked") or []:
116+ if not (st.get("points") or []):
117+ continue
118+ add(f"stacked:{st.get('id')}", st.get("title", ""),
119+ ["stacked", "table"], count=len(st.get("keys") or []))
120+ for b in dash.get("breakdowns") or []:
121+ if not (b.get("items") or []):
122+ continue
123+ default = "donut" if b.get("kind") == "donut" else "bars"
124+ add(f"breakdowns:{b.get('id')}", b.get("title", ""),
125+ ["donut", "bars", "table"], default, len(b["items"]))
126+ for d in dash.get("distributions") or []:
127+ if not (d.get("bins") or []):
128+ continue
129+ add(f"distributions:{d.get('id')}", d.get("title", ""),
130+ ["histogram", "table"], count=len(d["bins"]))
131+ geo = dash.get("geo") or {}
132+ if geo.get("items"):
133+ add("geo", geo.get("title", "Répartition géographique"),
134+ ["bars", "table"], count=len(geo["items"]))
135+ hm = dash.get("heatmap") or {}
136+ if hm.get("cells"):
137+ add("heatmap", hm.get("title", "Calendrier d'activité"),
138+ ["heatmap", "table"])
139+ hr = dash.get("hourly") or {}
140+ if hr.get("cells"):
141+ add("hourly", hr.get("title", "Activité par jour et heure"),
142+ ["heatmap", "table"])
143+ for t in dash.get("tables") or []:
144+ if not (t.get("rows") or []):
145+ continue
146+ add(f"tables:{t.get('id')}", t.get("title", ""), ["table"],
147+ count=len(t["rows"]))
148+ if dash.get("records"):
149+ add("records", "Records & faits marquants", ["cards", "table"],
150+ count=len(dash["records"]))
151+ return out
51 152
52 EMAILS = [ 153 EMAILS = [
53 ("contact@groupe-ka.com", "Projets, partenariats & données"), 154 ("contact@groupe-ka.com", "Projets, partenariats & données"),
@@ -135,16 +236,25 @@ class _PDF(FPDF):
135 236
136 237
137 class GroupeKAReport: 238 class GroupeKAReport:
138− def __init__(self, site: dict, dashboard: dict, mode: str = "complet"): 239+ def __init__(self, site: dict, dashboard: dict, mode: str = "complet",
240+ spec: dict | None = None):
139 self.site = site 241 self.site = site
140 self.d = dashboard 242 self.d = dashboard
141− self.mode = mode if mode in REPORT_MODES else "complet" 243+ self.mode = mode if (mode in REPORT_MODES or mode == CUSTOM_MODE) else "complet"
244+ self.spec = spec or {}
142 self.accent = _hex(site.get("accent", "#d9f26b")) 245 self.accent = _hex(site.get("accent", "#d9f26b"))
143 period = dashboard.get("period", {}) or {} 246 period = dashboard.get("period", {}) or {}
144 self.period_label = period.get("label") or "toute la période" 247 self.period_label = period.get("label") or "toute la période"
145 self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label) 248 self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
146 self.toc: list[tuple[str, int]] = [] 249 self.toc: list[tuple[str, int]] = []
147 250
251+ @property
252+ def mode_label(self) -> str:
253+ if self.mode == CUSTOM_MODE:
254+ t = str(self.spec.get("title") or "").strip()
255+ return f"{CUSTOM_LABEL} — {t}" if t else CUSTOM_LABEL
256+ return REPORT_MODES[self.mode]
257+
148 # ---------- primitives ---------- 258 # ---------- primitives ----------
149 def _card(self, x, y, w, h, fill=WHITE): 259 def _card(self, x, y, w, h, fill=WHITE):
150 p = self.pdf 260 p = self.pdf
@@ -221,7 +331,7 @@ class GroupeKAReport:
221 p.set_xy(24, 100) 331 p.set_xy(24, 100)
222 p.set_font("helvetica", "", 13) 332 p.set_font("helvetica", "", 13)
223 p.set_text_color(*INK2) 333 p.set_text_color(*INK2)
224− p.multi_cell(150, 7, f"{REPORT_MODES[self.mode]} — {wm}") 334+ p.multi_cell(150, 7, f"{self.mode_label} — {wm}")
225 now = datetime.now(ZoneInfo("America/Toronto")) 335 now = datetime.now(ZoneInfo("America/Toronto"))
226 per = self.d.get("period", {}) or {} 336 per = self.d.get("period", {}) or {}
227 p.set_xy(24, 125) 337 p.set_xy(24, 125)
@@ -230,7 +340,7 @@ class GroupeKAReport:
230 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")), 340 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
231 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"), 341 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
232 ("Plateforme", "https://" + self.site.get("domain", "")), 342 ("Plateforme", "https://" + self.site.get("domain", "")),
233− ("Type de rapport", REPORT_MODES[self.mode]), 343+ ("Type de rapport", self.mode_label),
234 ] 344 ]
235 y = 128 345 y = 128
236 for k, v in rows: 346 for k, v in rows:
@@ -693,6 +803,271 @@ class GroupeKAReport:
693 p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF") 803 p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")
694 p.set_y(y0 + ly + 7 * chh + 5) 804 p.set_y(y0 + ly + 7 * chh + 5)
695 805
806+ def _calheat(self, hm):
807+ """v3 — calendrier de chaleur 26 semaines (équivalent PDF du
808+ CalendarHeatmap du kit front) : colonnes = semaines, lignes = jours."""
809+ from datetime import date as _date, timedelta as _td
810+ cells = hm.get("cells") or []
811+ vals = {c.get("date"): c.get("value") or 0 for c in cells if c.get("date")}
812+ if not vals:
813+ return
814+ p = self.pdf
815+ if p.get_y() > 215:
816+ p.add_page()
817+ self._chart_title(hm.get("title", "Calendrier d'activité"))
818+ try:
819+ end = _date.fromisoformat(max(vals))
820+ except ValueError:
821+ return
822+ weeks = 26
823+ start = end - _td(days=weeks * 7 - 1)
824+ start -= _td(days=start.weekday()) # lundi
825+ vmax = max(vals.values()) or 1
826+ x0, y0 = p.l_margin, p.get_y()
827+ cw, lx, ly = 6.3, 10, 4
828+ dows = ["Lun", "", "Mer", "", "Ven", "", "Dim"]
829+ p.set_font("helvetica", "", 5.8)
830+ p.set_text_color(*INK3)
831+ for d in range(7):
832+ if dows[d]:
833+ p.set_xy(x0, y0 + ly + d * cw + 1.2)
834+ p.cell(lx - 1, 3, dows[d], align="R")
835+ for w in range(weeks):
836+ monday = start + _td(days=7 * w)
837+ if monday.day <= 7: # étiquette de mois à la 1re semaine du mois
838+ p.set_xy(x0 + lx + w * cw, y0)
839+ p.cell(cw * 4, 3, monday.strftime("%m"))
840+ for d in range(7):
841+ day = monday + _td(days=d)
842+ v = vals.get(day.isoformat(), 0)
843+ f = 0.15 + 0.85 * (v / vmax) if v else 0.0
844+ col = (tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f)
845+ for j in range(3)) if v else (235, 233, 228))
846+ p.set_fill_color(*col)
847+ p.set_draw_color(215, 213, 207)
848+ p.set_line_width(0.1)
849+ p.rect(x0 + lx + w * cw, y0 + ly + d * cw, cw - 0.5, cw - 0.5,
850+ style="DF")
851+ p.set_y(y0 + ly + 7 * cw + 5)
852+
853+ # ---------- v3 : conversions bloc → tableau ----------
854+ @staticmethod
855+ def _serie_as_table(s):
856+ unit = s.get("unit") or "Valeur"
857+ cols = ["Date", unit.capitalize()]
858+ cmp_ = s.get("compare") or []
859+ if cmp_:
860+ cols.append("Période comparée")
861+ rows = []
862+ for i, pt in enumerate(s.get("points") or []):
863+ row = [str(pt.get("t", "")), pt.get("v", "")]
864+ if cmp_:
865+ row.append(cmp_[i]["v"] if i < len(cmp_) else "")
866+ rows.append(row)
867+ return {"id": s.get("id"), "title": s.get("title", ""),
868+ "columns": cols, "rows": rows}
869+
870+ @staticmethod
871+ def _multi_as_table(ms):
872+ labels = [s.get("label", "") for s in (ms.get("series") or [])][:4]
873+ by_t: dict[str, dict] = {}
874+ for s in (ms.get("series") or [])[:4]:
875+ for pt in s.get("points") or []:
876+ by_t.setdefault(str(pt.get("t", "")), {})[s.get("label", "")] = pt.get("v")
877+ rows = [[t] + [by_t[t].get(lbl, "") for lbl in labels]
878+ for t in sorted(by_t)]
879+ return {"id": ms.get("id"), "title": ms.get("title", ""),
880+ "columns": ["Date"] + labels, "rows": rows}
881+
882+ @staticmethod
883+ def _stacked_as_table(st):
884+ keys = (st.get("keys") or [])[:6]
885+ rows = []
886+ for pt in st.get("points") or []:
887+ vs = [(pt.get("values") or [])[j] if j < len(pt.get("values") or []) else 0
888+ for j in range(len(keys))]
889+ rows.append([str(pt.get("t", ""))] + vs + [sum(v or 0 for v in vs)])
890+ return {"id": st.get("id"), "title": st.get("title", ""),
891+ "columns": ["Date"] + list(keys) + ["Total"], "rows": rows}
892+
893+ @staticmethod
894+ def _items_as_table(id_, title, items, label_col="Libellé"):
895+ items = items or []
896+ with_delta = any(it.get("delta_pct") is not None for it in items)
897+ cols = [label_col, "Valeur"] + (["delta %"] if with_delta else [])
898+ rows = []
899+ for it in items:
900+ row = [str(it.get("label", "")), it.get("value", "")]
901+ if with_delta:
902+ d = it.get("delta_pct")
903+ row.append("" if d is None else f"{'+' if d >= 0 else ''}{d} %")
904+ rows.append(row)
905+ return {"id": id_, "title": title, "columns": cols, "rows": rows}
906+
907+ def _kpis_as_table(self):
908+ rows = []
909+ for k in self.d.get("kpis") or []:
910+ v = k.get("value")
911+ val = (_fr(v) if isinstance(v, (int, float)) else str(v)) + \
912+ ((" " + k["unit"]) if k.get("unit") else "")
913+ d = k.get("delta_pct")
914+ rows.append([str(k.get("label", "")), val,
915+ "" if d is None else f"{'+' if d >= 0 else ''}{d} %"])
916+ return {"id": "kpis", "title": "Indicateurs clés",
917+ "columns": ["Indicateur", "Valeur", "delta %"], "rows": rows}
918+
919+ def _gauges_as_table(self):
920+ rows = [[str(g.get("label", "")),
921+ f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}",
922+ _fr(g["max"]), f"{100.0 * g['value'] / g['max']:.0f} %"]
923+ for g in self.d.get("gauges") or []
924+ if isinstance(g.get("value"), (int, float)) and g.get("max")]
925+ return {"id": "gauges", "title": "Taux & couvertures",
926+ "columns": ["Mesure", "Valeur", "Max", "Part"], "rows": rows}
927+
928+ def _records_as_table(self):
929+ rows = [[str(r.get("label", "")), str(r.get("value", "")),
930+ str(r.get("date", "") or "")]
931+ for r in self.d.get("records") or []]
932+ return {"id": "records", "title": "Records & faits marquants",
933+ "columns": ["Fait marquant", "Valeur", "Date"], "rows": rows}
934+
935+ @staticmethod
936+ def _heatmap_as_table(hm, title):
937+ cells = sorted((hm.get("cells") or []),
938+ key=lambda c: -(c.get("value") or 0))[:40]
939+ return {"id": "heatmap", "title": title + " — jours les plus chargés",
940+ "columns": ["Date", "Valeur"],
941+ "rows": [[c.get("date", ""), c.get("value") or 0] for c in cells]}
942+
943+ @staticmethod
944+ def _hourly_as_table(hr, title):
945+ days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi",
946+ "Dimanche"]
947+ cells = sorted((hr.get("cells") or []),
948+ key=lambda c: -(c.get("value") or 0))[:40]
949+ return {"id": "hourly", "title": title + " — créneaux les plus actifs",
950+ "columns": ["Jour", "Heure", "Valeur"],
951+ "rows": [[days[c["dow"]] if 0 <= c.get("dow", -1) <= 6 else "?",
952+ f"{c.get('hour', '?')} h", c.get("value") or 0]
953+ for c in cells]}
954+
955+ # ---------- v3 : rendu d'un bloc du rapport personnalisé ----------
956+ def _find(self, coll: str, id_: str):
957+ for it in self.d.get(coll) or []:
958+ if str(it.get("id")) == id_:
959+ return it
960+ return None
961+
962+ def _toc_mark(self, title: str):
963+ """Blocs graphiques du mode personnalisé : entrée de sommaire sans
964+ _section_title (le graphique porte déjà son titre)."""
965+ if self.pdf.get_y() > 235:
966+ self.pdf.add_page()
967+ self.toc.append((title, self.pdf.page_no()))
968+
969+ def _render_block(self, key: str, render: str):
970+ section, _, id_ = key.partition(":")
971+ if section == "kpis":
972+ self._table(self._kpis_as_table()) if render == "table" else self._kpis()
973+ elif section == "gauges":
974+ self._table(self._gauges_as_table()) if render == "table" else self._gauges()
975+ elif section == "records":
976+ self._table(self._records_as_table()) if render == "table" else self._records()
977+ elif section == "series":
978+ s = self._find("series", id_)
979+ if not s:
980+ return
981+ if render == "table":
982+ self._table(self._serie_as_table(s), max_rows=400)
983+ else:
984+ s2 = dict(s)
985+ if render in ("line", "area", "bar"):
986+ s2["kind"] = render
987+ self._toc_mark(s2.get("title", ""))
988+ if s2.get("kind") == "bar":
989+ self._vbars(s2)
990+ else:
991+ self._line_chart(s2, with_stats=True)
992+ elif section == "multiseries":
993+ ms = self._find("multiseries", id_)
994+ if not ms:
995+ return
996+ if render == "table":
997+ self._table(self._multi_as_table(ms), max_rows=400)
998+ else:
999+ self._toc_mark(ms.get("title", ""))
1000+ self._multiline(ms)
1001+ elif section == "stacked":
1002+ st = self._find("stacked", id_)
1003+ if not st:
1004+ return
1005+ if render == "table":
1006+ self._table(self._stacked_as_table(st), max_rows=400)
1007+ else:
1008+ self._toc_mark(st.get("title", ""))
1009+ self._stacked(st)
1010+ elif section == "breakdowns":
1011+ b = self._find("breakdowns", id_)
1012+ if not b:
1013+ return
1014+ if render == "table":
1015+ self._table(self._items_as_table(id_, b.get("title", ""),
1016+ b.get("items")), max_rows=400)
1017+ else:
1018+ self._toc_mark(b.get("title", ""))
1019+ if render == "donut":
1020+ self._donut(b)
1021+ else:
1022+ self._bars(b.get("title", ""), b.get("items"))
1023+ elif section == "distributions":
1024+ d = self._find("distributions", id_)
1025+ if not d:
1026+ return
1027+ if render == "table":
1028+ bins = [{"label": bn.get("label"), "value": bn.get("value")}
1029+ for bn in d.get("bins") or []]
1030+ self._table(self._items_as_table(id_, d.get("title", ""), bins,
1031+ label_col="Tranche"))
1032+ else:
1033+ self._toc_mark(d.get("title", ""))
1034+ self._vbars(d)
1035+ elif section == "geo":
1036+ geo = self.d.get("geo") or {}
1037+ if not geo.get("items"):
1038+ return
1039+ title = geo.get("title", "Répartition géographique")
1040+ if render == "table":
1041+ self._table(self._items_as_table("geo", title, geo["items"],
1042+ label_col="Zone"), max_rows=400)
1043+ else:
1044+ self._toc_mark(title)
1045+ self._bars(title, geo["items"])
1046+ elif section == "heatmap":
1047+ hm = self.d.get("heatmap") or {}
1048+ if not hm.get("cells"):
1049+ return
1050+ title = hm.get("title", "Calendrier d'activité")
1051+ if render == "table":
1052+ self._table(self._heatmap_as_table(hm, title))
1053+ else:
1054+ self._toc_mark(title)
1055+ self._calheat(hm)
1056+ elif section == "hourly":
1057+ hr = self.d.get("hourly") or {}
1058+ if not hr.get("cells"):
1059+ return
1060+ title = hr.get("title", "Activité par jour et heure")
1061+ if render == "table":
1062+ self._table(self._hourly_as_table(hr, title))
1063+ else:
1064+ self._toc_mark(title)
1065+ self._hourly()
1066+ elif section == "tables":
1067+ t = self._find("tables", id_)
1068+ if t:
1069+ self._table(t, max_rows=400)
1070+
696 def _table(self, t, max_rows=200): 1071 def _table(self, t, max_rows=200):
697 p = self.pdf 1072 p = self.pdf
698 cols = t.get("columns") or [] 1073 cols = t.get("columns") or []
@@ -817,7 +1192,7 @@ class GroupeKAReport:
817 p = self.pdf 1192 p = self.pdf
818 p.alias_nb_pages() 1193 p.alias_nb_pages()
819 self._cover() 1194 self._cover()
820− with_toc = self.mode in ("complet", "donnees") 1195+ with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE)
821 toc_page_no = None 1196 toc_page_no = None
822 if self.mode == "synthese": 1197 if self.mode == "synthese":
823 p.add_page() 1198 p.add_page()
@@ -843,6 +1218,21 @@ class GroupeKAReport:
843 for t in self.d.get("tables") or []: 1218 for t in self.d.get("tables") or []:
844 self._table(t, max_rows=400) 1219 self._table(t, max_rows=400)
845 self._final_page() 1220 self._final_page()
1221+ elif self.mode == CUSTOM_MODE:
1222+ p.add_page()
1223+ toc_page_no = p.page_no()
1224+ p.add_page()
1225+ known = {b["key"]: b for b in catalog(self.d)}
1226+ for blk in self.spec.get("blocks") or []:
1227+ key = str(blk.get("key", ""))
1228+ b = known.get(key)
1229+ if not b:
1230+ continue
1231+ render = str(blk.get("render") or "")
1232+ if render not in b["renders"]:
1233+ render = b["default_render"]
1234+ self._render_block(key, render)
1235+ self._final_page()
846 else: # complet 1236 else: # complet
847 p.add_page() 1237 p.add_page()
848 toc_page_no = p.page_no() 1238 toc_page_no = p.page_no()
modified src/api/routes/stats.py +55 −1
@@ -39,7 +39,7 @@ from typing import Any
39 from zoneinfo import ZoneInfo 39 from zoneinfo import ZoneInfo
40 40
41 import httpx 41 import httpx
42−from fastapi import APIRouter, Depends, HTTPException, Query, Response 42+from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response
43 from sqlalchemy import func, select 43 from sqlalchemy import func, select
44 from sqlalchemy.orm import Session 44 from sqlalchemy.orm import Session
45 45
@@ -1230,6 +1230,60 @@ def stats_report(
1230 ) 1230 )
1231 1231
1232 1232
1233+@router.get("/catalog")
1234+def stats_catalog(
1235+ period: str = Query("30j", description="auj, 7j, 30j, 3m, 6m, 12m, annee, tout"),
1236+ date_from: datetime.date | None = Query(None, alias="from"),
1237+ date_to: datetime.date | None = Query(None, alias="to"),
1238+ db: Session = Depends(get_db),
1239+) -> dict[str, Any]:
1240+ """v3 — blocs composables pour le constructeur de rapports personnalisés."""
1241+ dash = _dashboard_cached(db, period, date_from, date_to)
1242+ return {
1243+ "updated": dash.get("updated"),
1244+ "period": dash.get("period"),
1245+ "blocks": kapdf.catalog(dash),
1246+ }
1247+
1248+
1249+@router.post("/report/custom")
1250+def stats_report_custom(
1251+ spec: dict = Body(...),
1252+ db: Session = Depends(get_db),
1253+) -> Response:
1254+ """v3 — rapport PDF personnalisé : ``{"title", "period", "from", "to",
1255+ "blocks": [{"key": "series:…", "render": "bar"}, …]}`` (SPEC.md §3bis)."""
1256+ period = str(spec.get("period") or "30j")
1257+ if period not in PERIOD_LABELS:
1258+ period = "30j"
1259+
1260+ def _date(v: Any) -> datetime.date | None:
1261+ try:
1262+ return datetime.date.fromisoformat(str(v)) if v else None
1263+ except ValueError:
1264+ return None
1265+
1266+ dash = _dashboard_cached(db, period, _date(spec.get("from")), _date(spec.get("to")))
1267+ known = {b["key"] for b in kapdf.catalog(dash)}
1268+ blocks = [
1269+ b for b in (spec.get("blocks") or []) if isinstance(b, dict) and b.get("key") in known
1270+ ][:40]
1271+ if not blocks:
1272+ raise HTTPException(400, "Aucun bloc valide dans la composition")
1273+ pdf_bytes = kapdf.GroupeKAReport(
1274+ site=SITE,
1275+ dashboard=dash,
1276+ mode=kapdf.CUSTOM_MODE,
1277+ spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},
1278+ ).build()
1279+ fname = kapdf.filename(PLATFORM_ID, period, kapdf.CUSTOM_MODE)
1280+ return Response(
1281+ content=pdf_bytes,
1282+ media_type="application/pdf",
1283+ headers={"Content-Disposition": f'attachment; filename="{fname}"'},
1284+ )
1285+
1286+
1233 # ------------------------------------------------- rapport écosystème (PDF) 1287 # ------------------------------------------------- rapport écosystème (PDF)
1234 async def _fetch_satellite_dashboard( 1288 async def _fetch_satellite_dashboard(
1235 client: httpx.AsyncClient, site: dict, period: str 1289 client: httpx.AsyncClient, site: dict, period: str
modified src/api/web/stats.html +243 −0
@@ -175,6 +175,42 @@ table.dt tbody tr:nth-child(even){background:var(--surface-2)}
175 .sec-gap{margin-top:30px} 175 .sec-gap{margin-top:30px}
176 @media(max-width:767px){section{padding:32px 0}.hero-s{padding:40px 0 32px}} 176 @media(max-width:767px){section{padding:32px 0}.hero-s{padding:40px 0 32px}}
177 @media(prefers-reduced-motion:reduce){*{transition:none!important}} 177 @media(prefers-reduced-motion:reduce){*{transition:none!important}}
178+/* ---------- v3 : constructeur de rapports personnalisés ---------- */
179+.stb-overlay{position:fixed;inset:0;z-index:var(--z-modal,900);background:rgba(20,24,20,.45);
180+ display:flex;align-items:flex-start;justify-content:center;padding:4vh 14px;overflow:auto}
181+.stb-panel{background:var(--surface);border:1px solid var(--ink);border-radius:12px;
182+ width:min(980px,100%);max-height:92vh;display:flex;flex-direction:column;
183+ box-shadow:0 18px 48px rgba(20,24,20,.3)}
184+.stb-head{display:flex;justify-content:space-between;align-items:center;gap:10px;
185+ padding:16px 20px;border-bottom:1px solid var(--line)}
186+.stb-head h2{margin:0;font-size:18px}
187+.stb-body{display:grid;grid-template-columns:1fr 1fr;gap:0;overflow:auto;flex:1}
188+@media(max-width:760px){.stb-body{grid-template-columns:1fr}}
189+.stb-col{padding:14px 20px;min-width:0}
190+.stb-col+.stb-col{border-left:1px solid var(--line)}
191+@media(max-width:760px){.stb-col+.stb-col{border-left:0;border-top:1px solid var(--line)}}
192+.stb-col h3{margin:4px 0 10px;font-size:12px;font-family:var(--font-mono);
193+ text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3)}
194+.stb-group{margin:0 0 6px;font-family:var(--font-mono);font-size:10.5px;font-weight:700;
195+ text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);margin-top:12px}
196+.stb-av{display:flex;justify-content:space-between;align-items:center;gap:8px;
197+ padding:7px 10px;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;font-size:13px}
198+.stb-av button{flex:none}
199+.stb-av.stb-in{opacity:.45}
200+.stb-item{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--ink);
201+ border-radius:8px;margin-bottom:6px;background:var(--surface-2);font-size:13px}
202+.stb-item .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
203+.stb-item select{border:1px solid var(--line);border-radius:6px;background:var(--surface);
204+ padding:4px 6px;font-size:12px;max-width:150px}
205+.stb-item .mv{border:0;background:none;cursor:pointer;font-size:14px;padding:2px 4px;min-height:0}
206+.stb-item .mv:disabled{opacity:.25;cursor:default}
207+.stb-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;
208+ padding:14px 20px;border-top:1px solid var(--line)}
209+.stb-tpl{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
210+.stb-err{color:var(--danger);font-size:12.5px;margin:6px 0 0}
211+.stb-empty{border:1px dashed var(--line);border-radius:8px;padding:16px;color:var(--ink-3);
212+ font-size:13px;text-align:center}
213+.hidden{display:none!important}
178 </style> 214 </style>
179 <link rel="canonical" href="https://www.api-ka.com/stats"> 215 <link rel="canonical" href="https://www.api-ka.com/stats">
180 <link rel="alternate" hreflang="fr-ca" href="https://www.api-ka.com/stats"> 216 <link rel="alternate" hreflang="fr-ca" href="https://www.api-ka.com/stats">
@@ -217,6 +253,7 @@ table.dt tbody tr:nth-child(even){background:var(--surface-2)}
217 <button type="button" data-mode="donnees" role="menuitem">Données détaillées<small>Tous les tableaux en version longue</small></button> 253 <button type="button" data-mode="donnees" role="menuitem">Données détaillées<small>Tous les tableaux en version longue</small></button>
218 </span> 254 </span>
219 </span> 255 </span>
256+ <button type="button" class="btn btn-ghost" id="st-custom-btn">🛠 Rapport personnalisé</button>
220 <span id="pdf-busy" role="status"></span> 257 <span id="pdf-busy" role="status"></span>
221 </div> 258 </div>
222 <p class="fresh" style="margin-top:18px"> 259 <p class="fresh" style="margin-top:18px">
@@ -902,6 +939,212 @@ async function load(){
902 renderRecords(dash.records); 939 renderRecords(dash.records);
903 } 940 }
904 load(); 941 load();
942+
943+/* ==================== v3 : constructeur de rapports personnalisés ==========
944+ Compose un PDF bloc par bloc : choix des données (catalogue du dashboard),
945+ du rendu par bloc (courbe/aire/barres/anneau/heatmap/tableau…), de l'ordre,
946+ et modèles sauvegardés (localStorage, propre au site = origine). */
947+const $ = id => document.getElementById(id);
948+function sbParams(){
949+ const p = new URLSearchParams({period: state.period});
950+ if(state.from && state.to){ p.set("from", state.from); p.set("to", state.to); }
951+ return p.toString();
952+}
953+const SB = { cat:null, sel:[], title:"", busy:false, err:"" };
954+const SB_TPL_KEY = "ka-stats-rapports";
955+const SB_RENDERS = { line:"Courbe", area:"Aire", bar:"Barres verticales",
956+ bars:"Barres horizontales", donut:"Anneau", lines:"Multi-courbes",
957+ stacked:"Barres empilées", histogram:"Histogramme", heatmap:"Heatmap",
958+ cards:"Cartes", gauges:"Jauges", table:"Tableau" };
959+const SB_SECTIONS = { kpis:"Indicateurs", gauges:"Jauges", series:"Évolution",
960+ multiseries:"Multi-courbes", stacked:"Compositions", breakdowns:"Répartitions",
961+ distributions:"Distributions", geo:"Géographie", heatmap:"Calendrier",
962+ hourly:"Activité horaire", tables:"Tableaux", records:"Records" };
963+
964+function sbTemplates(){
965+ try{ return JSON.parse(localStorage.getItem(SB_TPL_KEY) || "[]"); }
966+ catch(e){ return []; }
967+}
968+function sbSaveTemplates(t){ localStorage.setItem(SB_TPL_KEY, JSON.stringify(t)); }
969+
970+async function sbOpen(){
971+ let ov = $("st-builder");
972+ if(!ov){
973+ ov = document.createElement("div");
974+ ov.id = "st-builder";
975+ ov.className = "stb-overlay hidden";
976+ ov.onclick = e => { if(e.target === ov) sbClose(); };
977+ document.body.appendChild(ov);
978+ }
979+ ov.classList.remove("hidden");
980+ document.body.style.overflow = "hidden";
981+ SB.err = "";
982+ if(!SB.cat){
983+ ov.innerHTML = `<div class="stb-panel"><div class="stb-head">
984+ <h2>Rapport personnalisé</h2></div>
985+ <div class="stb-col"><p class="klabel">Chargement du catalogue…</p></div></div>`;
986+ try{
987+ SB.cat = (await (await fetch(`/api/stats/catalog?${sbParams()}`)).json()).blocks || [];
988+ }catch(e){
989+ ov.innerHTML = `<div class="stb-panel"><div class="stb-head"><h2>Rapport personnalisé</h2>
990+ <button type="button" class="btn btn-ghost" onclick="sbClose()">✕ Fermer</button></div>
991+ <div class="stb-col"><p class="stb-err">Catalogue indisponible — réessayez plus tard.</p></div></div>`;
992+ return;
993+ }
994+ }
995+ sbRender();
996+}
997+function sbClose(){
998+ const ov = $("st-builder");
999+ if(ov) ov.classList.add("hidden");
1000+ document.body.style.overflow = "";
1001+}
1002+
1003+function sbAdd(key){
1004+ const b = SB.cat.find(x => x.key === key);
1005+ if(!b || SB.sel.some(s => s.key === key && s.render === b.default_render)) return;
1006+ SB.sel.push({ key, render: b.default_render });
1007+ sbRender();
1008+}
1009+function sbDel(i){ SB.sel.splice(i, 1); sbRender(); }
1010+function sbMove(i, d){
1011+ const j = i + d;
1012+ if(j < 0 || j >= SB.sel.length) return;
1013+ [SB.sel[i], SB.sel[j]] = [SB.sel[j], SB.sel[i]];
1014+ sbRender();
1015+}
1016+function sbAll(){
1017+ SB.sel = SB.cat.map(b => ({ key: b.key, render: b.default_render }));
1018+ sbRender();
1019+}
1020+
1021+function sbRender(){
1022+ const ov = $("st-builder");
1023+ const groups = {};
1024+ SB.cat.forEach(b => (groups[b.section] = groups[b.section] || []).push(b));
1025+ const selKeys = new Set(SB.sel.map(s => s.key));
1026+ const perLabel = (state.from && state.to) ? `${state.from} → ${state.to}`
1027+ : (PERIODS.find(p => p.id === state.period) || {label:"30 jours"}).label;
1028+ const tpls = sbTemplates();
1029+
1030+ ov.innerHTML = `<div class="stb-panel">
1031+ <div class="stb-head">
1032+ <h2>Rapport personnalisé <span class="klabel">· période : ${esc(perLabel)}</span></h2>
1033+ <button type="button" class="btn btn-ghost" id="stb-close">✕ Fermer</button>
1034+ </div>
1035+ <div class="stb-body">
1036+ <div class="stb-col">
1037+ <h3>Blocs disponibles (${SB.cat.length})</h3>
1038+ <p class="klabel" style="margin:0 0 10px">Cliquez « + » pour ajouter un bloc au rapport.
1039+ Un même bloc peut être ajouté sous plusieurs rendus.</p>
1040+ ${Object.entries(groups).map(([sec, bs]) => `
1041+ <p class="stb-group">${esc(SB_SECTIONS[sec] || sec)}</p>
1042+ ${bs.map(b => `<div class="stb-av${selKeys.has(b.key) ? " stb-in" : ""}">
1043+ <span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
1044+ title="${esc(b.title)}">${esc(b.title)}</span>
1045+ <button type="button" class="btn btn-ghost" data-add="${esc(b.key)}"
1046+ aria-label="Ajouter ${esc(b.title)}">+</button></div>`).join("")}`).join("")}
1047+ </div>
1048+ <div class="stb-col">
1049+ <h3>Composition du rapport (${SB.sel.length})</h3>
1050+ <label class="klabel" for="stb-title">Titre du rapport</label>
1051+ <input class="input" id="stb-title" style="width:100%;margin:4px 0 12px"
1052+ maxlength="80" placeholder="Ex. : Revue mensuelle" value="${esc(SB.title)}">
1053+ ${SB.sel.length ? SB.sel.map((s, i) => {
1054+ const b = SB.cat.find(x => x.key === s.key) || { title: s.key, renders: [s.render] };
1055+ return `<div class="stb-item">
1056+ <button type="button" class="mv" data-up="${i}" ${i === 0 ? "disabled" : ""} aria-label="Monter">▲</button>
1057+ <button type="button" class="mv" data-dn="${i}" ${i === SB.sel.length - 1 ? "disabled" : ""} aria-label="Descendre">▼</button>
1058+ <span class="t" title="${esc(b.title)}"><b>${i + 1}.</b> ${esc(b.title)}</span>
1059+ ${b.renders.length > 1 ? `<select data-rd="${i}" aria-label="Rendu">
1060+ ${b.renders.map(r => `<option value="${r}"${r === s.render ? " selected" : ""}>${SB_RENDERS[r] || r}</option>`).join("")}
1061+ </select>` : `<span class="klabel">${SB_RENDERS[s.render] || s.render}</span>`}
1062+ <button type="button" class="mv" data-del="${i}" aria-label="Retirer">✕</button>
1063+ </div>`;
1064+ }).join("") : `<div class="stb-empty">Aucun bloc — ajoutez des blocs depuis la colonne de gauche,
1065+ ou chargez un modèle ci-dessous.</div>`}
1066+ <p style="display:flex;gap:8px;margin:10px 0 0">
1067+ <button type="button" class="btn btn-ghost" id="stb-all">Tout ajouter</button>
1068+ <button type="button" class="btn btn-ghost" id="stb-clear" ${SB.sel.length ? "" : "disabled"}>Vider</button>
1069+ </p>
1070+ ${SB.err ? `<p class="stb-err">${esc(SB.err)}</p>` : ""}
1071+ </div>
1072+ </div>
1073+ <div class="stb-foot">
1074+ <span class="stb-tpl">
1075+ <select class="input" id="stb-tpl" aria-label="Modèles sauvegardés" style="max-width:210px">
1076+ <option value="">Modèles (${tpls.length})…</option>
1077+ ${tpls.map((t, i) => `<option value="${i}">${esc(t.name)}</option>`).join("")}
1078+ </select>
1079+ <button type="button" class="btn btn-ghost" id="stb-save" ${SB.sel.length ? "" : "disabled"}>💾 Sauvegarder</button>
1080+ <button type="button" class="btn btn-ghost" id="stb-tpl-del" ${tpls.length ? "" : "disabled"}>🗑 Supprimer</button>
1081+ </span>
1082+ <button type="button" class="btn btn-primary" id="stb-go" ${SB.sel.length && !SB.busy ? "" : "disabled"}>
1083+ ${SB.busy ? "Génération…" : "⬇ Générer le PDF"}</button>
1084+ </div>
1085+ </div>`;
1086+
1087+ $("stb-close").onclick = sbClose;
1088+ $("stb-all").onclick = sbAll;
1089+ $("stb-clear").onclick = () => { SB.sel = []; sbRender(); };
1090+ $("stb-title").oninput = e => { SB.title = e.target.value; };
1091+ ov.querySelectorAll("[data-add]").forEach(b => b.onclick = () => sbAdd(b.dataset.add));
1092+ ov.querySelectorAll("[data-up]").forEach(b => b.onclick = () => sbMove(+b.dataset.up, -1));
1093+ ov.querySelectorAll("[data-dn]").forEach(b => b.onclick = () => sbMove(+b.dataset.dn, 1));
1094+ ov.querySelectorAll("[data-del]").forEach(b => b.onclick = () => sbDel(+b.dataset.del));
1095+ ov.querySelectorAll("[data-rd]").forEach(sl => sl.onchange = () => {
1096+ SB.sel[+sl.dataset.rd].render = sl.value;
1097+ });
1098+ $("stb-save").onclick = () => {
1099+ const name = prompt("Nom du modèle :", SB.title || "Mon rapport");
1100+ if(!name) return;
1101+ const tpl = sbTemplates().filter(t => t.name !== name);
1102+ tpl.push({ name, title: SB.title, blocks: SB.sel.map(s => ({...s})) });
1103+ sbSaveTemplates(tpl);
1104+ sbRender();
1105+ };
1106+ $("stb-tpl").onchange = e => {
1107+ const t = sbTemplates()[+e.target.value];
1108+ if(!t) return;
1109+ SB.title = t.title || t.name;
1110+ SB.sel = (t.blocks || []).filter(s => SB.cat.some(b => b.key === s.key)).map(s => ({...s}));
1111+ sbRender();
1112+ };
1113+ $("stb-tpl-del").onclick = () => {
1114+ const sel = $("stb-tpl");
1115+ const t = sbTemplates()[+sel.value];
1116+ if(!t || !confirm(`Supprimer le modèle « ${t.name} » ?`)) return;
1117+ sbSaveTemplates(sbTemplates().filter(x => x.name !== t.name));
1118+ sbRender();
1119+ };
1120+ $("stb-go").onclick = sbGenerate;
1121+}
1122+
1123+async function sbGenerate(){
1124+ if(SB.busy || !SB.sel.length) return;
1125+ SB.busy = true; SB.err = ""; sbRender();
1126+ try{
1127+ const body = { title: SB.title, period: state.period, blocks: SB.sel };
1128+ if(state.from && state.to){ body.from = state.from; body.to = state.to; }
1129+ const r = await fetch("/api/stats/report/custom", {
1130+ method: "POST", headers: { "Content-Type": "application/json" },
1131+ body: JSON.stringify(body) });
1132+ if(!r.ok) throw new Error(`HTTP ${r.status}`);
1133+ const blob = await r.blob();
1134+ const cd = r.headers.get("Content-Disposition") || "";
1135+ const m = cd.match(/filename="?([^";]+)/);
1136+ const a = document.createElement("a");
1137+ a.href = URL.createObjectURL(blob);
1138+ a.download = m ? m[1] : "rapport-personnalise.pdf";
1139+ document.body.appendChild(a); a.click(); a.remove();
1140+ setTimeout(() => URL.revokeObjectURL(a.href), 4000);
1141+ }catch(e){
1142+ SB.err = "La génération a échoué — réessayez.";
1143+ }
1144+ SB.busy = false; sbRender();
1145+}
1146+
1147+$("st-custom-btn").onclick = () => { SB.cat = null; SB.err = ""; sbOpen(); };
905 </script> 1148 </script>
906 <script>window.KA_AGENT={site:"api-ka"}</script> 1149 <script>window.KA_AGENT={site:"api-ka"}</script>
907 <script src="/ka-agent.js" defer></script> 1150 <script src="/ka-agent.js" defer></script>
908 1151
Menu

Theme