SPB Git forge

spb/trouve-ka

Public ★ Pinned

Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com

40commits 1branches 0releases
9.2 MBsize
maindefault branch
22 days agolast push
Python 51.9% TypeScript 32.7% JavaScript 4.8% CSS 4.1% Shell 2.9% HTML 1.8% SQL 1.5%

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

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

4 changed files +676 −11

modified .gitignore +1 −0
@@ -11,3 +11,4 @@ __pycache__/
11 11 .ruff_cache/
12 12 data/
13 13 .DS_Store
14 +.env.bak*
modified apps/api/kapdf.py +398 −8
@@ -1,7 +1,7 @@
1 1 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 −# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v2
2 +# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v3
3 3 # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4 −# les rapports estampillés Groupe-KA. 5 modes :
4 +# les rapports estampillés Groupe-KA. 5 modes fixes :
5 5 # complet — toutes les sections (KPI, jauges, séries + stats, multi-
6 6 # séries, empilées, distributions, répartitions, géo,
7 7 # heatmap horaire, tableaux, records)
@@ -9,12 +9,19 @@
9 9 # tendances — KPI + toutes les séries temporelles + stats de séries
10 10 # repartitions — breakdowns, distributions, géo, activité horaire
11 11 # donnees — tous les tableaux en version longue (400 lignes max)
12 +# v3 : mode « personnalise » — l'utilisateur compose son rapport bloc par
13 +# bloc (choix des données ET du rendu par bloc : courbe/aire/barres/anneau/
14 +# heatmap/tableau…). catalog(dash) expose les blocs disponibles ; le rapport
15 +# suit une spec {"title": str, "blocks": [{"key": "series:ajouts",
16 +# "render": "bar"}, …]} et respecte l'ordre demandé.
12 17 # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.
13 18 # Usage :
14 −# from kapdf import GroupeKAReport, REPORT_MODES, filename
19 +# from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename
15 20 # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
16 21 # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
17 22 # mode="complet").build()
23 +# pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise",
24 +# spec={"title": "Mon rapport", "blocks": [...]}).build()
18 25 # Dépendance : pip install fpdf2 (aucune autre)
19 26 from __future__ import annotations
20 27
@@ -40,6 +47,100 @@ REPORT_MODES = {
40 47 "repartitions": "Répartitions & géographie",
41 48 "donnees": "Données détaillées",
42 49 }
50 +# v3 — mode composé par l'utilisateur (jamais dans le menu des modes fixes)
51 +CUSTOM_MODE = "personnalise"
52 +CUSTOM_LABEL = "Rapport personnalisé"
53 +
54 +# v3 — rendus proposés par type de bloc (le 1er est le rendu par défaut ;
55 +# « table » est toujours offert : toute donnée a un équivalent tableau)
56 +RENDER_LABELS = {
57 + "line": "Courbe", "area": "Aire", "bar": "Barres verticales",
58 + "bars": "Barres horizontales", "donut": "Anneau",
59 + "lines": "Multi-courbes", "stacked": "Barres empilées",
60 + "histogram": "Histogramme", "heatmap": "Heatmap",
61 + "cards": "Cartes", "gauges": "Jauges", "table": "Tableau",
62 +}
63 +SECTION_LABELS = {
64 + "kpis": "Indicateurs", "gauges": "Taux & couvertures",
65 + "series": "Évolution", "multiseries": "Comparaisons",
66 + "stacked": "Compositions", "breakdowns": "Répartitions",
67 + "distributions": "Distributions", "geo": "Géographie",
68 + "heatmap": "Calendrier", "hourly": "Activité horaire",
69 + "tables": "Tableaux", "records": "Records",
70 +}
71 +
72 +
73 +def catalog(dash: dict) -> list[dict]:
74 + """v3 — blocs composables d'un dashboard : ce que le constructeur de
75 + rapports personnalisés peut inclure, avec les rendus compatibles.
76 + key = section[:id] ; l'ordre renvoyé = ordre naturel du dashboard."""
77 + out: list[dict] = []
78 +
79 + def add(key, title, renders, default=None, count=None):
80 + b = {"key": key, "section": key.split(":")[0], "title": title,
81 + "renders": renders, "default_render": default or renders[0]}
82 + if count is not None:
83 + b["count"] = count
84 + out.append(b)
85 +
86 + if dash.get("kpis"):
87 + add("kpis", "Indicateurs clés (KPI)", ["cards", "table"],
88 + count=len(dash["kpis"]))
89 + gs = [g for g in (dash.get("gauges") or [])
90 + if isinstance(g.get("value"), (int, float)) and g.get("max")]
91 + if gs:
92 + add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"],
93 + count=len(gs))
94 + for s in dash.get("series") or []:
95 + if len(s.get("points") or []) < 2:
96 + continue
97 + kind = s.get("kind") or "line"
98 + default = kind if kind in ("line", "area", "bar") else "line"
99 + add(f"series:{s.get('id')}", s.get("title", ""),
100 + ["line", "area", "bar", "table"], default,
101 + len(s.get("points") or []))
102 + for ms in dash.get("multiseries") or []:
103 + if not (ms.get("series") or []):
104 + continue
105 + add(f"multiseries:{ms.get('id')}", ms.get("title", ""),
106 + ["lines", "table"], count=len(ms["series"]))
107 + for st in dash.get("stacked") or []:
108 + if not (st.get("points") or []):
109 + continue
110 + add(f"stacked:{st.get('id')}", st.get("title", ""),
111 + ["stacked", "table"], count=len(st.get("keys") or []))
112 + for b in dash.get("breakdowns") or []:
113 + if not (b.get("items") or []):
114 + continue
115 + default = "donut" if b.get("kind") == "donut" else "bars"
116 + add(f"breakdowns:{b.get('id')}", b.get("title", ""),
117 + ["donut", "bars", "table"], default, len(b["items"]))
118 + for d in dash.get("distributions") or []:
119 + if not (d.get("bins") or []):
120 + continue
121 + add(f"distributions:{d.get('id')}", d.get("title", ""),
122 + ["histogram", "table"], count=len(d["bins"]))
123 + geo = dash.get("geo") or {}
124 + if geo.get("items"):
125 + add("geo", geo.get("title", "Répartition géographique"),
126 + ["bars", "table"], count=len(geo["items"]))
127 + hm = dash.get("heatmap") or {}
128 + if hm.get("cells"):
129 + add("heatmap", hm.get("title", "Calendrier d'activité"),
130 + ["heatmap", "table"])
131 + hr = dash.get("hourly") or {}
132 + if hr.get("cells"):
133 + add("hourly", hr.get("title", "Activité par jour et heure"),
134 + ["heatmap", "table"])
135 + for t in dash.get("tables") or []:
136 + if not (t.get("rows") or []):
137 + continue
138 + add(f"tables:{t.get('id')}", t.get("title", ""), ["table"],
139 + count=len(t["rows"]))
140 + if dash.get("records"):
141 + add("records", "Records & faits marquants", ["cards", "table"],
142 + count=len(dash["records"]))
143 + return out
43 144
44 145 EMAILS = [
45 146 ("contact@groupe-ka.com", "Projets, partenariats & données"),
@@ -127,16 +228,25 @@ class _PDF(FPDF):
127 228
128 229
129 230 class GroupeKAReport:
130 − def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
231 + def __init__(self, site: dict, dashboard: dict, mode: str = "complet",
232 + spec: dict | None = None):
131 233 self.site = site
132 234 self.d = dashboard
133 − self.mode = mode if mode in REPORT_MODES else "complet"
235 + self.mode = mode if (mode in REPORT_MODES or mode == CUSTOM_MODE) else "complet"
236 + self.spec = spec or {}
134 237 self.accent = _hex(site.get("accent", "#d9f26b"))
135 238 period = dashboard.get("period", {}) or {}
136 239 self.period_label = period.get("label") or "toute la période"
137 240 self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
138 241 self.toc: list[tuple[str, int]] = []
139 242
243 + @property
244 + def mode_label(self) -> str:
245 + if self.mode == CUSTOM_MODE:
246 + t = str(self.spec.get("title") or "").strip()
247 + return f"{CUSTOM_LABEL} — {t}" if t else CUSTOM_LABEL
248 + return REPORT_MODES[self.mode]
249 +
140 250 # ---------- primitives ----------
141 251 def _card(self, x, y, w, h, fill=WHITE):
142 252 p = self.pdf
@@ -213,7 +323,7 @@ class GroupeKAReport:
213 323 p.set_xy(24, 100)
214 324 p.set_font("helvetica", "", 13)
215 325 p.set_text_color(*INK2)
216 − p.multi_cell(150, 7, f"{REPORT_MODES[self.mode]} — {wm}")
326 + p.multi_cell(150, 7, f"{self.mode_label} — {wm}")
217 327 now = datetime.now(ZoneInfo("America/Toronto"))
218 328 per = self.d.get("period", {}) or {}
219 329 p.set_xy(24, 125)
@@ -222,7 +332,7 @@ class GroupeKAReport:
222 332 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
223 333 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
224 334 ("Plateforme", "https://" + self.site.get("domain", "")),
225 − ("Type de rapport", REPORT_MODES[self.mode]),
335 + ("Type de rapport", self.mode_label),
226 336 ]
227 337 y = 128
228 338 for k, v in rows:
@@ -685,6 +795,271 @@ class GroupeKAReport:
685 795 p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")
686 796 p.set_y(y0 + ly + 7 * chh + 5)
687 797
798 + def _calheat(self, hm):
799 + """v3 — calendrier de chaleur 26 semaines (équivalent PDF du
800 + CalendarHeatmap du kit front) : colonnes = semaines, lignes = jours."""
801 + from datetime import date as _date, timedelta as _td
802 + cells = hm.get("cells") or []
803 + vals = {c.get("date"): c.get("value") or 0 for c in cells if c.get("date")}
804 + if not vals:
805 + return
806 + p = self.pdf
807 + if p.get_y() > 215:
808 + p.add_page()
809 + self._chart_title(hm.get("title", "Calendrier d'activité"))
810 + try:
811 + end = _date.fromisoformat(max(vals))
812 + except ValueError:
813 + return
814 + weeks = 26
815 + start = end - _td(days=weeks * 7 - 1)
816 + start -= _td(days=start.weekday()) # lundi
817 + vmax = max(vals.values()) or 1
818 + x0, y0 = p.l_margin, p.get_y()
819 + cw, lx, ly = 6.3, 10, 4
820 + dows = ["Lun", "", "Mer", "", "Ven", "", "Dim"]
821 + p.set_font("helvetica", "", 5.8)
822 + p.set_text_color(*INK3)
823 + for d in range(7):
824 + if dows[d]:
825 + p.set_xy(x0, y0 + ly + d * cw + 1.2)
826 + p.cell(lx - 1, 3, dows[d], align="R")
827 + for w in range(weeks):
828 + monday = start + _td(days=7 * w)
829 + if monday.day <= 7: # étiquette de mois à la 1re semaine du mois
830 + p.set_xy(x0 + lx + w * cw, y0)
831 + p.cell(cw * 4, 3, monday.strftime("%m"))
832 + for d in range(7):
833 + day = monday + _td(days=d)
834 + v = vals.get(day.isoformat(), 0)
835 + f = 0.15 + 0.85 * (v / vmax) if v else 0.0
836 + col = (tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f)
837 + for j in range(3)) if v else (235, 233, 228))
838 + p.set_fill_color(*col)
839 + p.set_draw_color(215, 213, 207)
840 + p.set_line_width(0.1)
841 + p.rect(x0 + lx + w * cw, y0 + ly + d * cw, cw - 0.5, cw - 0.5,
842 + style="DF")
843 + p.set_y(y0 + ly + 7 * cw + 5)
844 +
845 + # ---------- v3 : conversions bloc → tableau ----------
846 + @staticmethod
847 + def _serie_as_table(s):
848 + unit = s.get("unit") or "Valeur"
849 + cols = ["Date", unit.capitalize()]
850 + cmp_ = s.get("compare") or []
851 + if cmp_:
852 + cols.append("Période comparée")
853 + rows = []
854 + for i, pt in enumerate(s.get("points") or []):
855 + row = [str(pt.get("t", "")), pt.get("v", "")]
856 + if cmp_:
857 + row.append(cmp_[i]["v"] if i < len(cmp_) else "")
858 + rows.append(row)
859 + return {"id": s.get("id"), "title": s.get("title", ""),
860 + "columns": cols, "rows": rows}
861 +
862 + @staticmethod
863 + def _multi_as_table(ms):
864 + labels = [s.get("label", "") for s in (ms.get("series") or [])][:4]
865 + by_t: dict[str, dict] = {}
866 + for s in (ms.get("series") or [])[:4]:
867 + for pt in s.get("points") or []:
868 + by_t.setdefault(str(pt.get("t", "")), {})[s.get("label", "")] = pt.get("v")
869 + rows = [[t] + [by_t[t].get(lbl, "") for lbl in labels]
870 + for t in sorted(by_t)]
871 + return {"id": ms.get("id"), "title": ms.get("title", ""),
872 + "columns": ["Date"] + labels, "rows": rows}
873 +
874 + @staticmethod
875 + def _stacked_as_table(st):
876 + keys = (st.get("keys") or [])[:6]
877 + rows = []
878 + for pt in st.get("points") or []:
879 + vs = [(pt.get("values") or [])[j] if j < len(pt.get("values") or []) else 0
880 + for j in range(len(keys))]
881 + rows.append([str(pt.get("t", ""))] + vs + [sum(v or 0 for v in vs)])
882 + return {"id": st.get("id"), "title": st.get("title", ""),
883 + "columns": ["Date"] + list(keys) + ["Total"], "rows": rows}
884 +
885 + @staticmethod
886 + def _items_as_table(id_, title, items, label_col="Libellé"):
887 + items = items or []
888 + with_delta = any(it.get("delta_pct") is not None for it in items)
889 + cols = [label_col, "Valeur"] + (["delta %"] if with_delta else [])
890 + rows = []
891 + for it in items:
892 + row = [str(it.get("label", "")), it.get("value", "")]
893 + if with_delta:
894 + d = it.get("delta_pct")
895 + row.append("" if d is None else f"{'+' if d >= 0 else ''}{d} %")
896 + rows.append(row)
897 + return {"id": id_, "title": title, "columns": cols, "rows": rows}
898 +
899 + def _kpis_as_table(self):
900 + rows = []
901 + for k in self.d.get("kpis") or []:
902 + v = k.get("value")
903 + val = (_fr(v) if isinstance(v, (int, float)) else str(v)) + \
904 + ((" " + k["unit"]) if k.get("unit") else "")
905 + d = k.get("delta_pct")
906 + rows.append([str(k.get("label", "")), val,
907 + "" if d is None else f"{'+' if d >= 0 else ''}{d} %"])
908 + return {"id": "kpis", "title": "Indicateurs clés",
909 + "columns": ["Indicateur", "Valeur", "delta %"], "rows": rows}
910 +
911 + def _gauges_as_table(self):
912 + rows = [[str(g.get("label", "")),
913 + f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}",
914 + _fr(g["max"]), f"{100.0 * g['value'] / g['max']:.0f} %"]
915 + for g in self.d.get("gauges") or []
916 + if isinstance(g.get("value"), (int, float)) and g.get("max")]
917 + return {"id": "gauges", "title": "Taux & couvertures",
918 + "columns": ["Mesure", "Valeur", "Max", "Part"], "rows": rows}
919 +
920 + def _records_as_table(self):
921 + rows = [[str(r.get("label", "")), str(r.get("value", "")),
922 + str(r.get("date", "") or "")]
923 + for r in self.d.get("records") or []]
924 + return {"id": "records", "title": "Records & faits marquants",
925 + "columns": ["Fait marquant", "Valeur", "Date"], "rows": rows}
926 +
927 + @staticmethod
928 + def _heatmap_as_table(hm, title):
929 + cells = sorted((hm.get("cells") or []),
930 + key=lambda c: -(c.get("value") or 0))[:40]
931 + return {"id": "heatmap", "title": title + " — jours les plus chargés",
932 + "columns": ["Date", "Valeur"],
933 + "rows": [[c.get("date", ""), c.get("value") or 0] for c in cells]}
934 +
935 + @staticmethod
936 + def _hourly_as_table(hr, title):
937 + days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi",
938 + "Dimanche"]
939 + cells = sorted((hr.get("cells") or []),
940 + key=lambda c: -(c.get("value") or 0))[:40]
941 + return {"id": "hourly", "title": title + " — créneaux les plus actifs",
942 + "columns": ["Jour", "Heure", "Valeur"],
943 + "rows": [[days[c["dow"]] if 0 <= c.get("dow", -1) <= 6 else "?",
944 + f"{c.get('hour', '?')} h", c.get("value") or 0]
945 + for c in cells]}
946 +
947 + # ---------- v3 : rendu d'un bloc du rapport personnalisé ----------
948 + def _find(self, coll: str, id_: str):
949 + for it in self.d.get(coll) or []:
950 + if str(it.get("id")) == id_:
951 + return it
952 + return None
953 +
954 + def _toc_mark(self, title: str):
955 + """Blocs graphiques du mode personnalisé : entrée de sommaire sans
956 + _section_title (le graphique porte déjà son titre)."""
957 + if self.pdf.get_y() > 235:
958 + self.pdf.add_page()
959 + self.toc.append((title, self.pdf.page_no()))
960 +
961 + def _render_block(self, key: str, render: str):
962 + section, _, id_ = key.partition(":")
963 + if section == "kpis":
964 + self._table(self._kpis_as_table()) if render == "table" else self._kpis()
965 + elif section == "gauges":
966 + self._table(self._gauges_as_table()) if render == "table" else self._gauges()
967 + elif section == "records":
968 + self._table(self._records_as_table()) if render == "table" else self._records()
969 + elif section == "series":
970 + s = self._find("series", id_)
971 + if not s:
972 + return
973 + if render == "table":
974 + self._table(self._serie_as_table(s), max_rows=400)
975 + else:
976 + s2 = dict(s)
977 + if render in ("line", "area", "bar"):
978 + s2["kind"] = render
979 + self._toc_mark(s2.get("title", ""))
980 + if s2.get("kind") == "bar":
981 + self._vbars(s2)
982 + else:
983 + self._line_chart(s2, with_stats=True)
984 + elif section == "multiseries":
985 + ms = self._find("multiseries", id_)
986 + if not ms:
987 + return
988 + if render == "table":
989 + self._table(self._multi_as_table(ms), max_rows=400)
990 + else:
991 + self._toc_mark(ms.get("title", ""))
992 + self._multiline(ms)
993 + elif section == "stacked":
994 + st = self._find("stacked", id_)
995 + if not st:
996 + return
997 + if render == "table":
998 + self._table(self._stacked_as_table(st), max_rows=400)
999 + else:
1000 + self._toc_mark(st.get("title", ""))
1001 + self._stacked(st)
1002 + elif section == "breakdowns":
1003 + b = self._find("breakdowns", id_)
1004 + if not b:
1005 + return
1006 + if render == "table":
1007 + self._table(self._items_as_table(id_, b.get("title", ""),
1008 + b.get("items")), max_rows=400)
1009 + else:
1010 + self._toc_mark(b.get("title", ""))
1011 + if render == "donut":
1012 + self._donut(b)
1013 + else:
1014 + self._bars(b.get("title", ""), b.get("items"))
1015 + elif section == "distributions":
1016 + d = self._find("distributions", id_)
1017 + if not d:
1018 + return
1019 + if render == "table":
1020 + bins = [{"label": bn.get("label"), "value": bn.get("value")}
1021 + for bn in d.get("bins") or []]
1022 + self._table(self._items_as_table(id_, d.get("title", ""), bins,
1023 + label_col="Tranche"))
1024 + else:
1025 + self._toc_mark(d.get("title", ""))
1026 + self._vbars(d)
1027 + elif section == "geo":
1028 + geo = self.d.get("geo") or {}
1029 + if not geo.get("items"):
1030 + return
1031 + title = geo.get("title", "Répartition géographique")
1032 + if render == "table":
1033 + self._table(self._items_as_table("geo", title, geo["items"],
1034 + label_col="Zone"), max_rows=400)
1035 + else:
1036 + self._toc_mark(title)
1037 + self._bars(title, geo["items"])
1038 + elif section == "heatmap":
1039 + hm = self.d.get("heatmap") or {}
1040 + if not hm.get("cells"):
1041 + return
1042 + title = hm.get("title", "Calendrier d'activité")
1043 + if render == "table":
1044 + self._table(self._heatmap_as_table(hm, title))
1045 + else:
1046 + self._toc_mark(title)
1047 + self._calheat(hm)
1048 + elif section == "hourly":
1049 + hr = self.d.get("hourly") or {}
1050 + if not hr.get("cells"):
1051 + return
1052 + title = hr.get("title", "Activité par jour et heure")
1053 + if render == "table":
1054 + self._table(self._hourly_as_table(hr, title))
1055 + else:
1056 + self._toc_mark(title)
1057 + self._hourly()
1058 + elif section == "tables":
1059 + t = self._find("tables", id_)
1060 + if t:
1061 + self._table(t, max_rows=400)
1062 +
688 1063 def _table(self, t, max_rows=200):
689 1064 p = self.pdf
690 1065 cols = t.get("columns") or []
@@ -809,7 +1184,7 @@ class GroupeKAReport:
809 1184 p = self.pdf
810 1185 p.alias_nb_pages()
811 1186 self._cover()
812 − with_toc = self.mode in ("complet", "donnees")
1187 + with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE)
813 1188 toc_page_no = None
814 1189 if self.mode == "synthese":
815 1190 p.add_page()
@@ -835,6 +1210,21 @@ class GroupeKAReport:
835 1210 for t in self.d.get("tables") or []:
836 1211 self._table(t, max_rows=400)
837 1212 self._final_page()
1213 + elif self.mode == CUSTOM_MODE:
1214 + p.add_page()
1215 + toc_page_no = p.page_no()
1216 + p.add_page()
1217 + known = {b["key"]: b for b in catalog(self.d)}
1218 + for blk in self.spec.get("blocks") or []:
1219 + key = str(blk.get("key", ""))
1220 + b = known.get(key)
1221 + if not b:
1222 + continue
1223 + render = str(blk.get("render") or "")
1224 + if render not in b["renders"]:
1225 + render = b["default_render"]
1226 + self._render_block(key, render)
1227 + self._final_page()
838 1228 else: # complet
839 1229 p.add_page()
840 1230 toc_page_no = p.page_no()
modified apps/api/main.py +64 −2
@@ -19,12 +19,13 @@ from contextlib import asynccontextmanager
19 19 from typing import Annotated
20 20
21 21 import anyio.to_thread
22 −from fastapi import Depends, FastAPI, Header, HTTPException, Query, Response
22 +from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Response
23 23 from fastapi.middleware.cors import CORSMiddleware
24 24 from pydantic import BaseModel
25 25
26 26 from trouveka.api import stats as stats_mod
27 −from trouveka.api.kapdf import REPORT_MODES, GroupeKAReport
27 +from trouveka.api.kapdf import CUSTOM_MODE, REPORT_MODES, GroupeKAReport
28 +from trouveka.api.kapdf import catalog as pdf_catalog
28 29 from trouveka.api.kapdf import filename as pdf_filename
29 30 from trouveka.config import get_settings
30 31 from trouveka.database import Database
@@ -340,6 +341,67 @@ async def api_stats_report(
340 341 )
341 342
342 343
344 +@app.get("/api/stats/catalog")
345 +async def api_stats_catalog(
346 + period: str = Query("30j", pattern=PERIOD_PATTERN),
347 + from_date: str | None = Query(None, alias="from", pattern=r"^\d{4}-\d{2}-\d{2}$"),
348 + to_date: str | None = Query(None, alias="to", pattern=r"^\d{4}-\d{2}-\d{2}$"),
349 +):
350 + """v3 — blocs composables pour le constructeur de rapports personnalisés."""
351 + try:
352 + dash = await stats_mod.dashboard(db, period, from_date, to_date)
353 + except Exception:
354 + log.exception("catalogue stats indisponible")
355 + raise HTTPException(status_code=503, detail="Statistiques temporairement indisponibles")
356 + return {"updated": dash.get("updated"), "period": dash.get("period"),
357 + "blocks": pdf_catalog(dash)}
358 +
359 +
360 +_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
361 +
362 +
363 +@app.post("/api/stats/report/custom")
364 +async def api_stats_report_custom(spec: dict = Body(...)):
365 + """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",
366 + "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""
367 + period = str(spec.get("period") or "30j")
368 + if not re.match(PERIOD_PATTERN, period):
369 + period = "30j"
370 + from_date = str(spec.get("from") or "") or None
371 + to_date = str(spec.get("to") or "") or None
372 + if from_date and not _DATE_RE.match(from_date):
373 + from_date = None
374 + if to_date and not _DATE_RE.match(to_date):
375 + to_date = None
376 + try:
377 + dash = await stats_mod.dashboard(db, period, from_date, to_date)
378 + except Exception:
379 + log.exception("rapport PDF personnalisé indisponible")
380 + raise HTTPException(status_code=503, detail="Rapport temporairement indisponible")
381 + known = {b["key"] for b in pdf_catalog(dash)}
382 + blocks = [b for b in (spec.get("blocks") or [])
383 + if isinstance(b, dict) and b.get("key") in known][:40]
384 + if not blocks:
385 + raise HTTPException(400, "Aucun bloc valide dans la composition")
386 + try:
387 + pdf_bytes = await anyio.to_thread.run_sync(
388 + lambda: GroupeKAReport(
389 + site=stats_mod.SITE, dashboard=dash, mode=CUSTOM_MODE,
390 + spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},
391 + ).build()
392 + )
393 + except Exception:
394 + log.exception("rapport PDF personnalisé indisponible")
395 + raise HTTPException(status_code=503, detail="Rapport temporairement indisponible")
396 + return Response(
397 + content=pdf_bytes,
398 + media_type="application/pdf",
399 + headers={
400 + "Content-Disposition": f'attachment; filename="{pdf_filename("trouve-ka", period, CUSTOM_MODE)}"'
401 + },
402 + )
403 +
404 +
343 405 @app.get("/api/live")
344 406 async def api_live():
345 407 """Dernière page visitée par TrouveKABot — alimente le flux temps réel du footer.
modified apps/web/ka/stats/kacharts.tsx +213 −1
@@ -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 680 export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) {
681 681 const [open, setOpen] = useState(false);
682 682 const [busy, setBusy] = useState<string | null>(null);
683 + const [builder, setBuilder] = useState(false);
683 684 const box = useRef<HTMLSpanElement>(null);
684 685 useEffect(() => {
685 686 if (!open) return;
@@ -715,6 +716,13 @@ export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }:
715 716 aria-haspopup="menu" aria-expanded={open}>
716 717 Autres rapports ▾
717 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 726 {open && (
719 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 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 947 /* ---------- États ---------- */
736 948 export function EmptyBlock({ title }: { title: string }) {
737 949 return (
738 950