SPB Git forge

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

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%

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

Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent f79fc27

3 changed files +696 −9

modified src/api/kapdf.py +398 −8
@@ -7,9 +7,9 @@
7 7 # Date : 2026-08-19
8 8 # ============================================
9 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 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 13 # complet — toutes les sections (KPI, jauges, séries + stats, multi-
14 14 # séries, empilées, distributions, répartitions, géo,
15 15 # heatmap horaire, tableaux, records)
@@ -17,12 +17,19 @@
17 17 # tendances — KPI + toutes les séries temporelles + stats de séries
18 18 # repartitions — breakdowns, distributions, géo, activité horaire
19 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 25 # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.
21 26 # Usage :
22 −# from kapdf import GroupeKAReport, REPORT_MODES, filename
27 +# from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename
23 28 # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
24 29 # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
25 30 # mode="complet").build()
31 +# pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise",
32 +# spec={"title": "Mon rapport", "blocks": [...]}).build()
26 33 # Dépendance : pip install fpdf2 (aucune autre)
27 34 from __future__ import annotations
28 35
@@ -48,6 +55,100 @@ REPORT_MODES = {
48 55 "repartitions": "Répartitions & géographie",
49 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 153 EMAILS = [
53 154 ("contact@groupe-ka.com", "Projets, partenariats & données"),
@@ -135,16 +236,25 @@ class _PDF(FPDF):
135 236
136 237
137 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 241 self.site = site
140 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 245 self.accent = _hex(site.get("accent", "#d9f26b"))
143 246 period = dashboard.get("period", {}) or {}
144 247 self.period_label = period.get("label") or "toute la période"
145 248 self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
146 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 258 # ---------- primitives ----------
149 259 def _card(self, x, y, w, h, fill=WHITE):
150 260 p = self.pdf
@@ -221,7 +331,7 @@ class GroupeKAReport:
221 331 p.set_xy(24, 100)
222 332 p.set_font("helvetica", "", 13)
223 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 335 now = datetime.now(ZoneInfo("America/Toronto"))
226 336 per = self.d.get("period", {}) or {}
227 337 p.set_xy(24, 125)
@@ -230,7 +340,7 @@ class GroupeKAReport:
230 340 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
231 341 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
232 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 345 y = 128
236 346 for k, v in rows:
@@ -693,6 +803,271 @@ class GroupeKAReport:
693 803 p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")
694 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 1071 def _table(self, t, max_rows=200):
697 1072 p = self.pdf
698 1073 cols = t.get("columns") or []
@@ -817,7 +1192,7 @@ class GroupeKAReport:
817 1192 p = self.pdf
818 1193 p.alias_nb_pages()
819 1194 self._cover()
820 − with_toc = self.mode in ("complet", "donnees")
1195 + with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE)
821 1196 toc_page_no = None
822 1197 if self.mode == "synthese":
823 1198 p.add_page()
@@ -843,6 +1218,21 @@ class GroupeKAReport:
843 1218 for t in self.d.get("tables") or []:
844 1219 self._table(t, max_rows=400)
845 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 1236 else: # complet
847 1237 p.add_page()
848 1238 toc_page_no = p.page_no()
modified src/api/routes/stats.py +55 −1
@@ -39,7 +39,7 @@ from typing import Any
39 39 from zoneinfo import ZoneInfo
40 40
41 41 import httpx
42 −from fastapi import APIRouter, Depends, HTTPException, Query, Response
42 +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response
43 43 from sqlalchemy import func, select
44 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 1287 # ------------------------------------------------- rapport écosystème (PDF)
1234 1288 async def _fetch_satellite_dashboard(
1235 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 175 .sec-gap{margin-top:30px}
176 176 @media(max-width:767px){section{padding:32px 0}.hero-s{padding:40px 0 32px}}
177 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 214 </style>
179 215 <link rel="canonical" href="https://www.api-ka.com/stats">
180 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 253 <button type="button" data-mode="donnees" role="menuitem">Données détaillées<small>Tous les tableaux en version longue</small></button>
218 254 </span>
219 255 </span>
256 + <button type="button" class="btn btn-ghost" id="st-custom-btn">🛠 Rapport personnalisé</button>
220 257 <span id="pdf-busy" role="status"></span>
221 258 </div>
222 259 <p class="fresh" style="margin-top:18px">
@@ -902,6 +939,212 @@ async function load(){
902 939 renderRecords(dash.records);
903 940 }
904 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 1148 </script>
906 1149 <script>window.KA_AGENT={site:"api-ka"}</script>
907 1150 <script src="/ka-agent.js" defer></script>
908 1151