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%

Rapport écosystème complet : GET /api/stats/ecosystem-report (PDF consolidé des 12 plateformes)

- src/api/ecopdf.py : EcosystemReport compose le moteur kapdf (couverture
  Groupe KA boîte encre + lime, page de consolidation avec totaux et
  classement des plateformes, une section par plateforme avec pastille
  accent + wordmark + KPI + records, page de fin) ; plateforme injoignable
  = section « données indisponibles » propre, jamais d'échec global.
- /api/stats/ecosystem-report?period=…&mode=complet|synthese : dashboards
  des plateformes sœurs récupérés en parallèle (httpx, délai 15 s), le
  sien calculé en local ; cache 10 min par (period, mode) ; filename
  groupe-ka_ecosysteme_stats_<periode>_<date>.pdf.
- kapdf._kpis : titre de section paramétrable (extension rétrocompatible).
- reqstats : /api/stats/ecosystem-report ajouté aux chemins connus.
- tests : test_ecosystem_pdf_builder (complet + synthese, hors-ligne).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent 792661b

5 changed files +549 −3

added src/api/ecopdf.py +362 −0
@@ -0,0 +1,362 @@
1 +# ============================================
2 +# Projet : API-KA
3 +# Fichier : src/api/ecopdf.py
4 +# Node : m3u96b
5 +# Author : Simon-Pierre Boucher
6 +# Contact : contact@spboucher.ai
7 +# Date : 2026-08-17
8 +# ============================================
9 +"""Rapport écosystème Groupe KA — un seul PDF consolidant les 12 plateformes.
10 +
11 +Compose le moteur PDF commun (``src/api/kapdf.py``) sans le dupliquer :
12 +``EcosystemReport`` étend ``GroupeKAReport`` et réutilise ses primitives
13 +(cartes KPI, tableaux, records, page de fin). Il ajoute la couverture
14 +« écosystème », la page de consolidation (totaux + classement) puis une
15 +section par plateforme. Une plateforme injoignable devient une section
16 +« données indisponibles » propre — jamais un échec global.
17 +"""
18 +
19 +from __future__ import annotations
20 +
21 +from typing import Any
22 +
23 +from src.api.kapdf import (
24 + GREEN,
25 + INK,
26 + INK2,
27 + INK3,
28 + PAPER,
29 + SURFACE2,
30 + GroupeKAReport,
31 + _fr,
32 + _hex,
33 +)
34 +
35 +# Identifiants de KPI « nouveautés de la période » connus des plateformes
36 +# (chacune nomme la sienne : nouvelles annonces, nouveaux véhicules, ajouts…).
37 +NEW_KPI_IDS = (
38 + "nouvelles",
39 + "nouveaux",
40 + "nouveautes",
41 + "new",
42 + "ajouts",
43 + "added",
44 + "indexees",
45 + "releves",
46 + "records",
47 +)
48 +
49 +ECO_SITE = {
50 + "wordmark": "Écosystème",
51 + "accent": "#d9f26b",
52 + "domain": "www.groupe-ka.com",
53 + "tagline": "Le portail de l'écosystème ·Ka",
54 +}
55 +
56 +
57 +def main_kpi(dash: dict | None) -> dict | None:
58 + """Premier KPI numérique = volume principal de la plateforme (convention
59 + du contrat SPEC : l'indicateur de volume est listé en premier)."""
60 + for k in (dash or {}).get("kpis") or []:
61 + if isinstance(k.get("value"), (int, float)) and not isinstance(k["value"], bool):
62 + return k
63 + return None
64 +
65 +
66 +def new_kpi(dash: dict | None) -> dict | None:
67 + """KPI « nouveautés de la période » si la plateforme le mesure."""
68 + kpis = (dash or {}).get("kpis") or []
69 + for kid in NEW_KPI_IDS:
70 + for k in kpis:
71 + if k.get("id") == kid and isinstance(k.get("value"), (int, float)):
72 + return k
73 + return None
74 +
75 +
76 +class EcosystemReport(GroupeKAReport):
77 + """PDF consolidé : couverture Groupe KA, consolidation, 1 section par
78 + plateforme (mode complet), page de fin. Mode ``synthese`` = couverture +
79 + consolidation + fin.
80 +
81 + ``platforms`` : liste de ``{"site": …, "dashboard": dict | None,
82 + "error": str | None}`` (une entrée par plateforme de données)."""
83 +
84 + def __init__(self, platforms: list[dict], period: dict, mode: str = "complet"):
85 + super().__init__(dict(ECO_SITE), {"period": period or {}}, mode)
86 + self.platforms = platforms
87 + available = [p for p in platforms if p.get("dashboard")]
88 + missing = [p for p in platforms if not p.get("dashboard")]
89 +
90 + def _vol(p: dict) -> float:
91 + k = main_kpi(p.get("dashboard"))
92 + return float(k["value"]) if k else 0.0
93 +
94 + self.ranked = sorted(available, key=_vol, reverse=True) + missing
95 +
96 + # ------------------------------------------------------------- couverture
97 + def _cover(self):
98 + p = self.pdf
99 + p.cover_mode = True
100 + p.set_auto_page_break(False)
101 + p.add_page()
102 + p.set_fill_color(*PAPER)
103 + p.rect(0, 0, 210, 297, style="F")
104 + p.set_draw_color(*INK)
105 + p.set_line_width(1.0)
106 + p.rect(10, 10, 190, 277)
107 + p.set_font("helvetica", "B", 10)
108 + p.set_text_color(*GREEN)
109 + p.set_xy(24, 34)
110 + p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")
111 + # wordmark « Groupe [KA] » : boîte encre, lettres lime
112 + p.set_xy(24, 70)
113 + p.set_font("helvetica", "B", 40)
114 + p.set_text_color(*INK)
115 + p.cell(p.get_string_width("Groupe") + 2, 20, "Groupe")
116 + bw = p.get_string_width("KA") + 12
117 + x = p.get_x() + 2
118 + p.set_fill_color(*INK)
119 + p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)
120 + p.set_text_color(*self.accent)
121 + p.set_xy(x + 6, 70)
122 + p.cell(bw - 12, 18, "KA")
123 + p.set_xy(24, 100)
124 + p.set_font("helvetica", "B", 16)
125 + p.set_text_color(*INK)
126 + p.cell(0, 8, "RAPPORT ÉCOSYSTÈME — 12 plateformes")
127 + p.set_xy(24, 109)
128 + p.set_font("helvetica", "", 11)
129 + p.set_text_color(*INK2)
130 + p.cell(0, 6, "Consolidation des plateformes de données du Groupe KA")
131 + from datetime import datetime
132 + from zoneinfo import ZoneInfo
133 +
134 + now = datetime.now(ZoneInfo("America/Toronto"))
135 + per = self.d.get("period", {}) or {}
136 + joined = sum(1 for pl in self.platforms if pl.get("dashboard"))
137 + rows = [
138 + (
139 + "Période couverte",
140 + self.period_label
141 + + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else ""),
142 + ),
143 + ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
144 + (
145 + "Plateformes",
146 + f"{len(self.platforms)} plateformes de données + le hub groupe-ka.com "
147 + f"({joined}/{len(self.platforms)} jointes)",
148 + ),
149 + ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),
150 + ]
151 + y = 128
152 + p.set_font("helvetica", "", 10.5)
153 + for k, v in rows:
154 + p.set_xy(24, y)
155 + p.set_text_color(*INK3)
156 + p.cell(40, 6, k)
157 + p.set_text_color(*INK)
158 + p.set_font("helvetica", "B", 10.5)
159 + p.cell(0, 6, str(v))
160 + p.set_font("helvetica", "", 10.5)
161 + y += 8
162 + # bande encre au pied
163 + p.set_fill_color(*INK)
164 + p.rect(10, 262, 190, 25, style="F")
165 + p.set_xy(24, 270)
166 + p.set_font("helvetica", "B", 12)
167 + p.set_text_color(255, 255, 255)
168 + p.cell(60, 8, "par Groupe ")
169 + p.set_text_color(*self.accent)
170 + p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)
171 + p.cell(20, 8, "KA")
172 + p.set_font("helvetica", "B", 10)
173 + p.set_xy(24, 270)
174 + p.set_text_color(*self.accent)
175 + p.cell(162, 8, "groupe-ka.com", align="R")
176 + p.set_auto_page_break(True, margin=22)
177 + p.cover_mode = False
178 +
179 + # ---------------------------------------------------------- consolidation
180 + def _consolidation(self):
181 + p = self.pdf
182 + available = [pl for pl in self.ranked if pl.get("dashboard")]
183 + missing = [pl for pl in self.ranked if not pl.get("dashboard")]
184 + total_vol = 0.0
185 + total_new = 0.0
186 + has_new = 0
187 + for pl in available:
188 + mk = main_kpi(pl["dashboard"])
189 + if mk:
190 + total_vol += float(mk["value"])
191 + nk = new_kpi(pl["dashboard"])
192 + if nk:
193 + total_new += float(nk["value"])
194 + has_new += 1
195 + kpis: list[dict[str, Any]] = [
196 + {
197 + "id": "vol",
198 + "label": "Volume principal agrégé (somme des indicateurs principaux)",
199 + "value": int(total_vol),
200 + },
201 + {
202 + "id": "joined",
203 + "label": "Plateformes de données jointes au moment de la génération",
204 + "value": len(available),
205 + "unit": f"/ {len(self.platforms)}",
206 + },
207 + ]
208 + if has_new:
209 + kpis.append(
210 + {
211 + "id": "new",
212 + "label": f"Nouveautés sur la période ({has_new} plateformes qui les mesurent)",
213 + "value": int(total_new),
214 + }
215 + )
216 + saved = self.d
217 + self.d = {"kpis": kpis}
218 + self._kpis("Consolidation de l'écosystème")
219 + self.d = saved
220 +
221 + rows: list[list[Any]] = []
222 + for i, pl in enumerate(self.ranked, 1):
223 + s = pl["site"]
224 + dash = pl.get("dashboard")
225 + if dash:
226 + mk = main_kpi(dash)
227 + nk = new_kpi(dash)
228 + rows.append(
229 + [
230 + f"{i}. {s.get('wordmark', s.get('id', ''))}",
231 + str(mk.get("label", ""))[:34] if mk else "—",
232 + mk["value"] if mk else "—",
233 + nk["value"] if nk else "—",
234 + ]
235 + )
236 + else:
237 + rows.append(
238 + [
239 + f"{i}. {s.get('wordmark', s.get('id', ''))}",
240 + "données indisponibles",
241 + "—",
242 + "—",
243 + ]
244 + )
245 + self._table(
246 + {
247 + "id": "classement",
248 + "title": "Classement des plateformes (volume principal)",
249 + "columns": [
250 + "Plateforme",
251 + "Indicateur principal",
252 + "Volume",
253 + "Nouveautés (période)",
254 + ],
255 + "rows": rows,
256 + }
257 + )
258 + p.set_font("helvetica", "", 7.6)
259 + p.set_text_color(*INK3)
260 + note = (
261 + "Le « volume principal » est le premier indicateur du tableau de bord de "
262 + "chaque plateforme (annonces actives, véhicules, produits, pages indexées…). "
263 + "Les volumes ne sont pas de même nature : la somme donne un ordre de "
264 + "grandeur de l'écosystème, pas une métrique homogène. Données lues en "
265 + "direct sur chaque plateforme (cache 10 minutes)."
266 + )
267 + if missing:
268 + names = ", ".join(pl["site"].get("wordmark", "") for pl in missing)
269 + note += f" Plateformes injoignables au moment de la génération : {names}."
270 + p.multi_cell(174, 4.2, note)
271 + p.ln(4)
272 +
273 + # ------------------------------------------------- section par plateforme
274 + def _platform_header(self, site: dict, accent: tuple):
275 + """Titre de section : pastille accent + wordmark (boîte encre) + domaine."""
276 + p = self.pdf
277 + y = p.get_y() + 2
278 + p.set_fill_color(*accent)
279 + p.set_draw_color(*INK)
280 + p.set_line_width(0.45)
281 + p.rect(p.l_margin, y + 2, 6, 6, style="DF", round_corners=True, corner_radius=1.5)
282 + wm = site.get("wordmark", site.get("id", ""))
283 + left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)
284 + p.set_xy(p.l_margin + 9, y - 1)
285 + p.set_font("helvetica", "B", 19)
286 + p.set_text_color(*INK)
287 + p.cell(p.get_string_width(left) + 1.5, 11, left)
288 + if boxed:
289 + bw = p.get_string_width(boxed) + 7
290 + bx = p.get_x() + 1
291 + p.set_fill_color(*INK)
292 + p.rect(bx, y - 0.6, bw, 10.4, style="F", round_corners=True, corner_radius=2)
293 + p.set_text_color(*accent)
294 + p.set_xy(bx + 3.5, y - 1)
295 + p.cell(bw - 7, 11, boxed)
296 + p.set_font("helvetica", "B", 9)
297 + p.set_text_color(*INK3)
298 + p.set_xy(p.l_margin, y + 2)
299 + p.cell(174, 6, "https://" + site.get("domain", ""), align="R")
300 + p.set_y(y + 12)
301 + if site.get("tagline"):
302 + p.set_x(p.l_margin)
303 + p.set_font("helvetica", "", 9)
304 + p.set_text_color(*INK2)
305 + p.cell(0, 5, str(site["tagline"]))
306 + p.ln(7)
307 +
308 + def _platform_section(self, pl: dict):
309 + p = self.pdf
310 + site = pl["site"]
311 + dash = pl.get("dashboard")
312 + accent = _hex(site.get("accent", "#d9f26b"))
313 + p.add_page()
314 + self._platform_header(site, accent)
315 + if not dash:
316 + y = p.get_y() + 2
317 + self._card(p.l_margin, y, 174, 26, fill=SURFACE2)
318 + p.set_xy(p.l_margin + 6, y + 5)
319 + p.set_font("helvetica", "B", 11)
320 + p.set_text_color(*INK2)
321 + p.cell(0, 6, "Données indisponibles")
322 + p.set_xy(p.l_margin + 6, y + 12)
323 + p.set_font("helvetica", "", 8.6)
324 + p.set_text_color(*INK3)
325 + p.multi_cell(
326 + 162,
327 + 4.4,
328 + f"La plateforme n'a pas répondu au moment de la génération "
329 + f"({pl.get('error') or 'erreur inconnue'}). Ses statistiques restent "
330 + f"consultables sur https://{site.get('domain', '')}/stats.",
331 + )
332 + p.set_y(y + 30)
333 + return
334 + # fraîcheur de la plateforme
335 + upd = dash.get("updated")
336 + if upd:
337 + p.set_x(p.l_margin)
338 + p.set_font("helvetica", "", 7.6)
339 + p.set_text_color(*INK3)
340 + p.cell(0, 4, f"Mis à jour : {str(upd)[:19].replace('T', ' à ')}")
341 + p.ln(6)
342 + # réutilise le moteur commun avec l'identité de la plateforme
343 + saved = (self.d, self.site, self.accent)
344 + self.d, self.site, self.accent = dash, site, accent
345 + try:
346 + self._kpis("Indicateurs — " + site.get("wordmark", ""))
347 + self._records()
348 + finally:
349 + self.d, self.site, self.accent = saved
350 +
351 + # ------------------------------------------------------------------ build
352 + def build(self) -> bytes:
353 + p = self.pdf
354 + p.alias_nb_pages()
355 + self._cover()
356 + p.add_page()
357 + self._consolidation()
358 + if self.mode == "complet":
359 + for pl in self.ranked:
360 + self._platform_section(pl)
361 + self._final_page()
362 + return bytes(p.output())
modified src/api/kapdf.py +2 −2
@@ -232,11 +232,11 @@ class GroupeKAReport:
232 232 p.set_auto_page_break(True, margin=22)
233 233 p.cover_mode = False
234 234
235 − def _kpis(self):
235 + def _kpis(self, title: str = "Synthèse des indicateurs"):
236 236 kpis = self.d.get("kpis") or []
237 237 if not kpis:
238 238 return
239 − self._section_title("Synthèse des indicateurs")
239 + self._section_title(title)
240 240 p = self.pdf
241 241 cols, gw, gh, gap = 3, 56, 26, 3
242 242 x0, y = p.l_margin, p.get_y()
modified src/api/reqstats.py +1 −0
@@ -51,6 +51,7 @@ _KNOWN_PATHS = frozenset(
51 51 "/api/v1/runs",
52 52 "/api/stats/dashboard",
53 53 "/api/stats/report",
54 + "/api/stats/ecosystem-report",
54 55 }
55 56 )
56 57 _SERVICE_SUBROUTES = frozenset({"latest", "stats"})
modified src/api/routes/stats.py +138 −1
@@ -13,6 +13,10 @@ Contrat commun Groupe KA (src/api/web/ka/stats/SPEC.md) :
13 13 heatmap, tableaux, records) calculé depuis les données réelles :
14 14 table ``api_requests`` (journal du middleware) et ``collection_runs``.
15 15 - ``GET /api/stats/report?period=…&mode=complet|synthese`` → PDF Groupe-KA.
16 +- ``GET /api/stats/ecosystem-report?period=…&mode=…`` → UN SEUL PDF
17 + consolidant les 12 plateformes de l'écosystème : les dashboards des
18 + plateformes sœurs sont lus en parallèle (httpx, 15 s), le sien en local ;
19 + une plateforme injoignable devient une section « données indisponibles ».
16 20
17 21 Cache serveur : 5 minutes par période. Aucune stat inventée : une section
18 22 sans données est simplement absente (le front affiche « Pas encore mesuré »).
@@ -20,17 +24,21 @@ sans données est simplement absente (le front affiche « Pas encore mesuré »)
20 24
21 25 from __future__ import annotations
22 26
27 +import asyncio
23 28 import datetime
29 +import json
24 30 import threading
25 31 import time
32 +from pathlib import Path
26 33 from typing import Any
27 34 from zoneinfo import ZoneInfo
28 35
36 +import httpx
29 37 from fastapi import APIRouter, Depends, HTTPException, Query, Response
30 38 from sqlalchemy import func, select
31 39 from sqlalchemy.orm import Session
32 40
33 −from src.api import kapdf
41 +from src.api import ecopdf, kapdf
34 42 from src.api.routes import envelope
35 43 from src.database.db import get_db
36 44 from src.database.models import ApiRequest, CollectionRun
@@ -74,6 +82,20 @@ PERIOD_DAYS = {"7j": 7, "30j": 30, "3m": 91, "6m": 182, "12m": 365}
74 82 _cache: dict[tuple, tuple[float, dict]] = {}
75 83 _cache_lock = threading.Lock()
76 84
85 +# ------------------------------------------------------ rapport écosystème
86 +ECO_CACHE_TTL = 600.0 # 10 min par (period, mode)
87 +ECO_FETCH_TIMEOUT = 15.0
88 +_eco_cache: dict[tuple[str, str], tuple[float, bytes, str]] = {}
89 +_eco_lock = threading.Lock()
90 +
91 +_ECOSYSTEM_JSON = Path(__file__).resolve().parents[1] / "web" / "ka" / "ecosystem.json"
92 +# Les 11 plateformes de données (le hub groupe-ka n'expose pas de dashboard).
93 +ECO_SITES: list[dict] = [
94 + s
95 + for s in json.loads(_ECOSYSTEM_JSON.read_text(encoding="utf-8"))["sites"]
96 + if s["id"] != "groupe-ka"
97 +]
98 +
77 99
78 100 # ---------------------------------------------------------------- périodes
79 101 def _resolve_period(
@@ -578,3 +600,118 @@ def stats_report(
578 600 media_type="application/pdf",
579 601 headers={"Content-Disposition": f'attachment; filename="{fname}"'},
580 602 )
603 +
604 +
605 +# ------------------------------------------------- rapport écosystème (PDF)
606 +async def _fetch_satellite_dashboard(
607 + client: httpx.AsyncClient, site: dict, period: str
608 +) -> dict[str, Any]:
609 + """Dashboard d'une plateforme sœur — jamais d'exception : une plateforme
610 + injoignable est retournée avec ``dashboard=None`` et un motif lisible."""
611 + url = f"https://{site['domain']}/api/stats/dashboard"
612 + try:
613 + resp = await client.get(url, params={"period": period})
614 + resp.raise_for_status()
615 + payload = resp.json()
616 + # certaines plateformes enveloppent la réponse ({success, data, meta})
617 + if (
618 + isinstance(payload, dict)
619 + and "kpis" not in payload
620 + and isinstance(payload.get("data"), dict)
621 + ):
622 + payload = payload["data"]
623 + if not isinstance(payload, dict) or not payload.get("kpis"):
624 + raise ValueError("réponse hors contrat (kpis manquants)")
625 + return {"site": site, "dashboard": payload, "error": None}
626 + except httpx.TimeoutException:
627 + return {"site": site, "dashboard": None, "error": "délai dépassé (15 s)"}
628 + except httpx.HTTPStatusError as exc:
629 + return {
630 + "site": site,
631 + "dashboard": None,
632 + "error": f"HTTP {exc.response.status_code}",
633 + }
634 + except Exception as exc: # noqa: BLE001 — motif affiché dans le PDF
635 + return {
636 + "site": site,
637 + "dashboard": None,
638 + "error": (str(exc) or exc.__class__.__name__)[:80],
639 + }
640 +
641 +
642 +@router.get("/ecosystem-report")
643 +async def stats_ecosystem_report(
644 + period: str = Query("30j", description="auj, 7j, 30j, 3m, 6m, 12m, annee, tout"),
645 + mode: str = Query("complet", description="complet ou synthese"),
646 + db: Session = Depends(get_db),
647 +) -> Response:
648 + """Rapport écosystème Groupe-KA : UN SEUL PDF consolidant les 12 plateformes.
649 +
650 + Les dashboards des plateformes sœurs sont récupérés en parallèle
651 + (httpx, délai 15 s), celui d'API-KA est calculé en local. Cache 10 min
652 + par (période, mode)."""
653 + if mode not in ("complet", "synthese"):
654 + raise HTTPException(status_code=422, detail="mode : complet ou synthese")
655 + if period not in PERIOD_LABELS:
656 + raise HTTPException(
657 + status_code=422,
658 + detail=f"Période invalide : {period}. Valides : {', '.join(PERIOD_LABELS)}",
659 + )
660 +
661 + def _pdf_response(pdf_bytes: bytes, fname: str) -> Response:
662 + return Response(
663 + content=pdf_bytes,
664 + media_type="application/pdf",
665 + headers={"Content-Disposition": f'attachment; filename="{fname}"'},
666 + )
667 +
668 + key = (period, mode)
669 + now = time.monotonic()
670 + with _eco_lock:
671 + hit = _eco_cache.get(key)
672 + if hit and now - hit[0] < ECO_CACHE_TTL:
673 + return _pdf_response(hit[1], hit[2])
674 +
675 + satellites = [s for s in ECO_SITES if s["id"] != PLATFORM_ID]
676 + own_site = next(s for s in ECO_SITES if s["id"] == PLATFORM_ID)
677 + async with httpx.AsyncClient(
678 + timeout=ECO_FETCH_TIMEOUT,
679 + follow_redirects=True,
680 + headers={
681 + "accept": "application/json",
682 + "user-agent": "apika-ecosystem-report/1.0 (+https://www.api-ka.com)",
683 + },
684 + ) as client:
685 + results = await asyncio.gather(
686 + *(_fetch_satellite_dashboard(client, s, period) for s in satellites)
687 + )
688 +
689 + def _build() -> bytes:
690 + try:
691 + own = {
692 + "site": own_site,
693 + "dashboard": _dashboard_cached(db, period, None, None),
694 + "error": None,
695 + }
696 + except Exception as exc: # noqa: BLE001
697 + own = {"site": own_site, "dashboard": None, "error": str(exc)[:80]}
698 + platforms = [*results, own]
699 + eco_period = next(
700 + (
701 + pl["dashboard"]["period"]
702 + for pl in platforms
703 + if pl["dashboard"] and pl["dashboard"].get("period")
704 + ),
705 + {"label": PERIOD_LABELS[period]},
706 + )
707 + return ecopdf.EcosystemReport(
708 + platforms=platforms, period=eco_period, mode=mode
709 + ).build()
710 +
711 + pdf_bytes = await asyncio.to_thread(_build)
712 + fname = kapdf.filename("ecosysteme", period)
713 + with _eco_lock:
714 + if len(_eco_cache) > 32:
715 + _eco_cache.clear()
716 + _eco_cache[key] = (now, pdf_bytes, fname)
717 + return _pdf_response(pdf_bytes, fname)
modified tests/test_stats.py +46 −0
@@ -104,6 +104,52 @@ def test_stats_page_and_middleware_recording() -> None:
104 104 )
105 105
106 106
107 +def test_ecosystem_pdf_builder() -> None:
108 + """EcosystemReport : PDF consolidé hors-ligne, avec une plateforme
109 + disponible et une injoignable (section « données indisponibles »)."""
110 + from src.api import ecopdf
111 +
112 + platforms = [
113 + {
114 + "site": {
115 + "id": "lou-ka",
116 + "wordmark": "Lou·Ka",
117 + "accent": "#ff6a00",
118 + "domain": "www.lou-ka.com",
119 + "tagline": "Tous les logements à louer",
120 + },
121 + "dashboard": {
122 + "updated": "2026-08-17T12:00:00-04:00",
123 + "period": {"from": "2026-08-11", "to": "2026-08-17", "label": "7 jours"},
124 + "kpis": [
125 + {"id": "actives", "label": "Annonces actives", "value": 33907},
126 + {"id": "nouvelles", "label": "Nouvelles annonces (période)", "value": 29428},
127 + ],
128 + "records": [{"label": "Jour record", "value": "412 annonces", "date": "2026-08-09"}],
129 + },
130 + "error": None,
131 + },
132 + {
133 + "site": {
134 + "id": "immo-ka",
135 + "wordmark": "Immo·Ka",
136 + "accent": "#e23744",
137 + "domain": "www.immo-ka.com",
138 + "tagline": "Toutes les propriétés à vendre",
139 + },
140 + "dashboard": None,
141 + "error": "délai dépassé (15 s)",
142 + },
143 + ]
144 + for mode in ("complet", "synthese"):
145 + pdf = ecopdf.EcosystemReport(
146 + platforms=platforms, period={"label": "7 jours"}, mode=mode
147 + ).build()
148 + assert pdf.startswith(b"%PDF")
149 + assert ecopdf.main_kpi(platforms[0]["dashboard"])["id"] == "actives"
150 + assert ecopdf.new_kpi(platforms[0]["dashboard"])["id"] == "nouvelles"
151 +
152 +
107 153 def test_normalize_endpoint() -> None:
108 154 assert reqstats.normalize_endpoint("/api/v1/louka/date/2026-08-16") == (
109 155 "/api/v1/louka/date/{date}"
110 156