From 11cada2ffcd2795bcf253161d27e33acd9b72e3b Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 19:36:45 +0200 Subject: [PATCH 01/32] docs: spec design tools MCP (scope A de #111) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-09-mcp-tools-scope-a-design.md | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-09-mcp-tools-scope-a-design.md diff --git a/docs/superpowers/specs/2026-07-09-mcp-tools-scope-a-design.md b/docs/superpowers/specs/2026-07-09-mcp-tools-scope-a-design.md new file mode 100644 index 0000000..46a0421 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-mcp-tools-scope-a-design.md @@ -0,0 +1,228 @@ +# Design — Serveur MCP colibre, lot 1 : les tools (scope A de l'issue #111) + +**Date** : 2026-07-09 +**Issue** : #111 — Serveur MCP des données colibre, conditionné à l'abonnement +**Périmètre de CE design** : **A seulement** — exposer des fonctions métier comme +_tools_ MCP via le décorateur `@mcp_enabled` de Dash. **Sans authentification.** +La couche d'autorisation OAuth 2.0 + gate abonnement fera l'objet d'un design +séparé (scope B). + +## Contexte + +- La migration Dash 4.x (#101) est terminée (Dash 4.4). Le serveur MCP de Dash est + disponible à partir de Dash 4.3.0 → prérequis satisfait. +- Dash fournit la couche protocole MCP (`enable_mcp=True`, décorateur + `@mcp_enabled`, `configure_mcp_server(...)`). Dash **n'implémente pas** + l'authentification (cf. `/dash-mcp/auth`) → c'est le scope B, hors de ce design. +- Les **DECP sont des données publiques ouvertes**. Exposer recherche/stats en MCP + n'est donc pas une fuite de confidentialité ; le gate abonnement (scope B) relève + du contrôle d'accès / monétisation, pas du secret. Conséquence : le scope A peut + tourner en dev/local sans risque de données. + +## Ce qui existe déjà et qu'on réutilise + +- `src/db.py` : `query_marches(where_sql, params, columns, order_by, limit, offset)`, + `count_marches(where_sql, params)`, `aggregate_marches(select_sql, where_sql, params, group_by, order_by, limit, offset)` — accès DuckDB paramétré renvoyant du + Polars. +- `src/api/filters.py` : `build_where(args, schema) -> (where_sql, params, order_by)` + et `parse_aggregators(...)` — moteur de filtres `col__op=valeur` déjà utilisé par + l'API REST (`src/api/routes.py`). **On le réutilise tel quel** pour que MCP et REST + partagent la même sémantique de filtrage. +- `src/utils/search.py` : `search_org(dff, query, org_type)` — recherche floue par + nom sur les acheteurs/titulaires (déjà utilisée par la page `/`). +- `src/utils/tracking.py` : `track_search(query, category)` — envoi direct à l'API + HTTP de tracking Matomo (`matomo.php`). + +## Approches considérées + +- **A — Module MCP fin réutilisant la couche données existante (RETENU).** + Nouveau package `src/mcp/`, 4 fonctions `@mcp_enabled` appelant directement + `db.*` / `filters.build_where` / `search_org`. Le « service layer » partagé + qu'on voudrait existe déjà (`src/db.py` + `src/api/filters.py`) → peu de code neuf. +- **B — Extraire un service commun** partagé entre `api/routes.py` et MCP. Meilleure + déduplication à terme mais gros refactor de l'API REST pour un gain marginal (la + logique est déjà factorisée). Rejeté (YAGNI). +- **C — MCP appelle l'API REST en HTTP.** Ajoute un saut HTTP interne en process, + perd le typage. Rejeté. + +## Architecture + +### Arborescence + +``` +src/mcp/ + __init__.py + tools.py # les 4 fonctions @mcp_enabled (surface MCP) + serialization.py # Polars → JSON propre (dates ISO, montants, None-safe) + stats.py # helpers d'agrégation acheteur/titulaire, partagés par les 2 tools stats_* +``` + +### Activation (dans `src/app.py`, là où `Dash(...)` est construit) + +```python +from dash.mcp import configure_mcp_server + +app = Dash(__name__, ..., enable_mcp=os.getenv("DASH_MCP_ENABLED") == "true") + +configure_mcp_server( + include_layout=False, + include_callbacks=False, + include_pages=False, + include_clientside_callbacks=False, +) # n'expose QUE les fonctions @mcp_enabled — aucun callback/layout/page d'UI + +import src.mcp.tools # noqa: E402,F401 — l'import enregistre les @mcp_enabled +``` + +**Sécurité (point de vigilance #111)** : en coupant `include_callbacks/layout/pages/ clientside`, aucun callback d'UI ni nom interne (type `get_data_from_s3`) n'est +exposé. La surface se limite aux 4 tools nommés proprement, avec docstrings +maîtrisées. + +### Isolation des unités + +- `tools.py` : **uniquement** la surface MCP (signatures, docstrings destinées à + l'agent, validation des arguments, appels aux helpers). Ne contient pas de SQL. +- `stats.py` : logique d'agrégation acheteur/titulaire, testable sans MCP. +- `serialization.py` : conversion Polars → structures JSON-sérialisables, testable + isolément. + +## Les 4 tools + +Tous renvoient des structures JSON-sérialisables (dict / list). Montants en euros +(float ou int), dates en ISO 8601 (`YYYY-MM-DD`), valeurs manquantes en `null`. +Les docstrings sont exposées à l'agent (`expose_docstring=True`) : ce sont elles qui +documentent l'outil côté client. + +### 1. `rechercher_organisations(query: str, type: str = "acheteur", limite: int = 20)` + +- `type` ∈ `{"acheteur", "titulaire"}`. +- Réutilise `search_org` sur la frame correspondante (mêmes données que la page `/`). +- Sortie : `[{ "id", "nom", "departement", "commune" }]`, triée par pertinence, + tronquée à `limite`. +- Rôle : **résoudre un nom → id** pour alimenter `stats_acheteur` / `stats_titulaire`. + +### 2. `stats_acheteur(acheteur_id: str)` + +Réutilise `aggregate_marches` avec un `where` filtrant sur `acheteur_id`. +Sortie : + +```json +{ + "identite": { "id", "nom", "departement", "commune" }, + "nb_marches": 0, + "montant_total": 0, + "repartition_annuelle": [ { "annee", "nb_marches", "montant_total" } ], + "top_titulaires": [ { "id", "nom", "nb_marches", "montant_total" } ], + "top_cpv": [ { "cpv", "libelle", "nb_marches" } ] +} +``` + +- `top_*` limités (ex. 10). Si `acheteur_id` inconnu → `nb_marches: 0` et listes vides + (pas d'erreur). + +### 3. `stats_titulaire(titulaire_id: str)` + +Symétrique de `stats_acheteur` : `top_acheteurs` au lieu de `top_titulaires`, +montants remportés. + +### 4. `rechercher_marches(...)` — signature **hybride** + +```python +rechercher_marches( + acheteur_id: str | None = None, + titulaire_id: str | None = None, + cpv: str | None = None, + objet_contient: str | None = None, + montant_min: float | None = None, + montant_max: float | None = None, + date_min: str | None = None, # ISO YYYY-MM-DD (dateNotification) + date_max: str | None = None, + departement: str | None = None, + page: int = 1, + filtres_avances: dict | None = None, # échappatoire moteur générique +) +``` + +- Les paramètres nommés sont traduits en tuples `col__op` (ex. + `montant_min` → `("montant__greater", ...)`, `objet_contient` → + `("objet__contains", ...)`, `date_min` → `("dateNotification__greater", ...)`). +- `departement` mappe sur **`acheteur_departement_code`** (intention de requête la + plus courante). Pour filtrer sur le département du titulaire ou du lieu + d'exécution, l'agent passe par `filtres_avances`. +- `filtres_avances` : dict `{"col__op": valeur}` passant au moteur générique complet, + **fusionné** avec les paramètres nommés. Couvre toute colonne/opérateur supportés + par l'API REST. +- L'ensemble passe à `filters.build_where(args, duckdb_schema)` puis + `db.query_marches` / `db.count_marches` → **même sémantique que l'API REST**. +- Pagination : `page_size` **fixe** (ex. 50), pagination par `page` (offset calculé). +- Sortie : + +```json +{ + "meta": { "page": 1, "page_size": 50, "total": 0 }, + "marches": [ + { + /* colonnes principales du marché */ + } + ] +} +``` + +- Erreurs de filtre (`FilterError`) → message d'erreur clair renvoyé à l'agent (pas + d'exception brute). + +## Tracking Matomo des appels MCP + +Nouveau helper dédié dans `src/utils/tracking.py` (on **ne** surcharge **pas** +`track_search`, qui gate sur `len(query) >= 4` et attend une requête texte) : + +```python +def track_mcp_tool(tool_name: str, query: str | None = None) -> None: + ... +``` + +- Même pattern que `track_search` : n'émet que si `not DEVELOPMENT` **et** + `MATOMO_DOMAIN` défini. Best-effort (ne doit jamais faire échouer l'appel du tool). +- Paramètres envoyés à `matomo.php` : + - `action_name = "MCP"` (hiérarchie `f"MCP / {tool_name}"` acceptable pour un arbre + lisible dans le rapport Actions), + - `dimension1 = tool_name`, + - `search` / `search_cat` en plus quand l'outil a une requête texte + (`rechercher_organisations`, `rechercher_marches`). +- Chaque tool appelle `track_mcp_tool(...)` en début d'exécution. +- **Prérequis de déploiement Matomo** : créer un _Custom Dimension_ slot 1, scope + **Action**, côté admin Matomo. Sinon `dimension1` est ignoré silencieusement. + +## Déploiement / gating + +- Activation via variable d'environnement `DASH_MCP_ENABLED` (Dash lit nativement + cette variable ; on la reflète dans le constructeur). +- **Off par défaut.** Activé en dev/local uniquement. +- **Pas activé en prod tant que le scope B (OAuth + gate abonnement) n'est pas + livré** — sinon le serveur MCP serait ouvert sans contrôle d'accès (feature + payante + coût compute). +- Documenter la variable dans `.template.env`. + +## Tests + +- Tests unitaires sur les 4 fonctions (données `tests/test.parquet`) : + - `rechercher_organisations` : résultats non vides, tri, `limite`, `type` invalide. + - `stats_acheteur` / `stats_titulaire` : forme de sortie, id inconnu → vides, + troncature des `top_*`. + - `rechercher_marches` : fusion params nommés ↔ `filtres_avances`, pagination + (`meta.total`, `page`), `FilterError` → message propre. + - `serialization` : dates ISO, `null`, montants. +- Smoke test : après import de `src.mcp.tools`, le registre MCP contient bien les + 4 tools attendus (pas de test du protocole MCP de bout en bout, qui nécessiterait + un client MCP). +- `tests/test.parquet` étant réduit, vérifier que les colonnes utilisées (cpv, + montant, dateNotification, acheteur_id, titulaire_id, departement) y sont + présentes ; sinon compléter la fixture ou marquer les cas concernés. + +## Hors périmètre (→ scope B, design séparé) + +- Serveur d'autorisation OAuth 2.0 conforme à la spec MCP (2025-06-18) : + metadata protected-resource, PKCE, dynamic client registration. +- Branchement du gate `subscriptions.has_active_subscription(user_id)` sur + l'autorisation MCP. +- Documentation de connexion côté client (`claude mcp add …`). From 6bc7540941233cdfde9d6aa5309b266031c838cb Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 19:47:07 +0200 Subject: [PATCH 02/32] =?UTF-8?q?docs:=20plan=20d'impl=C3=A9mentation=20to?= =?UTF-8?q?ols=20MCP=20(scope=20A=20de=20#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-09-mcp-tools-scope-a.md | 1033 +++++++++++++++++ 1 file changed, 1033 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-mcp-tools-scope-a.md diff --git a/docs/superpowers/plans/2026-07-09-mcp-tools-scope-a.md b/docs/superpowers/plans/2026-07-09-mcp-tools-scope-a.md new file mode 100644 index 0000000..b2b7554 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-mcp-tools-scope-a.md @@ -0,0 +1,1033 @@ +# Tools MCP colibre (scope A de #111) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Exposer 4 fonctions métier des DECP comme _tools_ MCP via le décorateur `@mcp_enabled` de Dash, sans authentification (le gate abonnement = scope B, séparé). + +**Architecture:** Un package `src/mcp/` fin. `tools.py` contient les 4 fonctions décorées (surface MCP + tracking, aucun SQL) ; `queries.py` porte la logique données en réutilisant la couche existante (`src/db.py`, `src/api/filters.py`, `src/utils/search.py`) ; `serialization.py` convertit le Polars en JSON propre. Le serveur MCP est activé dans `src/app.py` via `enable_mcp` + `configure_mcp_server` qui coupe l'exposition des callbacks/layout/pages : seules les 4 fonctions sont exposées. + +**Tech Stack:** Python 3, Dash 4.4 (`dash.mcp`), Polars, DuckDB, httpx (tracking Matomo), pytest. + +## Global Constraints + +- Imports internes toujours préfixés `src.` (ex. `from src.db import ...`), jamais `db` ou `utils`. +- Périmètre = **scope A uniquement**. Aucune authentification, aucun gate abonnement dans ce plan. +- Sorties des tools = structures **JSON-sérialisables** : dates en ISO 8601 (`YYYY-MM-DD`), valeurs manquantes en `None`, montants numériques. +- Le serveur MCP n'expose **que** les fonctions `@mcp_enabled` (couper `include_callbacks/layout/pages/clientside`). +- Activation par variable d'env `DASH_MCP_ENABLED` (`"true"` pour activer), **off par défaut**. Ne pas activer en prod tant que le scope B n'est pas livré. +- Colonnes réelles de la table `decp` : `uid`, `objet`, `montant`, `dateNotification`, `codeCPV`, `acheteur_id`, `acheteur_nom`, `acheteur_departement_code`, `acheteur_departement_nom`, `acheteur_commune_nom`, `titulaire_id`, `titulaire_nom`, `titulaire_departement_nom`, `titulaire_commune_nom`. **Pas de colonne `annee`** → dériver l'année via `date_part('year', "dateNotification")`. **Pas de libellé CPV** dans les données. +- Avant chaque commit, le hook `pre-commit` (ruff + prettier) s'exécute et peut reformater : si des fichiers sont modifiés par le hook, refaire `git add` puis `git commit`. +- Lancer les tests avec `uv run pytest`. Chaque tâche ne lance QUE son fichier de test ; la suite complète (`uv run pytest` sans chemin) uniquement à la dernière tâche. + +## File Structure + +- Create: `src/mcp/__init__.py` — package vide. +- Create: `src/mcp/serialization.py` — `to_json_records(df) -> list[dict]`. +- Create: `src/mcp/queries.py` — `search_organisations`, `search_marches`, `build_where_args`, `compute_org_stats`, `_org_identite`. +- Create: `src/mcp/tools.py` — les 4 fonctions `@mcp_enabled`. +- Modify: `src/utils/tracking.py` — ajouter `track_mcp_tool`. +- Modify: `src/utils/search.py` — ajouter le paramètre `track: bool = True` à `search_org`. +- Modify: `src/app.py` — activer le serveur MCP (constructeur + `configure_mcp_server` + import des tools). +- Modify: `.template.env` — documenter `DASH_MCP_ENABLED`. +- Create: `tests/mcp/__init__.py`, `tests/mcp/test_serialization.py`, `tests/mcp/test_tracking.py`, `tests/mcp/test_queries.py`, `tests/mcp/test_tools.py`. + +Le harness de test racine (`tests/conftest.py`) écrit `tests/test.parquet` (1 ligne : `acheteur_id="123"`, `acheteur_nom="ACHETEUR 1"`, `titulaire_id="345"`, `titulaire_nom="TITULAIRE 1"`, `montant=10`, `codeCPV="71600000"`, `dateNotification=2025-01-01`, `acheteur_departement_code="75"`, `objet="Objet test"`) et construit une base DuckDB de test isolée à l'import du conftest. Les tests sous `tests/mcp/` en héritent automatiquement. + +--- + +### Task 1: `serialization.py` — conversion Polars → JSON + +**Files:** + +- Create: `src/mcp/__init__.py` +- Create: `src/mcp/serialization.py` +- Test: `tests/mcp/__init__.py`, `tests/mcp/test_serialization.py` + +**Interfaces:** + +- Produces: `to_json_records(df: pl.DataFrame) -> list[dict[str, Any]]` — convertit chaque ligne en dict JSON-sérialisable ; `datetime.date`/`datetime.datetime` → chaîne ISO, `None` préservé. + +- [ ] **Step 1: Créer le package et le fichier de test** + +Créer `src/mcp/__init__.py` (vide) et `tests/mcp/__init__.py` (vide), puis écrire le test : + +```python +# tests/mcp/test_serialization.py +import datetime + +import polars as pl + +from src.mcp.serialization import to_json_records + + +def test_dates_become_iso_strings(): + df = pl.DataFrame({"d": [datetime.date(2025, 1, 1)], "n": [10]}) + assert to_json_records(df) == [{"d": "2025-01-01", "n": 10}] + + +def test_none_is_preserved(): + df = pl.DataFrame({"nom": [None], "x": [3]}) + assert to_json_records(df) == [{"nom": None, "x": 3}] + + +def test_strings_and_numbers_untouched(): + df = pl.DataFrame({"s": ["abc"], "f": [1.5]}) + assert to_json_records(df) == [{"s": "abc", "f": 1.5}] +``` + +- [ ] **Step 2: Lancer le test → échec** + +Run: `uv run pytest tests/mcp/test_serialization.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'src.mcp.serialization'` + +- [ ] **Step 3: Implémenter `serialization.py`** + +```python +# src/mcp/serialization.py +import datetime +from typing import Any + +import polars as pl + + +def to_json_records(df: pl.DataFrame) -> list[dict[str, Any]]: + """Convertit un DataFrame Polars en liste de dicts JSON-sérialisables. + + Les dates/datetimes deviennent des chaînes ISO 8601 ; les valeurs nulles + restent None. Utilisé par tous les tools MCP pour produire une sortie propre. + """ + return [ + {key: _jsonify(value) for key, value in row.items()} + for row in df.to_dicts() + ] + + +def _jsonify(value: Any) -> Any: + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value +``` + +- [ ] **Step 4: Lancer le test → succès** + +Run: `uv run pytest tests/mcp/test_serialization.py -v` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/__init__.py src/mcp/serialization.py tests/mcp/__init__.py tests/mcp/test_serialization.py +git commit -m "feat(mcp): sérialisation Polars -> JSON pour les tools MCP" +``` + +(Si le hook reformate, refaire `git add` puis `git commit`.) + +--- + +### Task 2: `track_mcp_tool` — tracking Matomo des appels MCP + +**Files:** + +- Modify: `src/utils/tracking.py` +- Test: `tests/mcp/test_tracking.py` + +**Interfaces:** + +- Consumes: le module `src.utils.tracking` (constante `DEVELOPMENT`, `post` de httpx). +- Produces: `track_mcp_tool(tool_name: str, query: str | None = None) -> None` — best-effort, n'émet qu'en prod (`not DEVELOPMENT` et `MATOMO_DOMAIN` défini) ; envoie `action_name="MCP / "`, `dimension1=`, et `search=` si fourni. + +- [ ] **Step 1: Écrire le test** + +```python +# tests/mcp/test_tracking.py +import src.utils.tracking as tracking + + +def test_track_mcp_tool_sends_action_and_dimension(monkeypatch): + captured = {} + + def fake_post(url, params): + captured["url"] = url + captured["params"] = params + + class _R: + def raise_for_status(self): + pass + + return _R() + + monkeypatch.setattr(tracking, "DEVELOPMENT", False) + monkeypatch.setattr(tracking, "post", fake_post) + monkeypatch.setenv("MATOMO_DOMAIN", "matomo.example") + monkeypatch.setenv("MATOMO_ID_SITE", "1") + + tracking.track_mcp_tool("rechercher_marches", query="informatique") + + assert captured["params"]["action_name"] == "MCP / rechercher_marches" + assert captured["params"]["dimension1"] == "rechercher_marches" + assert captured["params"]["search"] == "informatique" + + +def test_track_mcp_tool_noop_in_development(monkeypatch): + called = False + + def fake_post(url, params): + nonlocal called + called = True + + monkeypatch.setattr(tracking, "DEVELOPMENT", True) + monkeypatch.setattr(tracking, "post", fake_post) + monkeypatch.setenv("MATOMO_DOMAIN", "matomo.example") + + tracking.track_mcp_tool("stats_acheteur") + + assert called is False +``` + +- [ ] **Step 2: Lancer le test → échec** + +Run: `uv run pytest tests/mcp/test_tracking.py -v` +Expected: FAIL — `AttributeError: module 'src.utils.tracking' has no attribute 'track_mcp_tool'` + +- [ ] **Step 3: Ajouter `track_mcp_tool` à `src/utils/tracking.py`** + +Ajouter à la fin du fichier (les imports `os`, `uuid`, `localtime`, `post`, `DEVELOPMENT` sont déjà présents en tête) : + +```python +def track_mcp_tool(tool_name: str, query: str | None = None) -> None: + """Enregistre un appel d'outil MCP dans Matomo (best-effort, prod uniquement). + + `action_name="MCP / "`, `dimension1=`. Si l'outil porte une + requête texte, elle est envoyée en `search`. Nécessite un Custom Dimension + slot 1 (scope Action) configuré côté Matomo — sinon `dimension1` est ignoré. + Ne lève jamais : une panne Matomo ne doit pas casser l'appel du tool. + """ + if DEVELOPMENT or not os.getenv("MATOMO_DOMAIN"): + return + params = { + "idsite": os.getenv("MATOMO_ID_SITE"), + "url": "https://colibre.fr/_mcp", + "rec": "1", + "action_name": f"MCP / {tool_name}", + "dimension1": tool_name, + "rand": uuid.uuid4().hex, + "apiv": "1", + "h": localtime().tm_hour, + "m": localtime().tm_min, + "s": localtime().tm_sec, + "token_auth": os.getenv("MATOMO_TOKEN"), + } + if query: + params["search"] = query + params["search_cat"] = "mcp" + try: + post(url=f"https://{os.getenv('MATOMO_DOMAIN')}/matomo.php", params=params) + except Exception: + pass +``` + +- [ ] **Step 4: Lancer le test → succès** + +Run: `uv run pytest tests/mcp/test_tracking.py -v` +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/tracking.py tests/mcp/test_tracking.py +git commit -m "feat(mcp): helper track_mcp_tool pour tracer les appels MCP dans Matomo" +``` + +--- + +### Task 3: `search_org` — paramètre `track` optionnel + +**Files:** + +- Modify: `src/utils/search.py` +- Test: `tests/mcp/test_queries.py` (créé ici, complété aux tâches suivantes) + +**Interfaces:** + +- Consumes: `search_org(dff, query, org_type)` existant. +- Produces: `search_org(dff, query, org_type, track: bool = True)` — quand `track=False`, ne déclenche pas `track_search(...)`. Comportement inchangé par défaut. + +- [ ] **Step 1: Écrire le test** + +```python +# tests/mcp/test_queries.py +import src.utils.search as search_mod +from src.utils.data import DF_ACHETEURS +from src.utils.search import search_org + + +def test_search_org_track_false_skips_track_search(monkeypatch): + calls = [] + monkeypatch.setattr( + search_mod, "track_search", lambda q, c: calls.append((q, c)) + ) + + search_org(DF_ACHETEURS, "ACHETEUR", "acheteur", track=False) + + assert calls == [] + + +def test_search_org_track_true_calls_track_search(monkeypatch): + calls = [] + monkeypatch.setattr( + search_mod, "track_search", lambda q, c: calls.append((q, c)) + ) + + search_org(DF_ACHETEURS, "ACHETEUR", "acheteur", track=True) + + assert calls == [("ACHETEUR", "home_page_search")] +``` + +- [ ] **Step 2: Lancer le test → échec** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: FAIL — `TypeError: search_org() got an unexpected keyword argument 'track'` + +- [ ] **Step 3: Ajouter le paramètre `track`** + +Dans `src/utils/search.py`, modifier la signature et le corps : + +```python +def search_org( + dff: pl.DataFrame, query: str, org_type: str, track: bool = True +) -> pl.DataFrame: +``` + +et remplacer : + +```python + # Enregistrement des recherche dans Matomo + track_search(query, "home_page_search") +``` + +par : + +```python + # Enregistrement des recherche dans Matomo + if track: + track_search(query, "home_page_search") +``` + +- [ ] **Step 4: Lancer le test → succès** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/search.py tests/mcp/test_queries.py +git commit -m "feat(mcp): search_org accepte track=False pour ne pas polluer Matomo" +``` + +--- + +### Task 4: `queries.py` — `search_organisations` + +**Files:** + +- Create: `src/mcp/queries.py` +- Test: `tests/mcp/test_queries.py` (ajout) + +**Interfaces:** + +- Consumes: `search_org(dff, query, org_type, track=False)` (Task 3), `DF_ACHETEURS`/`DF_TITULAIRES` de `src.utils.data`, `to_json_records` (Task 1). +- Produces: `search_organisations(query: str, org_type: str = "acheteur", limite: int = 20) -> list[dict]` — retourne `[{"id", "nom", "departement"}]`. Lève `ValueError` si `org_type` invalide. Aussi : constantes `PAGE_SIZE = 50`, `TOP_N = 10`, `ORG_FRAMES`. + +- [ ] **Step 1: Écrire le test (ajouter à `tests/mcp/test_queries.py`)** + +```python +import pytest + +from src.mcp.queries import search_organisations + + +def test_search_organisations_finds_known_acheteur(): + result = search_organisations("ACHETEUR", "acheteur") + assert any(r["id"] == "123" for r in result) + first = next(r for r in result if r["id"] == "123") + assert set(first.keys()) == {"id", "nom", "departement"} + + +def test_search_organisations_invalid_type_raises(): + with pytest.raises(ValueError): + search_organisations("x", "autre") + + +def test_search_organisations_respects_limite(): + result = search_organisations("ACHETEUR", "acheteur", limite=1) + assert len(result) <= 1 +``` + +- [ ] **Step 2: Lancer le test → échec** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'src.mcp.queries'` + +- [ ] **Step 3: Créer `src/mcp/queries.py` avec `search_organisations`** + +```python +# src/mcp/queries.py +from src.mcp.serialization import to_json_records # noqa: F401 (utilisé aux tâches suivantes) +from src.utils.data import DF_ACHETEURS, DF_TITULAIRES +from src.utils.search import search_org + +PAGE_SIZE = 50 +TOP_N = 10 +ORG_FRAMES = {"acheteur": DF_ACHETEURS, "titulaire": DF_TITULAIRES} + + +def search_organisations( + query: str, org_type: str = "acheteur", limite: int = 20 +) -> list[dict]: + """Recherche des organisations par nom, résout un nom -> id.""" + if org_type not in ORG_FRAMES: + raise ValueError( + f"type invalide: {org_type!r} (attendu 'acheteur' ou 'titulaire')" + ) + df = search_org(ORG_FRAMES[org_type], query, org_type, track=False).head(limite) + return [ + { + "id": r[f"{org_type}_id"], + "nom": r[f"{org_type}_nom"], + "departement": r.get("Département"), + } + for r in df.to_dicts() + ] +``` + +- [ ] **Step 4: Lancer le test → succès** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: PASS (5 tests au total dans ce fichier) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/queries.py tests/mcp/test_queries.py +git commit -m "feat(mcp): tool search_organisations (résolution nom -> id)" +``` + +--- + +### Task 5: `queries.py` — `build_where_args` + `search_marches` + +**Files:** + +- Modify: `src/mcp/queries.py` +- Test: `tests/mcp/test_queries.py` (ajout) + +**Interfaces:** + +- Consumes: `build_where(args, schema)` et `FilterError` de `src.api.filters` ; `query_marches`, `count_marches`, `schema` de `src.db` ; `to_json_records` (Task 1) ; `PAGE_SIZE` (Task 4). +- Produces: + + - `build_where_args(named: dict, filtres_avances: dict | None) -> list[tuple[str, str]]` — traduit paramètres nommés + filtres avancés en tuples `("col__op", "valeur")`. + - `search_marches(*, acheteur_id=None, titulaire_id=None, cpv=None, objet_contient=None, montant_min=None, montant_max=None, date_min=None, date_max=None, departement=None, page=1, filtres_avances=None) -> dict` — retourne `{"meta": {"page", "page_size", "total"}, "marches": [...]}` ou `{"error": ..., "champ": ...}` sur `FilterError`. + +- [ ] **Step 1: Écrire le test (ajouter à `tests/mcp/test_queries.py`)** + +```python +from src.mcp.queries import build_where_args, search_marches + + +def test_build_where_args_named_params(): + args = build_where_args( + {"acheteur_id": "123", "montant_min": 5, "objet_contient": "test"}, None + ) + assert ("acheteur_id__exact", "123") in args + assert ("montant__greater", "5") in args + assert ("objet__contains", "test") in args + + +def test_build_where_args_merges_filtres_avances(): + args = build_where_args( + {"acheteur_id": "123"}, {"titulaire_departement_code__exact": "35"} + ) + assert ("acheteur_id__exact", "123") in args + assert ("titulaire_departement_code__exact", "35") in args + + +def test_search_marches_returns_meta_and_rows(): + result = search_marches(acheteur_id="123") + assert result["meta"]["total"] >= 1 + assert result["meta"]["page"] == 1 + assert result["meta"]["page_size"] == 50 + assert any(m["acheteur_id"] == "123" for m in result["marches"]) + # dates sérialisées en ISO + assert result["marches"][0]["dateNotification"] == "2025-01-01" + + +def test_search_marches_no_match_is_empty(): + result = search_marches(acheteur_id="inconnu-xyz") + assert result["meta"]["total"] == 0 + assert result["marches"] == [] + + +def test_search_marches_bad_filter_returns_error(): + result = search_marches(filtres_avances={"colonne_bidon__exact": "x"}) + assert "error" in result +``` + +- [ ] **Step 2: Lancer le test → échec** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: FAIL — `ImportError: cannot import name 'build_where_args'` + +- [ ] **Step 3: Étendre `src/mcp/queries.py`** + +Remplacer la ligne d'import `to_json_records` (marquée `noqa`) par les imports réels et ajouter le code. Nouvel en-tête d'imports en haut du fichier : + +```python +from src.api.filters import FilterError, build_where +from src.db import count_marches, query_marches +from src.db import schema as duckdb_schema +from src.mcp.serialization import to_json_records +from src.utils.data import DF_ACHETEURS, DF_TITULAIRES +from src.utils.search import search_org +``` + +Ajouter après les constantes : + +```python +# Colonnes renvoyées par rechercher_marches (sortie ciblée, pas SELECT *). +MARCHES_COLUMNS = [ + "uid", + "objet", + "montant", + "dateNotification", + "codeCPV", + "acheteur_id", + "acheteur_nom", + "acheteur_departement_code", + "titulaire_id", + "titulaire_nom", +] + +# (param nommé, colonne decp, opérateur du moteur de filtres API). +# `greater` = >=, `less` = <=, `contains` = LIKE %v%, `exact` = =. +_NAMED_FILTERS = [ + ("acheteur_id", "acheteur_id", "exact"), + ("titulaire_id", "titulaire_id", "exact"), + ("cpv", "codeCPV", "contains"), + ("objet_contient", "objet", "contains"), + ("montant_min", "montant", "greater"), + ("montant_max", "montant", "less"), + ("date_min", "dateNotification", "greater"), + ("date_max", "dateNotification", "less"), + ("departement", "acheteur_departement_code", "exact"), +] + + +def build_where_args( + named: dict, filtres_avances: dict | None +) -> list[tuple[str, str]]: + """Traduit les paramètres nommés + filtres avancés en tuples (col__op, valeur).""" + args: list[tuple[str, str]] = [] + for param, col, op in _NAMED_FILTERS: + value = named.get(param) + if value is not None: + args.append((f"{col}__{op}", str(value))) + if filtres_avances: + for key, value in filtres_avances.items(): + args.append((key, str(value))) + return args + + +def search_marches( + *, + acheteur_id: str | None = None, + titulaire_id: str | None = None, + cpv: str | None = None, + objet_contient: str | None = None, + montant_min: float | None = None, + montant_max: float | None = None, + date_min: str | None = None, + date_max: str | None = None, + departement: str | None = None, + page: int = 1, + filtres_avances: dict | None = None, +) -> dict: + """Recherche paginée de marchés. Même sémantique de filtres que l'API REST.""" + named = { + "acheteur_id": acheteur_id, + "titulaire_id": titulaire_id, + "cpv": cpv, + "objet_contient": objet_contient, + "montant_min": montant_min, + "montant_max": montant_max, + "date_min": date_min, + "date_max": date_max, + "departement": departement, + } + args = build_where_args(named, filtres_avances) + try: + where_sql, params, order_sql = build_where(args, duckdb_schema) + except FilterError as e: + return {"error": str(e), "champ": e.field} + + page = max(1, int(page)) + offset = (page - 1) * PAGE_SIZE + order_by = order_sql or '"dateNotification" DESC, "uid" DESC' + df = query_marches( + where_sql, + params, + columns=MARCHES_COLUMNS, + order_by=order_by, + limit=PAGE_SIZE, + offset=offset, + ) + total = count_marches(where_sql, params) + return { + "meta": {"page": page, "page_size": PAGE_SIZE, "total": total}, + "marches": to_json_records(df), + } +``` + +Supprimer l'ancienne ligne `from src.mcp.serialization import to_json_records # noqa: F401` et l'ancien bloc d'imports partiel de la Task 4 (remplacés par l'en-tête ci-dessus). + +- [ ] **Step 4: Lancer le test → succès** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: PASS (10 tests au total dans ce fichier) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/queries.py tests/mcp/test_queries.py +git commit -m "feat(mcp): tool rechercher_marches (filtres hybrides + pagination)" +``` + +--- + +### Task 6: `queries.py` — `compute_org_stats` + +**Files:** + +- Modify: `src/mcp/queries.py` +- Test: `tests/mcp/test_queries.py` (ajout) + +**Interfaces:** + +- Consumes: `aggregate_marches`, `query_marches` de `src.db` ; `to_json_records` (Task 1) ; `TOP_N` (Task 4). +- Produces: `compute_org_stats(org_type: str, org_id: str) -> dict` — retourne `{"identite", "nb_marches", "montant_total", "repartition_annuelle", "top_s", "top_cpv"}`. Contrepartie = `titulaire` si `org_type="acheteur"`, sinon `acheteur`. `nb_marches=0` et listes vides si id inconnu (pas d'exception). Aussi `_org_identite(org_type, org_id) -> dict`. + +- [ ] **Step 1: Écrire le test (ajouter à `tests/mcp/test_queries.py`)** + +```python +from src.mcp.queries import compute_org_stats + + +def test_compute_org_stats_acheteur_known(): + stats = compute_org_stats("acheteur", "123") + assert stats["nb_marches"] >= 1 + assert stats["montant_total"] == 10 + assert stats["identite"]["id"] == "123" + assert stats["identite"]["nom"] == "ACHETEUR 1" + assert "top_titulaires" in stats + assert "top_cpv" in stats + # répartition annuelle dérivée de dateNotification (2025) + annees = [row["annee"] for row in stats["repartition_annuelle"]] + assert 2025 in annees + + +def test_compute_org_stats_titulaire_known(): + stats = compute_org_stats("titulaire", "345") + assert stats["nb_marches"] >= 1 + assert "top_acheteurs" in stats + + +def test_compute_org_stats_unknown_is_empty(): + stats = compute_org_stats("acheteur", "inconnu-xyz") + assert stats["nb_marches"] == 0 + assert stats["montant_total"] == 0 + assert stats["repartition_annuelle"] == [] + assert stats["top_titulaires"] == [] + assert stats["top_cpv"] == [] +``` + +- [ ] **Step 2: Lancer le test → échec** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: FAIL — `ImportError: cannot import name 'compute_org_stats'` + +- [ ] **Step 3: Étendre `src/mcp/queries.py`** + +Ajouter `aggregate_marches` à l'import de `src.db` : + +```python +from src.db import aggregate_marches, count_marches, query_marches +``` + +Ajouter les fonctions : + +```python +def _org_identite(org_type: str, org_id: str) -> dict: + df = query_marches( + where_sql=f'"{org_type}_id" = ?', + params=[org_id], + columns=[ + f"{org_type}_id", + f"{org_type}_nom", + f"{org_type}_departement_nom", + f"{org_type}_commune_nom", + ], + limit=1, + ) + if df.height == 0: + return {"id": org_id, "nom": None, "departement": None, "commune": None} + r = df.row(0, named=True) + return { + "id": org_id, + "nom": r[f"{org_type}_nom"], + "departement": r[f"{org_type}_departement_nom"], + "commune": r[f"{org_type}_commune_nom"], + } + + +def compute_org_stats(org_type: str, org_id: str) -> dict: + """Statistiques agrégées d'un acheteur ou titulaire.""" + if org_type not in ORG_FRAMES: + raise ValueError( + f"type invalide: {org_type!r} (attendu 'acheteur' ou 'titulaire')" + ) + other = "titulaire" if org_type == "acheteur" else "acheteur" + where_sql = f'"{org_type}_id" = ?' + params = [org_id] + identite = _org_identite(org_type, org_id) + + totals = aggregate_marches( + select_sql='COUNT("uid") AS nb, COALESCE(SUM("montant"), 0) AS montant_total', + where_sql=where_sql, + params=params, + ) + nb = int(totals["nb"][0]) + if nb == 0: + return { + "identite": identite, + "nb_marches": 0, + "montant_total": 0, + "repartition_annuelle": [], + f"top_{other}s": [], + "top_cpv": [], + } + + annuelle = aggregate_marches( + select_sql=( + 'CAST(date_part(\'year\', "dateNotification") AS INTEGER) AS annee, ' + 'COUNT("uid") AS nb_marches, ' + 'COALESCE(SUM("montant"), 0) AS montant_total' + ), + where_sql=where_sql, + params=params, + group_by='date_part(\'year\', "dateNotification")', + order_by="annee", + ) + top_other = aggregate_marches( + select_sql=( + f'"{other}_id" AS id, any_value("{other}_nom") AS nom, ' + 'COUNT("uid") AS nb_marches, ' + 'COALESCE(SUM("montant"), 0) AS montant_total' + ), + where_sql=where_sql, + params=params, + group_by=f'"{other}_id"', + order_by="nb_marches DESC", + limit=TOP_N, + ) + top_cpv = aggregate_marches( + select_sql='"codeCPV" AS cpv, COUNT("uid") AS nb_marches', + where_sql=where_sql, + params=params, + group_by='"codeCPV"', + order_by="nb_marches DESC", + limit=TOP_N, + ) + return { + "identite": identite, + "nb_marches": nb, + "montant_total": float(totals["montant_total"][0]), + "repartition_annuelle": to_json_records(annuelle), + f"top_{other}s": to_json_records(top_other), + "top_cpv": to_json_records(top_cpv), + } +``` + +- [ ] **Step 4: Lancer le test → succès** + +Run: `uv run pytest tests/mcp/test_queries.py -v` +Expected: PASS (13 tests au total dans ce fichier) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/queries.py tests/mcp/test_queries.py +git commit -m "feat(mcp): tools stats_acheteur / stats_titulaire (agrégations)" +``` + +--- + +### Task 7: `tools.py` — les 4 fonctions `@mcp_enabled` + +**Files:** + +- Create: `src/mcp/tools.py` +- Test: `tests/mcp/test_tools.py` + +**Interfaces:** + +- Consumes: `queries.search_organisations`, `queries.search_marches`, `queries.compute_org_stats` ; `track_mcp_tool` (Task 2) ; `mcp_enabled` de `dash.mcp`. +- Produces: 4 fonctions décorées `rechercher_organisations`, `stats_acheteur`, `stats_titulaire`, `rechercher_marches`, importables et appelables directement. + +- [ ] **Step 1: Écrire le test** + +```python +# tests/mcp/test_tools.py +from src.mcp import tools + + +def test_all_four_tools_are_callable(): + for name in ( + "rechercher_organisations", + "stats_acheteur", + "stats_titulaire", + "rechercher_marches", + ): + assert callable(getattr(tools, name)) + + +def test_rechercher_organisations_returns_list(): + result = tools.rechercher_organisations("ACHETEUR", "acheteur") + assert isinstance(result, list) + assert any(r["id"] == "123" for r in result) + + +def test_rechercher_marches_returns_meta(): + result = tools.rechercher_marches(acheteur_id="123") + assert result["meta"]["total"] >= 1 + + +def test_stats_acheteur_returns_stats(): + result = tools.stats_acheteur("123") + assert result["nb_marches"] >= 1 + assert result["identite"]["id"] == "123" + + +def test_stats_titulaire_returns_stats(): + result = tools.stats_titulaire("345") + assert result["nb_marches"] >= 1 +``` + +- [ ] **Step 2: Lancer le test → échec** + +Run: `uv run pytest tests/mcp/test_tools.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'src.mcp.tools'` + +- [ ] **Step 3: Créer `src/mcp/tools.py`** + +```python +# src/mcp/tools.py +from dash.mcp import mcp_enabled + +from src.mcp import queries +from src.utils.tracking import track_mcp_tool + + +@mcp_enabled(name="rechercher_organisations", expose_docstring=True) +def rechercher_organisations( + query: str, type: str = "acheteur", limite: int = 20 # noqa: A002 +) -> list[dict]: + """Recherche des acheteurs ou titulaires publics par nom. + + Utiliser en premier pour résoudre un nom d'organisation vers son + identifiant, à passer ensuite à stats_acheteur / stats_titulaire. + + query: texte libre (nom d'organisation). + type: "acheteur" ou "titulaire". + Retourne une liste de {id, nom, departement}. + """ + track_mcp_tool("rechercher_organisations", query=query) + return queries.search_organisations(query, type, limite) + + +@mcp_enabled(name="stats_acheteur", expose_docstring=True) +def stats_acheteur(acheteur_id: str) -> dict: + """Statistiques agrégées d'un acheteur public (par identifiant). + + Retourne nombre de marchés, montant total, répartition annuelle, + principaux titulaires et principaux codes CPV. + """ + track_mcp_tool("stats_acheteur") + return queries.compute_org_stats("acheteur", acheteur_id) + + +@mcp_enabled(name="stats_titulaire", expose_docstring=True) +def stats_titulaire(titulaire_id: str) -> dict: + """Statistiques agrégées d'un titulaire (entreprise) par identifiant. + + Retourne nombre de marchés remportés, montant total, répartition + annuelle, principaux acheteurs et principaux codes CPV. + """ + track_mcp_tool("stats_titulaire") + return queries.compute_org_stats("titulaire", titulaire_id) + + +@mcp_enabled(name="rechercher_marches", expose_docstring=True) +def rechercher_marches( + acheteur_id: str | None = None, + titulaire_id: str | None = None, + cpv: str | None = None, + objet_contient: str | None = None, + montant_min: float | None = None, + montant_max: float | None = None, + date_min: str | None = None, + date_max: str | None = None, + departement: str | None = None, + page: int = 1, + filtres_avances: dict | None = None, +) -> dict: + """Recherche paginée de marchés publics (DECP). + + Filtres nommés : acheteur_id, titulaire_id, cpv (code CPV, correspondance + partielle), objet_contient (texte de l'objet), montant_min, montant_max, + date_min / date_max (format YYYY-MM-DD, sur dateNotification), + departement (code département de l'acheteur). + filtres_avances : dict optionnel {"colonne__operateur": valeur} pour les + besoins pointus (mêmes colonnes/opérateurs que l'API REST colibre). + page : numéro de page (50 résultats par page). + Retourne {meta: {page, page_size, total}, marches: [...]}. + """ + track_mcp_tool("rechercher_marches", query=objet_contient) + return queries.search_marches( + acheteur_id=acheteur_id, + titulaire_id=titulaire_id, + cpv=cpv, + objet_contient=objet_contient, + montant_min=montant_min, + montant_max=montant_max, + date_min=date_min, + date_max=date_max, + departement=departement, + page=page, + filtres_avances=filtres_avances, + ) +``` + +- [ ] **Step 4: Lancer le test → succès** + +Run: `uv run pytest tests/mcp/test_tools.py -v` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/tools.py tests/mcp/test_tools.py +git commit -m "feat(mcp): expose les 4 fonctions métier via @mcp_enabled" +``` + +--- + +### Task 8: Activation du serveur MCP dans l'app + doc env + +**Files:** + +- Modify: `src/app.py` +- Modify: `.template.env` +- Test: vérification manuelle (import) + suite complète. + +**Interfaces:** + +- Consumes: `src.mcp.tools` (Task 7), `enable_mcp`/`configure_mcp_server` de Dash. +- Produces: serveur MCP monté sur `/_mcp` quand `DASH_MCP_ENABLED=true`, exposant uniquement les 4 tools. + +- [ ] **Step 1: Ajouter le paramètre `enable_mcp` au constructeur `Dash`** + +Dans `src/app.py`, juste avant `app: Dash = Dash(` (vers la ligne 78), ajouter : + +```python +_mcp_enabled = os.getenv("DASH_MCP_ENABLED") == "true" +``` + +Puis, dans l'appel `Dash(...)`, ajouter le paramètre (après `compress=True,`) : + +```python + enable_mcp=_mcp_enabled, +``` + +- [ ] **Step 2: Monter les tools après l'init de l'app** + +Toujours dans `src/app.py`, juste après la ligne `init_api(app.server)`, ajouter : + +```python +# Serveur MCP (issue #111, scope A) : n'expose QUE les fonctions @mcp_enabled, +# jamais les callbacks/layout/pages d'UI. Activé via DASH_MCP_ENABLED=true. +if _mcp_enabled: + from dash.mcp import configure_mcp_server # noqa: E402 + + configure_mcp_server( + include_layout=False, + include_callbacks=False, + include_pages=False, + include_clientside_callbacks=False, + ) + import src.mcp.tools # noqa: E402,F401 # l'import enregistre les @mcp_enabled +``` + +- [ ] **Step 3: Documenter la variable dans `.template.env`** + +Ajouter à `.template.env` : + +```bash +# Serveur MCP (issue #111). Laisser à false tant que l'authentification +# (OAuth + gate abonnement, scope B) n'est pas en place : sinon le serveur MCP +# est ouvert sans contrôle d'accès. Prérequis Matomo pour le tracking des appels : +# créer un Custom Dimension slot 1 (scope Action). +DASH_MCP_ENABLED=false +``` + +- [ ] **Step 4: Vérifier l'import des tools (hors app complète)** + +Run: `uv run python -c "import src.mcp.tools; print('ok', [f for f in ('rechercher_organisations','stats_acheteur','stats_titulaire','rechercher_marches')])"` +Expected: affiche `ok [...]` sans erreur d'import. + +- [ ] **Step 5: Lancer la suite de tests MCP complète** + +Run: `uv run pytest tests/mcp/ -v` +Expected: PASS (tous les tests des tâches 1-7). + +- [ ] **Step 6: Lancer la suite complète (dernière tâche du plan)** + +Run: `uv run pytest` +Expected: PASS (aucune régression ; les tests Selenium nécessitent Chrome). + +- [ ] **Step 7: Commit** + +```bash +git add src/app.py .template.env +git commit -m "feat(mcp): activer le serveur MCP via DASH_MCP_ENABLED (scope A #111)" +``` + +--- + +## Vérification manuelle de bout en bout (optionnelle, après Task 8) + +Pour tester le serveur MCP réel avec un vrai client : + +```bash +DASH_MCP_ENABLED=true DATA_FILE_PARQUET_PATH=tests/test.parquet uv run run.py +# dans un autre terminal / client MCP : +claude mcp add colibre-local --transport http --scope user http://127.0.0.1:8050/_mcp +``` + +Puis demander à l'agent d'appeler `rechercher_marches` / `stats_acheteur`. Confirmer que seuls les 4 tools sont visibles (aucun callback d'UI). + +## Self-Review (effectuée) + +- **Couverture spec** : sérialisation (T1), tracking Matomo `action_name="MCP"`+`dimension1` (T2), non-pollution de `track_search` (T3), les 4 tools `rechercher_organisations` (T4) / `rechercher_marches` hybride (T5) / `stats_acheteur`+`stats_titulaire` (T6), surface réduite via `configure_mcp_server` + gating `DASH_MCP_ENABLED` off par défaut (T8), doc env + prérequis Matomo (T8). ✎ Écarts assumés vs spec, dus aux données réelles : pas de libellé CPV (`top_cpv` = `{cpv, nb_marches}`) ; `search_organisations` renvoie `{id, nom, departement}` sans `commune` (colonne absente de la sortie de `search_org`) — l'identité complète (avec commune) reste fournie par `stats_*` via `_org_identite`. Arborescence affinée : logique données regroupée dans `queries.py` (au lieu de `stats.py` seul) pour garder `tools.py` sans SQL. +- **Placeholders** : aucun TODO/TBD ; tout le code est fourni. +- **Cohérence des types** : `search_marches` / `compute_org_stats` / `search_organisations` référencés en T7 avec les signatures définies en T4-T6 ; `to_json_records`, `track_mcp_tool`, `search_org(track=…)` cohérents entre tâches ; contrepartie `top_s` cohérente entre T6 (définition) et T7 (docstrings). From e407f875293d8e02a8faefbdcf494be11d0292a6 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 19:53:08 +0200 Subject: [PATCH 03/32] =?UTF-8?q?feat(mcp):=20s=C3=A9rialisation=20Polars?= =?UTF-8?q?=20->=20JSON=20pour=20les=20tools=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- src/mcp/__init__.py | 0 src/mcp/serialization.py | 21 +++++++++++++++++++++ tests/mcp/__init__.py | 0 tests/mcp/test_serialization.py | 20 ++++++++++++++++++++ 4 files changed, 41 insertions(+) create mode 100644 src/mcp/__init__.py create mode 100644 src/mcp/serialization.py create mode 100644 tests/mcp/__init__.py create mode 100644 tests/mcp/test_serialization.py diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mcp/serialization.py b/src/mcp/serialization.py new file mode 100644 index 0000000..9b81ff5 --- /dev/null +++ b/src/mcp/serialization.py @@ -0,0 +1,21 @@ +import datetime +from typing import Any + +import polars as pl + + +def to_json_records(df: pl.DataFrame) -> list[dict[str, Any]]: + """Convertit un DataFrame Polars en liste de dicts JSON-sérialisables. + + Les dates/datetimes deviennent des chaînes ISO 8601 ; les valeurs nulles + restent None. Utilisé par tous les tools MCP pour produire une sortie propre. + """ + return [ + {key: _jsonify(value) for key, value in row.items()} for row in df.to_dicts() + ] + + +def _jsonify(value: Any) -> Any: + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + return value diff --git a/tests/mcp/__init__.py b/tests/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mcp/test_serialization.py b/tests/mcp/test_serialization.py new file mode 100644 index 0000000..ad35b88 --- /dev/null +++ b/tests/mcp/test_serialization.py @@ -0,0 +1,20 @@ +import datetime + +import polars as pl + +from src.mcp.serialization import to_json_records + + +def test_dates_become_iso_strings(): + df = pl.DataFrame({"d": [datetime.date(2025, 1, 1)], "n": [10]}) + assert to_json_records(df) == [{"d": "2025-01-01", "n": 10}] + + +def test_none_is_preserved(): + df = pl.DataFrame({"nom": [None], "x": [3]}) + assert to_json_records(df) == [{"nom": None, "x": 3}] + + +def test_strings_and_numbers_untouched(): + df = pl.DataFrame({"s": ["abc"], "f": [1.5]}) + assert to_json_records(df) == [{"s": "abc", "f": 1.5}] From 571c56ef7102ceb2966dbbd5f8ffa454012c4fd7 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 19:57:09 +0200 Subject: [PATCH 04/32] feat(mcp): helper track_mcp_tool pour tracer les appels MCP dans Matomo --- src/utils/tracking.py | 32 +++++++++++++++++++++++++++++ tests/mcp/test_tracking.py | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 tests/mcp/test_tracking.py diff --git a/src/utils/tracking.py b/src/utils/tracking.py index 86e5377..e7ba173 100644 --- a/src/utils/tracking.py +++ b/src/utils/tracking.py @@ -28,3 +28,35 @@ def track_search(query, category): url=f"https://{os.getenv('MATOMO_DOMAIN')}/matomo.php", params=params, ).raise_for_status() + + +def track_mcp_tool(tool_name: str, query: str | None = None) -> None: + """Enregistre un appel d'outil MCP dans Matomo (best-effort, prod uniquement). + + `action_name="MCP / "`, `dimension1=`. Si l'outil porte une + requête texte, elle est envoyée en `search`. Nécessite un Custom Dimension + slot 1 (scope Action) configuré côté Matomo — sinon `dimension1` est ignoré. + Ne lève jamais : une panne Matomo ne doit pas casser l'appel du tool. + """ + if DEVELOPMENT or not os.getenv("MATOMO_DOMAIN"): + return + params = { + "idsite": os.getenv("MATOMO_ID_SITE"), + "url": "https://colibre.fr/_mcp", + "rec": "1", + "action_name": f"MCP / {tool_name}", + "dimension1": tool_name, + "rand": uuid.uuid4().hex, + "apiv": "1", + "h": localtime().tm_hour, + "m": localtime().tm_min, + "s": localtime().tm_sec, + "token_auth": os.getenv("MATOMO_TOKEN"), + } + if query: + params["search"] = query + params["search_cat"] = "mcp" + try: + post(url=f"https://{os.getenv('MATOMO_DOMAIN')}/matomo.php", params=params) + except Exception: + pass diff --git a/tests/mcp/test_tracking.py b/tests/mcp/test_tracking.py new file mode 100644 index 0000000..e4067b9 --- /dev/null +++ b/tests/mcp/test_tracking.py @@ -0,0 +1,42 @@ +import src.utils.tracking as tracking + + +def test_track_mcp_tool_sends_action_and_dimension(monkeypatch): + captured = {} + + def fake_post(url, params): + captured["url"] = url + captured["params"] = params + + class _R: + def raise_for_status(self): + pass + + return _R() + + monkeypatch.setattr(tracking, "DEVELOPMENT", False) + monkeypatch.setattr(tracking, "post", fake_post) + monkeypatch.setenv("MATOMO_DOMAIN", "matomo.example") + monkeypatch.setenv("MATOMO_ID_SITE", "1") + + tracking.track_mcp_tool("rechercher_marches", query="informatique") + + assert captured["params"]["action_name"] == "MCP / rechercher_marches" + assert captured["params"]["dimension1"] == "rechercher_marches" + assert captured["params"]["search"] == "informatique" + + +def test_track_mcp_tool_noop_in_development(monkeypatch): + called = False + + def fake_post(url, params): + nonlocal called + called = True + + monkeypatch.setattr(tracking, "DEVELOPMENT", True) + monkeypatch.setattr(tracking, "post", fake_post) + monkeypatch.setenv("MATOMO_DOMAIN", "matomo.example") + + tracking.track_mcp_tool("stats_acheteur") + + assert called is False From 9303c5e206d25f11df4edbd1c5a1c063efdbad5a Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 20:01:57 +0200 Subject: [PATCH 05/32] feat(mcp): search_org accepte track=False pour ne pas polluer Matomo Co-Authored-By: Claude Sonnet 5 --- src/utils/search.py | 7 +++++-- tests/mcp/test_queries.py | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 tests/mcp/test_queries.py diff --git a/src/utils/search.py b/src/utils/search.py index df998ad..57252ae 100644 --- a/src/utils/search.py +++ b/src/utils/search.py @@ -5,7 +5,9 @@ from src.utils.table import add_links from src.utils.tracking import track_search -def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame: +def search_org( + dff: pl.DataFrame, query: str, org_type: str, track: bool = True +) -> pl.DataFrame: """ Search in either 'acheteur' or 'titulaire' DataFrame. @@ -18,7 +20,8 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame: return dff.select(pl.lit(False).alias("matches")) # Enregistrement des recherche dans Matomo - track_search(query, "home_page_search") + if track: + track_search(query, "home_page_search") # Normalize query normalized_query = unidecode(query.strip()).upper() diff --git a/tests/mcp/test_queries.py b/tests/mcp/test_queries.py new file mode 100644 index 0000000..b560a73 --- /dev/null +++ b/tests/mcp/test_queries.py @@ -0,0 +1,21 @@ +import src.utils.search as search_mod +from src.utils.data import DF_ACHETEURS +from src.utils.search import search_org + + +def test_search_org_track_false_skips_track_search(monkeypatch): + calls = [] + monkeypatch.setattr(search_mod, "track_search", lambda q, c: calls.append((q, c))) + + search_org(DF_ACHETEURS, "ACHETEUR", "acheteur", track=False) + + assert calls == [] + + +def test_search_org_track_true_calls_track_search(monkeypatch): + calls = [] + monkeypatch.setattr(search_mod, "track_search", lambda q, c: calls.append((q, c))) + + search_org(DF_ACHETEURS, "ACHETEUR", "acheteur", track=True) + + assert calls == [("ACHETEUR", "home_page_search")] From e2d1082e92c3fe075748c42619b28d1b6e6fd53b Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 20:13:17 +0200 Subject: [PATCH 06/32] =?UTF-8?q?feat(mcp):=20tool=20search=5Forganisation?= =?UTF-8?q?s=20(r=C3=A9solution=20nom=20->=20id)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- src/mcp/queries.py | 37 +++++++++++++++++++++++++++++++++++++ tests/mcp/test_queries.py | 20 ++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/mcp/queries.py diff --git a/src/mcp/queries.py b/src/mcp/queries.py new file mode 100644 index 0000000..cbc9d98 --- /dev/null +++ b/src/mcp/queries.py @@ -0,0 +1,37 @@ +# src/mcp/queries.py +import re + +from src.mcp.serialization import ( + to_json_records, # noqa: F401 (utilisé aux tâches suivantes) +) +from src.utils.data import DF_ACHETEURS, DF_TITULAIRES +from src.utils.search import search_org + +PAGE_SIZE = 50 +TOP_N = 10 +ORG_FRAMES = {"acheteur": DF_ACHETEURS, "titulaire": DF_TITULAIRES} + + +def _extract_plain_text(html_str: str) -> str: + """Extract plain text from HTML link, e.g. '123' -> '123'.""" + match = re.search(r">([^<]+)<", html_str) + return match.group(1) if match else html_str + + +def search_organisations( + query: str, org_type: str = "acheteur", limite: int = 20 +) -> list[dict]: + """Recherche des organisations par nom, résout un nom -> id.""" + if org_type not in ORG_FRAMES: + raise ValueError( + f"type invalide: {org_type!r} (attendu 'acheteur' ou 'titulaire')" + ) + df = search_org(ORG_FRAMES[org_type], query, org_type, track=False).head(limite) + return [ + { + "id": _extract_plain_text(r[f"{org_type}_id"]), + "nom": _extract_plain_text(r[f"{org_type}_nom"]), + "departement": r.get("Département"), + } + for r in df.to_dicts() + ] diff --git a/tests/mcp/test_queries.py b/tests/mcp/test_queries.py index b560a73..8dce3ad 100644 --- a/tests/mcp/test_queries.py +++ b/tests/mcp/test_queries.py @@ -1,4 +1,7 @@ +import pytest + import src.utils.search as search_mod +from src.mcp.queries import search_organisations from src.utils.data import DF_ACHETEURS from src.utils.search import search_org @@ -19,3 +22,20 @@ def test_search_org_track_true_calls_track_search(monkeypatch): search_org(DF_ACHETEURS, "ACHETEUR", "acheteur", track=True) assert calls == [("ACHETEUR", "home_page_search")] + + +def test_search_organisations_finds_known_acheteur(): + result = search_organisations("ACHETEUR", "acheteur") + assert any(r["id"] == "123" for r in result) + first = next(r for r in result if r["id"] == "123") + assert set(first.keys()) == {"id", "nom", "departement"} + + +def test_search_organisations_invalid_type_raises(): + with pytest.raises(ValueError): + search_organisations("x", "autre") + + +def test_search_organisations_respects_limite(): + result = search_organisations("ACHETEUR", "acheteur", limite=1) + assert len(result) <= 1 From 47694f2e5d9f8a6355899e272bcaa8ccbdbe5893 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 20:18:28 +0200 Subject: [PATCH 07/32] =?UTF-8?q?test(mcp):=20v=C3=A9rifier=20l'extraction?= =?UTF-8?q?=20HTML->texte=20plain=20du=20nom=20(revue=20Task=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/mcp/test_queries.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/mcp/test_queries.py b/tests/mcp/test_queries.py index 8dce3ad..7f19ac4 100644 --- a/tests/mcp/test_queries.py +++ b/tests/mcp/test_queries.py @@ -29,6 +29,9 @@ def test_search_organisations_finds_known_acheteur(): assert any(r["id"] == "123" for r in result) first = next(r for r in result if r["id"] == "123") assert set(first.keys()) == {"id", "nom", "departement"} + # Vérifier que le nom a été extrait en texte plain (HTML strippé) + assert first["nom"] == "ACHETEUR 1" + assert "<" not in first["nom"] # Défense : aucun markup HTML ne s'échappe def test_search_organisations_invalid_type_raises(): From 11da1e9d8dace34e22e9dab64dfa1dcb92733449 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 20:23:05 +0200 Subject: [PATCH 08/32] feat(mcp): tool rechercher_marches (filtres hybrides + pagination) --- src/mcp/queries.py | 100 ++++++++++++++++++++++++++++++++++++-- tests/mcp/test_queries.py | 40 ++++++++++++++- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/src/mcp/queries.py b/src/mcp/queries.py index cbc9d98..d52f8a1 100644 --- a/src/mcp/queries.py +++ b/src/mcp/queries.py @@ -1,9 +1,10 @@ # src/mcp/queries.py import re -from src.mcp.serialization import ( - to_json_records, # noqa: F401 (utilisé aux tâches suivantes) -) +from src.api.filters import FilterError, build_where +from src.db import count_marches, query_marches +from src.db import schema as duckdb_schema +from src.mcp.serialization import to_json_records from src.utils.data import DF_ACHETEURS, DF_TITULAIRES from src.utils.search import search_org @@ -11,6 +12,99 @@ PAGE_SIZE = 50 TOP_N = 10 ORG_FRAMES = {"acheteur": DF_ACHETEURS, "titulaire": DF_TITULAIRES} +# Colonnes renvoyées par rechercher_marches (sortie ciblée, pas SELECT *). +MARCHES_COLUMNS = [ + "uid", + "objet", + "montant", + "dateNotification", + "codeCPV", + "acheteur_id", + "acheteur_nom", + "acheteur_departement_code", + "titulaire_id", + "titulaire_nom", +] + +# (param nommé, colonne decp, opérateur du moteur de filtres API). +# `greater` = >=, `less` = <=, `contains` = LIKE %v%, `exact` = =. +_NAMED_FILTERS = [ + ("acheteur_id", "acheteur_id", "exact"), + ("titulaire_id", "titulaire_id", "exact"), + ("cpv", "codeCPV", "contains"), + ("objet_contient", "objet", "contains"), + ("montant_min", "montant", "greater"), + ("montant_max", "montant", "less"), + ("date_min", "dateNotification", "greater"), + ("date_max", "dateNotification", "less"), + ("departement", "acheteur_departement_code", "exact"), +] + + +def build_where_args( + named: dict, filtres_avances: dict | None +) -> list[tuple[str, str]]: + """Traduit les paramètres nommés + filtres avancés en tuples (col__op, valeur).""" + args: list[tuple[str, str]] = [] + for param, col, op in _NAMED_FILTERS: + value = named.get(param) + if value is not None: + args.append((f"{col}__{op}", str(value))) + if filtres_avances: + for key, value in filtres_avances.items(): + args.append((key, str(value))) + return args + + +def search_marches( + *, + acheteur_id: str | None = None, + titulaire_id: str | None = None, + cpv: str | None = None, + objet_contient: str | None = None, + montant_min: float | None = None, + montant_max: float | None = None, + date_min: str | None = None, + date_max: str | None = None, + departement: str | None = None, + page: int = 1, + filtres_avances: dict | None = None, +) -> dict: + """Recherche paginée de marchés. Même sémantique de filtres que l'API REST.""" + named = { + "acheteur_id": acheteur_id, + "titulaire_id": titulaire_id, + "cpv": cpv, + "objet_contient": objet_contient, + "montant_min": montant_min, + "montant_max": montant_max, + "date_min": date_min, + "date_max": date_max, + "departement": departement, + } + args = build_where_args(named, filtres_avances) + try: + where_sql, params, order_sql = build_where(args, duckdb_schema) + except FilterError as e: + return {"error": str(e), "champ": e.field} + + page = max(1, int(page)) + offset = (page - 1) * PAGE_SIZE + order_by = order_sql or '"dateNotification" DESC, "uid" DESC' + df = query_marches( + where_sql, + params, + columns=MARCHES_COLUMNS, + order_by=order_by, + limit=PAGE_SIZE, + offset=offset, + ) + total = count_marches(where_sql, params) + return { + "meta": {"page": page, "page_size": PAGE_SIZE, "total": total}, + "marches": to_json_records(df), + } + def _extract_plain_text(html_str: str) -> str: """Extract plain text from HTML link, e.g. '123' -> '123'.""" diff --git a/tests/mcp/test_queries.py b/tests/mcp/test_queries.py index 7f19ac4..b8031da 100644 --- a/tests/mcp/test_queries.py +++ b/tests/mcp/test_queries.py @@ -1,7 +1,7 @@ import pytest import src.utils.search as search_mod -from src.mcp.queries import search_organisations +from src.mcp.queries import build_where_args, search_marches, search_organisations from src.utils.data import DF_ACHETEURS from src.utils.search import search_org @@ -42,3 +42,41 @@ def test_search_organisations_invalid_type_raises(): def test_search_organisations_respects_limite(): result = search_organisations("ACHETEUR", "acheteur", limite=1) assert len(result) <= 1 + + +def test_build_where_args_named_params(): + args = build_where_args( + {"acheteur_id": "123", "montant_min": 5, "objet_contient": "test"}, None + ) + assert ("acheteur_id__exact", "123") in args + assert ("montant__greater", "5") in args + assert ("objet__contains", "test") in args + + +def test_build_where_args_merges_filtres_avances(): + args = build_where_args( + {"acheteur_id": "123"}, {"titulaire_departement_code__exact": "35"} + ) + assert ("acheteur_id__exact", "123") in args + assert ("titulaire_departement_code__exact", "35") in args + + +def test_search_marches_returns_meta_and_rows(): + result = search_marches(acheteur_id="123") + assert result["meta"]["total"] >= 1 + assert result["meta"]["page"] == 1 + assert result["meta"]["page_size"] == 50 + assert any(m["acheteur_id"] == "123" for m in result["marches"]) + # dates sérialisées en ISO + assert result["marches"][0]["dateNotification"] == "2025-01-01" + + +def test_search_marches_no_match_is_empty(): + result = search_marches(acheteur_id="inconnu-xyz") + assert result["meta"]["total"] == 0 + assert result["marches"] == [] + + +def test_search_marches_bad_filter_returns_error(): + result = search_marches(filtres_avances={"colonne_bidon__exact": "x"}) + assert "error" in result From ee98aa8186d01a51dccf9cd6cf4ff654eca89f45 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 20:30:16 +0200 Subject: [PATCH 09/32] =?UTF-8?q?feat(mcp):=20tools=20stats=5Facheteur=20/?= =?UTF-8?q?=20stats=5Ftitulaire=20(agr=C3=A9gations)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mcp/queries.py | 93 ++++++++++++++++++++++++++++++++++++++- tests/mcp/test_queries.py | 35 ++++++++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/mcp/queries.py b/src/mcp/queries.py index d52f8a1..2716363 100644 --- a/src/mcp/queries.py +++ b/src/mcp/queries.py @@ -2,7 +2,7 @@ import re from src.api.filters import FilterError, build_where -from src.db import count_marches, query_marches +from src.db import aggregate_marches, count_marches, query_marches from src.db import schema as duckdb_schema from src.mcp.serialization import to_json_records from src.utils.data import DF_ACHETEURS, DF_TITULAIRES @@ -129,3 +129,94 @@ def search_organisations( } for r in df.to_dicts() ] + + +def _org_identite(org_type: str, org_id: str) -> dict: + df = query_marches( + where_sql=f'"{org_type}_id" = ?', + params=[org_id], + columns=[ + f"{org_type}_id", + f"{org_type}_nom", + f"{org_type}_departement_nom", + f"{org_type}_commune_nom", + ], + limit=1, + ) + if df.height == 0: + return {"id": org_id, "nom": None, "departement": None, "commune": None} + r = df.row(0, named=True) + return { + "id": org_id, + "nom": r[f"{org_type}_nom"], + "departement": r[f"{org_type}_departement_nom"], + "commune": r[f"{org_type}_commune_nom"], + } + + +def compute_org_stats(org_type: str, org_id: str) -> dict: + """Statistiques agrégées d'un acheteur ou titulaire.""" + if org_type not in ORG_FRAMES: + raise ValueError( + f"type invalide: {org_type!r} (attendu 'acheteur' ou 'titulaire')" + ) + other = "titulaire" if org_type == "acheteur" else "acheteur" + where_sql = f'"{org_type}_id" = ?' + params = [org_id] + identite = _org_identite(org_type, org_id) + + totals = aggregate_marches( + select_sql='COUNT("uid") AS nb, COALESCE(SUM("montant"), 0) AS montant_total', + where_sql=where_sql, + params=params, + ) + nb = int(totals["nb"][0]) + if nb == 0: + return { + "identite": identite, + "nb_marches": 0, + "montant_total": 0, + "repartition_annuelle": [], + f"top_{other}s": [], + "top_cpv": [], + } + + annuelle = aggregate_marches( + select_sql=( + "CAST(date_part('year', \"dateNotification\") AS INTEGER) AS annee, " + 'COUNT("uid") AS nb_marches, ' + 'COALESCE(SUM("montant"), 0) AS montant_total' + ), + where_sql=where_sql, + params=params, + group_by="date_part('year', \"dateNotification\")", + order_by="annee", + ) + top_other = aggregate_marches( + select_sql=( + f'"{other}_id" AS id, any_value("{other}_nom") AS nom, ' + 'COUNT("uid") AS nb_marches, ' + 'COALESCE(SUM("montant"), 0) AS montant_total' + ), + where_sql=where_sql, + params=params, + group_by=f'"{other}_id"', + order_by="nb_marches DESC", + limit=TOP_N, + ) + top_cpv = aggregate_marches( + select_sql='"codeCPV" AS cpv, COUNT("uid") AS nb_marches', + where_sql=where_sql, + params=params, + group_by='"codeCPV"', + order_by="nb_marches DESC", + limit=TOP_N, + ) + return { + "identite": identite, + "nb_marches": nb, + "montant_total": float(totals["montant_total"][0]), + "repartition_annuelle": to_json_records(annuelle), + f"top_{other}s": to_json_records(top_other), + "top_cpv": to_json_records(top_cpv), + } diff --git a/tests/mcp/test_queries.py b/tests/mcp/test_queries.py index b8031da..83605b6 100644 --- a/tests/mcp/test_queries.py +++ b/tests/mcp/test_queries.py @@ -1,7 +1,12 @@ import pytest import src.utils.search as search_mod -from src.mcp.queries import build_where_args, search_marches, search_organisations +from src.mcp.queries import ( + build_where_args, + compute_org_stats, + search_marches, + search_organisations, +) from src.utils.data import DF_ACHETEURS from src.utils.search import search_org @@ -80,3 +85,31 @@ def test_search_marches_no_match_is_empty(): def test_search_marches_bad_filter_returns_error(): result = search_marches(filtres_avances={"colonne_bidon__exact": "x"}) assert "error" in result + + +def test_compute_org_stats_acheteur_known(): + stats = compute_org_stats("acheteur", "123") + assert stats["nb_marches"] >= 1 + assert stats["montant_total"] == 10 + assert stats["identite"]["id"] == "123" + assert stats["identite"]["nom"] == "ACHETEUR 1" + assert "top_titulaires" in stats + assert "top_cpv" in stats + # répartition annuelle dérivée de dateNotification (2025) + annees = [row["annee"] for row in stats["repartition_annuelle"]] + assert 2025 in annees + + +def test_compute_org_stats_titulaire_known(): + stats = compute_org_stats("titulaire", "345") + assert stats["nb_marches"] >= 1 + assert "top_acheteurs" in stats + + +def test_compute_org_stats_unknown_is_empty(): + stats = compute_org_stats("acheteur", "inconnu-xyz") + assert stats["nb_marches"] == 0 + assert stats["montant_total"] == 0 + assert stats["repartition_annuelle"] == [] + assert stats["top_titulaires"] == [] + assert stats["top_cpv"] == [] From 5a6bd59297c053de28940056781dd3def8089d58 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 20:35:24 +0200 Subject: [PATCH 10/32] =?UTF-8?q?feat(mcp):=20expose=20les=204=20fonctions?= =?UTF-8?q?=20m=C3=A9tier=20via=20@mcp=5Fenabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- src/mcp/tools.py | 86 +++++++++++++++++++++++++++++++++++++++++ tests/mcp/test_tools.py | 33 ++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/mcp/tools.py create mode 100644 tests/mcp/test_tools.py diff --git a/src/mcp/tools.py b/src/mcp/tools.py new file mode 100644 index 0000000..1f08a3b --- /dev/null +++ b/src/mcp/tools.py @@ -0,0 +1,86 @@ +from dash.mcp import mcp_enabled + +from src.mcp import queries +from src.utils.tracking import track_mcp_tool + + +@mcp_enabled(name="rechercher_organisations", expose_docstring=True) +def rechercher_organisations( + query: str, + type: str = "acheteur", + limite: int = 20, # noqa: A002 +) -> list[dict]: + """Recherche des acheteurs ou titulaires publics par nom. + + Utiliser en premier pour résoudre un nom d'organisation vers son + identifiant, à passer ensuite à stats_acheteur / stats_titulaire. + + query: texte libre (nom d'organisation). + type: "acheteur" ou "titulaire". + Retourne une liste de {id, nom, departement}. + """ + track_mcp_tool("rechercher_organisations", query=query) + return queries.search_organisations(query, type, limite) + + +@mcp_enabled(name="stats_acheteur", expose_docstring=True) +def stats_acheteur(acheteur_id: str) -> dict: + """Statistiques agrégées d'un acheteur public (par identifiant). + + Retourne nombre de marchés, montant total, répartition annuelle, + principaux titulaires et principaux codes CPV. + """ + track_mcp_tool("stats_acheteur") + return queries.compute_org_stats("acheteur", acheteur_id) + + +@mcp_enabled(name="stats_titulaire", expose_docstring=True) +def stats_titulaire(titulaire_id: str) -> dict: + """Statistiques agrégées d'un titulaire (entreprise) par identifiant. + + Retourne nombre de marchés remportés, montant total, répartition + annuelle, principaux acheteurs et principaux codes CPV. + """ + track_mcp_tool("stats_titulaire") + return queries.compute_org_stats("titulaire", titulaire_id) + + +@mcp_enabled(name="rechercher_marches", expose_docstring=True) +def rechercher_marches( + acheteur_id: str | None = None, + titulaire_id: str | None = None, + cpv: str | None = None, + objet_contient: str | None = None, + montant_min: float | None = None, + montant_max: float | None = None, + date_min: str | None = None, + date_max: str | None = None, + departement: str | None = None, + page: int = 1, + filtres_avances: dict | None = None, +) -> dict: + """Recherche paginée de marchés publics (DECP). + + Filtres nommés : acheteur_id, titulaire_id, cpv (code CPV, correspondance + partielle), objet_contient (texte de l'objet), montant_min, montant_max, + date_min / date_max (format YYYY-MM-DD, sur dateNotification), + departement (code département de l'acheteur). + filtres_avances : dict optionnel {"colonne__operateur": valeur} pour les + besoins pointus (mêmes colonnes/opérateurs que l'API REST colibre). + page : numéro de page (50 résultats par page). + Retourne {meta: {page, page_size, total}, marches: [...]}. + """ + track_mcp_tool("rechercher_marches", query=objet_contient) + return queries.search_marches( + acheteur_id=acheteur_id, + titulaire_id=titulaire_id, + cpv=cpv, + objet_contient=objet_contient, + montant_min=montant_min, + montant_max=montant_max, + date_min=date_min, + date_max=date_max, + departement=departement, + page=page, + filtres_avances=filtres_avances, + ) diff --git a/tests/mcp/test_tools.py b/tests/mcp/test_tools.py new file mode 100644 index 0000000..18ac863 --- /dev/null +++ b/tests/mcp/test_tools.py @@ -0,0 +1,33 @@ +from src.mcp import tools + + +def test_all_four_tools_are_callable(): + for name in ( + "rechercher_organisations", + "stats_acheteur", + "stats_titulaire", + "rechercher_marches", + ): + assert callable(getattr(tools, name)) + + +def test_rechercher_organisations_returns_list(): + result = tools.rechercher_organisations("ACHETEUR", "acheteur") + assert isinstance(result, list) + assert any(r["id"] == "123" for r in result) + + +def test_rechercher_marches_returns_meta(): + result = tools.rechercher_marches(acheteur_id="123") + assert result["meta"]["total"] >= 1 + + +def test_stats_acheteur_returns_stats(): + result = tools.stats_acheteur("123") + assert result["nb_marches"] >= 1 + assert result["identite"]["id"] == "123" + + +def test_stats_titulaire_returns_stats(): + result = tools.stats_titulaire("345") + assert result["nb_marches"] >= 1 From 903b1e4dfd6e3c2ed7331977c4a36a4d7817b465 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 20:42:28 +0200 Subject: [PATCH 11/32] =?UTF-8?q?docs:=20ajouter=20l'entr=C3=A9e=20changel?= =?UTF-8?q?og=20pour=20le=20serveur=20MCP=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e046bf7..f3bdd43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Abonnement payant à colibre : période d'essai gratuite, souscription et gestion du moyen de paiement, résiliation, historique de facturation ([#90](https://github.com/ColinMaudry/colibre/issues/90)) - Sauvegarde de vues personnalisées (filtres, tris, colonnes) dans la page Tableau, réservée aux abonné·es ([#95](https://github.com/ColinMaudry/colibre/issues/95)) - Vote pour prioriser les fonctionnalités de la roadmap, réservé aux abonné·es une fois leur période d'essai terminée, avec une page roadmap publique en lecture seule ([#94](https://github.com/ColinMaudry/colibre/issues/94)) +- Accès aux données de la commande publique via un serveur MCP (Model Context Protocol), pour interroger colibre directement depuis un agent IA (Claude, Cursor…) ([#111](https://github.com/ColinMaudry/colibre/issues/111)) **Autres améliorations** From 082d3e24b15d6eea5fc3d253249c6a247a7a2b5b Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 20:50:14 +0200 Subject: [PATCH 12/32] feat(mcp): activer le serveur MCP via DASH_MCP_ENABLED (scope A #111) --- .template.env | 6 ++++++ src/app.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/.template.env b/.template.env index 5ebef94..27ef161 100644 --- a/.template.env +++ b/.template.env @@ -72,3 +72,9 @@ FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks # Accès gratuit temporaire TOUS_ABONNES=false # true = ouvre gratuitement les fonctionnalités d'abonné à tout compte + +# Serveur MCP (issue #111). Laisser à false tant que l'authentification +# (OAuth + gate abonnement, scope B) n'est pas en place : sinon le serveur MCP +# est ouvert sans contrôle d'accès. Prérequis Matomo pour le tracking des appels : +# créer un Custom Dimension slot 1 (scope Action). +DASH_MCP_ENABLED=false diff --git a/src/app.py b/src/app.py index fdcf7a9..d04a342 100644 --- a/src/app.py +++ b/src/app.py @@ -74,6 +74,8 @@ cache.init_app( }, ) +_mcp_enabled = os.getenv("DASH_MCP_ENABLED") == "true" + app: Dash = Dash( server=server, # name="src" (et non "src.app") pour que use_pages enregistre les pages sous @@ -87,6 +89,7 @@ app: Dash = Dash( use_pages=True, suppress_callback_exceptions=True, compress=True, + enable_mcp=_mcp_enabled, meta_tags=META_TAGS, ) @@ -108,6 +111,19 @@ from src.api import init_api # noqa: E402 # inline: src.db.conn must be ready init_api(app.server) +# Serveur MCP (issue #111, scope A) : n'expose QUE les fonctions @mcp_enabled, +# jamais les callbacks/layout/pages d'UI. Activé via DASH_MCP_ENABLED=true. +if _mcp_enabled: + from dash.mcp import configure_mcp_server # noqa: E402 + + configure_mcp_server( + include_layout=False, + include_callbacks=False, + include_pages=False, + include_clientside_callbacks=False, + ) + import src.mcp.tools # noqa: E402,F401 # l'import enregistre les @mcp_enabled + from src.subscriptions.setup import init_subscriptions # noqa: E402 init_subscriptions(app.server) From 24bc1c08c1e9bd1a53228e6fdb5d37f80da237be Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 21:43:26 +0200 Subject: [PATCH 13/32] =?UTF-8?q?test(mcp):=20combler=20les=20lacunes=20de?= =?UTF-8?q?=20couverture=20relev=C3=A9es=20par=20la=20revue=20finale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute des tests de bout en bout pour les filtres montant/date/cpv, le chemin titulaire de search_organisations, et la pagination (page>1, clamping page=0). Normalise montant_total en float dans les deux branches de compute_org_stats. Co-Authored-By: Claude Sonnet 5 --- src/mcp/queries.py | 2 +- tests/mcp/test_queries.py | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/mcp/queries.py b/src/mcp/queries.py index 2716363..14833d3 100644 --- a/src/mcp/queries.py +++ b/src/mcp/queries.py @@ -175,7 +175,7 @@ def compute_org_stats(org_type: str, org_id: str) -> dict: return { "identite": identite, "nb_marches": 0, - "montant_total": 0, + "montant_total": 0.0, "repartition_annuelle": [], f"top_{other}s": [], "top_cpv": [], diff --git a/tests/mcp/test_queries.py b/tests/mcp/test_queries.py index 83605b6..6dafde9 100644 --- a/tests/mcp/test_queries.py +++ b/tests/mcp/test_queries.py @@ -39,6 +39,19 @@ def test_search_organisations_finds_known_acheteur(): assert "<" not in first["nom"] # Défense : aucun markup HTML ne s'échappe +def test_search_organisations_finds_known_titulaire(): + result = search_organisations("TITULAIRE", "titulaire") + assert any(r["id"] == "345" for r in result) + first = next(r for r in result if r["id"] == "345") + assert set(first.keys()) == {"id", "nom", "departement"} + # Vérifier que le nom a été extrait en texte plain (HTML strippé), + # même chemin SIRET/non-SIRET différent de celui du test acheteur. + assert first["id"] == "345" + assert first["nom"] == "TITULAIRE 1" + assert "<" not in first["id"] + assert "<" not in first["nom"] + + def test_search_organisations_invalid_type_raises(): with pytest.raises(ValueError): search_organisations("x", "autre") @@ -87,6 +100,34 @@ def test_search_marches_bad_filter_returns_error(): assert "error" in result +def test_search_marches_montant_min_filters_correctly(): + assert search_marches(montant_min=100)["meta"]["total"] == 0 + assert search_marches(montant_min=1)["meta"]["total"] >= 1 + + +def test_search_marches_date_min_max_filters_correctly(): + assert search_marches(date_min="2025-01-01")["meta"]["total"] >= 1 + assert search_marches(date_min="2025-01-02")["meta"]["total"] == 0 + assert search_marches(date_max="2025-01-01")["meta"]["total"] >= 1 + assert search_marches(date_max="2024-12-31")["meta"]["total"] == 0 + + +def test_search_marches_cpv_filters_correctly(): + assert search_marches(cpv="716")["meta"]["total"] >= 1 + assert search_marches(cpv="999")["meta"]["total"] == 0 + + +def test_search_marches_page_2_is_empty_with_correct_meta(): + result = search_marches(acheteur_id="123", page=2) + assert result["meta"]["page"] == 2 + assert result["marches"] == [] + + +def test_search_marches_page_zero_is_clamped_to_one(): + result = search_marches(acheteur_id="123", page=0) + assert result["meta"]["page"] == 1 + + def test_compute_org_stats_acheteur_known(): stats = compute_org_stats("acheteur", "123") assert stats["nb_marches"] >= 1 From 41236df0fefb23304578515260250731a5dfb033 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 22:45:41 +0200 Subject: [PATCH 14/32] docs(tableau): spec de design migration AG Grid (Lot 1, #41) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-09-migration-ag-grid-design.md | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-09-migration-ag-grid-design.md diff --git a/docs/superpowers/specs/2026-07-09-migration-ag-grid-design.md b/docs/superpowers/specs/2026-07-09-migration-ag-grid-design.md new file mode 100644 index 0000000..e8b921a --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-migration-ag-grid-design.md @@ -0,0 +1,127 @@ +# Migration des tables vers Dash AG Grid — Design (Lot 1 : `tableau.py`) + +- **Issues** : #41 (migration AG Grid), #97 (requêtes booléennes, abonnés), #112 (partage de vues abonnés par URL courte) +- **Date** : 2026-07-09 +- **Décision d'archi de référence** : commentaire sur #41 + +## Contexte + +Les tables de l'application reposent sur `dash_table.DataTable`, que Plotly abandonne au profit d'AG Grid. `colibre` a fortement personnalisé ses DataTable et les utilise sur 6 emplacements. La page vitrine `tableau.py` est la plus riche : paging / filtre / tri **server-side** sur DuckDB (~1,5 M lignes), partage de vue par URL, vues sauvegardées (abonnés), export Excel, sélecteur de colonnes, persistance, tooltips d'en-tête, liens dans les cellules. + +Cette migration prépare une **version majeure** (pas de rétro-compatibilité) et doit rendre implémentable #97 (requêtes booléennes OR/AND/NOT + parenthèses, réservé aux abonnés). + +## Objectifs + +1. **Préserver les fonctionnalités existantes** de `tableau.py` en passant de `DataTable` à `dag.AgGrid`. +2. **Conserver l'apparence de base d'AG Grid** dans un premier temps — le portage de nos overrides CSS (polices Inter, largeurs de colonnes conditionnelles, tailles) est **reporté** au 2e temps. +3. **Poser le moteur de requête** (AST booléen → SQL DuckDB) comme socle canonique, pour que #97 soit une extension incrémentale. + +## Non-objectifs (reportés) + +- Portage des overrides CSS des DataTable (2e temps). +- Migration des autres pages : `acheteur.py`, `titulaire.py`, `observatoire.py`, `recherche.py`, `admin/liste.py`, `figures.make_table` (Lots 2 et 3). +- UI du champ de requête booléenne avancée #97 (le moteur AST est posé ici, l'UI vient ensuite). +- Partage de vue par URL courte `?vue=_` (#112). + +## Décisions d'architecture (rappel) + +- **AG Grid = grille d'affichage.** En row model server-side, c'est notre callback Dash qui compile le filtre en SQL ; on ne dépend pas de la puissance de filtrage d'AG Grid. +- **Infinite Row Model** pour `tableau.py` (`rowModelType="infinite"`). +- **Modèle canonique = AST booléen** (`AND`/`OR`/`NOT` + groupement ; feuilles = `colonne op valeur`), compilé en SQL DuckDB paramétré. +- **Deux producteurs** alimentent le même AST : (1) filtres de colonne AG Grid (gratuit, comportement actuel préservé), (2) champ de requête texte inter-colonnes (#97, abonnés — UI reportée). +- **Pas de rétro-compat** : l'encodage riche de vue dans l'URL (`?filtres/tris/colonnes` en DSL DataTable) est **retiré**. Le partage passera par les vues sauvegardées (#112). +- **Pas d'AG Grid Enterprise.** + +## Architecture cible (Lot 1) + +### Flux de données + +``` +AG Grid (infinite) + │ getRowsRequest = {startRow, endRow, filterModel, sortModel} + ▼ +callback Dash `get_rows_tableau` + │ filterModel ──► filtermodel_to_ast() ──► AST + │ AST ──► ast_to_sql() ──► (where_sql, params) + │ sortModel ──► sort_model_to_sql() + ▼ +DuckDB (via _fetch_page_sql, réutilisé/adapté) ──► page + total + ▼ +getRowsResponse = {rowData, rowCount} +``` + +### Moteur de requête — nouveau module `src/utils/query_ast.py` + +Représentation canonique et compilateur, indépendants de l'UI : + +- **Types AST** : nœuds `And(children)`, `Or(children)`, `Not(child)`, et feuille `Condition(column, operator, value)`. +- `ast_to_sql(node, schema) -> (where_sql, params)` : compile en SQL DuckDB **paramétré**. Valide chaque `column` contre `schema.names()` (jamais de concaténation de valeur utilisateur — même garantie que l'actuel `filter_query_to_sql`). +- Les **feuilles texte** réutilisent la logique de `tokenize_text_filter` (`src/utils/table_sql.py`) : insensible casse/accents, wildcards `*`, phrases `+`, multi-mots en `AND`. Les feuilles numériques/date réutilisent la logique de typage de `filter_query_to_sql`. + +Deux traducteurs (producteurs) vers l'AST : + +- `filtermodel_to_ast(filter_model, schema) -> node` : convertit le `filterModel` d'AG Grid (`agTextColumnFilter`, `agNumberColumnFilter`, `agDateColumnFilter`, avec `operator: AND/OR` + `condition1/condition2`) en AST. Colonnes combinées en `And`. +- _(reporté #97)_ `query_string_to_ast(text, schema) -> node` : parseur de la syntaxe FR `(béton OR ciment) AND brique AND NOT démolition`. Non implémenté au Lot 1, mais l'AST est prêt à le recevoir. + +> On **retire** l'ancien DSL `{col} icontains valeur && …` de `tableau.py` : `filter_query_to_sql` et le JS `clean_filters` (`src/assets/dash_clientside.js`) ne sont plus utilisés par cette page. On les conserve tant que les autres pages (Lots 2/3) s'en servent, puis on les supprime au dernier lot. + +### Composant grille — `src/figures.py` + +Nouvelle fabrique `ag_grid(...)` (à côté de la classe `DataTable`, qui reste pour les pages non encore migrées) : + +- `dag.AgGrid(rowModelType="infinite", ...)`. +- `columnDefs` dérivés de `schema` : `field`, `headerName`, `filter` par type (`agTextColumnFilter` / `agNumberColumnFilter` / `agDateColumnFilter`), `floatingFilter: True`, `headerTooltip` = définition de la colonne (remplace `tooltip_header`), `hide` selon les colonnes masquées. +- Cellules à liens (`marche` 🔍, `acheteur_nom`/`titulaire_nom` avec liens détail + 📊, `uid`, ressource) : `cellRenderer: "markdown"` + `dangerously_allow_code=True` sur la grille → le HTML `` produit par `postprocess_page`/`add_links` se rend tel quel. `linkTarget: "_blank"` au besoin. +- `dashGridOptions` : `cacheBlockSize` = taille de page (20), `maxBlocksInCache`, `rowBuffer`, `pagination`/`paginationAutoPageSize` selon l'UX voulue. +- **Apparence de base** : pas de thème custom au Lot 1 (thème AG Grid par défaut). + +### Persistance + +- `persistence=True`, `persistence_type="local"`, `persisted_props=["filterModel", "columnState"]` — remplace la persistance actuelle (`filter_query`, `sort_by`). Les tris et la visibilité des colonnes vivent dans `columnState`. + +### Réécriture des callbacks `tableau.py` + +- **Remplacé** : le callback `update_table` (Inputs `page_current/page_size/filter_query/sort_by`) devient `get_rows_tableau` (Input `getRowsRequest` → Output `getRowsResponse`). +- **Sélecteur de colonnes** : les callbacks colonnes pilotent désormais `columnDefs`/`columnState` (`hide`) au lieu de `hidden_columns`. `make_column_picker`, `get_default_hidden_columns`, `invert_columns` réutilisés. +- **Export Excel** (`download_data`) : recompile le filtre via l'AST (à partir du `filterModel` courant, exposé en `State`). **Recommandé** : compiler l'AST en SQL et récupérer les lignes filtrées/triées depuis DuckDB, puis `write_styled_excel` — unifie le chemin de données et évite un second compilateur (AST→Polars). L'actuel export passe par Polars (`filter_table_data`) ; ce point est listé dans « Questions ouvertes ». +- **nb_rows / hint téléchargement** : dérivés du `rowCount` et du total (seuil 65 000 lignes conservé). +- **Vues sauvegardées (abonnés)** : `saved_views` stocke désormais l'AST (JSON) + `columnState`, au lieu de la query DSL. `build_view_query` / `restore_view_from_url` remplacés par une sérialisation AST. Le _rappel_ de vue reste ; le _partage par URL riche_ est retiré. +- **Retiré** : `restore_view_from_url` (partie `?filtres/tris/colonnes`), `sync_url_and_reset_button` (URL riche), bouton « Partager la vue » (revient avec #112), `clean_filters` clientside. +- **Conservé** : mode d'emploi (à réécrire pour la nouvelle UX de filtres AG Grid), bouton Réinitialiser, `track_search`. + +### Mode d'emploi + +Le `dcc.Markdown` d'aide et les **liens d'exemple** codés en dur (qui encodent l'ancien DSL dans l'URL) sont **réécrits** pour décrire les filtres de colonne AG Grid. Les exemples « voirie < 40 k€ » / « clause sociale PME » sont retirés ou reformulés (plus d'URL riche). + +## Dépendances + +- Ajouter `dash-ag-grid` (non installé actuellement) : `uv add dash-ag-grid`. Version alignée sur Dash 3.4 (dash-ag-grid 35.x). Vérifier la compatibilité au moment de l'ajout. + +## Cas limites & erreurs + +- `getRowsRequest is None` → `no_update`. +- `filterModel` vide → AST vide → `where = TRUE`. +- Colonne inconnue dans un filtre → ignorée + `logger.warning` (parité avec l'actuel). +- Valeur numérique/date invalide → ignorée + warning. +- `rowCount` = 0 → la grille affiche « aucune ligne » (gérer le total à 0 sans casser la pagination). +- Sécurité : identifiants de colonnes validés contre le schéma, valeurs toujours paramétrées (jamais concaténées). + +## Tests + +- **Unitaires `query_ast.py`** : `ast_to_sql` (feuilles texte accent-insensitive, wildcard `*`, phrase `+`, numérique `=/>/<`, date ; `And/Or/Not` ; groupement). Réutiliser/adapter les cas de `tests/test_table.py` (ex. `test_filter_table_data_accent_insensitive`). +- **Unitaires `filtermodel_to_ast`** : chaque type de filtre AG Grid + `operator AND/OR` + `condition1/condition2`. +- **Parité SQL** : un `filterModel` simple doit produire le même résultat que l'ancien DSL équivalent (non-régression). +- **Intégration (Selenium/DashComposite)** : chargement de la grille, filtre de colonne, tri, pagination, sélecteur de colonnes, export Excel, persistance locale, vue sauvegardée (abonné). +- Suite complète `uv run pytest` uniquement en fin de lot. + +## Questions ouvertes à trancher + +1. **UX de pagination** : garder des **pages numérotées** (20 lignes/page, comme aujourd'hui, via `pagination=True` sur l'infinite row model) ou passer au **scroll infini** (chargement continu au défilement) ? Défaut proposé : pages numérotées, plus proche de l'existant. +2. **Chemin de l'export Excel** : passer l'export sur **DuckDB** (AST→SQL, cohérent avec la grille — recommandé) ou conserver le pipeline **Polars** actuel en lui branchant un compilateur AST→Polars ? + +## Reporté au 2e temps / lots suivants + +- Portage des overrides CSS (apparence). +- #97 : `query_string_to_ast` + champ de requête avancé (abonnés). +- #112 : partage de vue `?vue=_`. +- Migration des Lots 2 (`acheteur`/`titulaire`/`observatoire`) et 3 (`recherche`/`admin`/`figures.make_table`), puis suppression de l'ancien DSL (`filter_query_to_sql`, `clean_filters`). From 91d3153e10af1bc34a42d880335a67a2cb3d6800 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 22:52:03 +0200 Subject: [PATCH 15/32] docs(tableau): trancher scroll infini + export DuckDB dans le spec (#41) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-09-migration-ag-grid-design.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-07-09-migration-ag-grid-design.md b/docs/superpowers/specs/2026-07-09-migration-ag-grid-design.md index e8b921a..0fe5f22 100644 --- a/docs/superpowers/specs/2026-07-09-migration-ag-grid-design.md +++ b/docs/superpowers/specs/2026-07-09-migration-ag-grid-design.md @@ -72,7 +72,8 @@ Nouvelle fabrique `ag_grid(...)` (à côté de la classe `DataTable`, qui reste - `dag.AgGrid(rowModelType="infinite", ...)`. - `columnDefs` dérivés de `schema` : `field`, `headerName`, `filter` par type (`agTextColumnFilter` / `agNumberColumnFilter` / `agDateColumnFilter`), `floatingFilter: True`, `headerTooltip` = définition de la colonne (remplace `tooltip_header`), `hide` selon les colonnes masquées. - Cellules à liens (`marche` 🔍, `acheteur_nom`/`titulaire_nom` avec liens détail + 📊, `uid`, ressource) : `cellRenderer: "markdown"` + `dangerously_allow_code=True` sur la grille → le HTML `` produit par `postprocess_page`/`add_links` se rend tel quel. `linkTarget: "_blank"` au besoin. -- `dashGridOptions` : `cacheBlockSize` = taille de page (20), `maxBlocksInCache`, `rowBuffer`, `pagination`/`paginationAutoPageSize` selon l'UX voulue. +- **Scroll infini** (décidé) : pas de pagination numérotée. Grille à **hauteur fixe** (ex. `calc(100vh - …)`) avec scroll interne → **en-têtes toujours figés**, virtualisation des lignes (seules les lignes visibles + buffer sont rendues), chargement des blocs à la volée. Ne PAS utiliser `domLayout: "autoHeight"` (incompatible avec l'infinite row model). +- `dashGridOptions` : `cacheBlockSize` (taille de bloc serveur, ex. 100), `maxBlocksInCache`, `rowBuffer`, `infiniteInitialRowCount`. Optionnel : épingler à gauche les colonnes-clés (lien 🔍 marché, acheteur) via `pinned: "left"` pour rester visibles au scroll horizontal. - **Apparence de base** : pas de thème custom au Lot 1 (thème AG Grid par défaut). ### Persistance @@ -83,7 +84,7 @@ Nouvelle fabrique `ag_grid(...)` (à côté de la classe `DataTable`, qui reste - **Remplacé** : le callback `update_table` (Inputs `page_current/page_size/filter_query/sort_by`) devient `get_rows_tableau` (Input `getRowsRequest` → Output `getRowsResponse`). - **Sélecteur de colonnes** : les callbacks colonnes pilotent désormais `columnDefs`/`columnState` (`hide`) au lieu de `hidden_columns`. `make_column_picker`, `get_default_hidden_columns`, `invert_columns` réutilisés. -- **Export Excel** (`download_data`) : recompile le filtre via l'AST (à partir du `filterModel` courant, exposé en `State`). **Recommandé** : compiler l'AST en SQL et récupérer les lignes filtrées/triées depuis DuckDB, puis `write_styled_excel` — unifie le chemin de données et évite un second compilateur (AST→Polars). L'actuel export passe par Polars (`filter_table_data`) ; ce point est listé dans « Questions ouvertes ». +- **Export Excel** (`download_data`) — chemin **DuckDB** (décidé) : recompile le `filterModel` courant (exposé en `State`) → AST → SQL, récupère les lignes filtrées/triées depuis DuckDB (colonnes masquées exclues), puis `write_styled_excel`. Un seul compilateur (AST→SQL), même chemin de données que la grille → pas de divergence filtre-affiché / filtre-exporté. Remplace l'actuel pipeline Polars (`filter_table_data`/`sort_table_data` sur `LazyFrame`). - **nb_rows / hint téléchargement** : dérivés du `rowCount` et du total (seuil 65 000 lignes conservé). - **Vues sauvegardées (abonnés)** : `saved_views` stocke désormais l'AST (JSON) + `columnState`, au lieu de la query DSL. `build_view_query` / `restore_view_from_url` remplacés par une sérialisation AST. Le _rappel_ de vue reste ; le _partage par URL riche_ est retiré. - **Retiré** : `restore_view_from_url` (partie `?filtres/tris/colonnes`), `sync_url_and_reset_button` (URL riche), bouton « Partager la vue » (revient avec #112), `clean_filters` clientside. @@ -114,10 +115,10 @@ Le `dcc.Markdown` d'aide et les **liens d'exemple** codés en dur (qui encodent - **Intégration (Selenium/DashComposite)** : chargement de la grille, filtre de colonne, tri, pagination, sélecteur de colonnes, export Excel, persistance locale, vue sauvegardée (abonné). - Suite complète `uv run pytest` uniquement en fin de lot. -## Questions ouvertes à trancher +## Décisions tranchées -1. **UX de pagination** : garder des **pages numérotées** (20 lignes/page, comme aujourd'hui, via `pagination=True` sur l'infinite row model) ou passer au **scroll infini** (chargement continu au défilement) ? Défaut proposé : pages numérotées, plus proche de l'existant. -2. **Chemin de l'export Excel** : passer l'export sur **DuckDB** (AST→SQL, cohérent avec la grille — recommandé) ou conserver le pipeline **Polars** actuel en lui branchant un compilateur AST→Polars ? +- **Pagination** : scroll infini (grille à hauteur fixe, en-têtes figés, virtualisation). +- **Export Excel** : chemin DuckDB (AST → SQL), pipeline Polars retiré. ## Reporté au 2e temps / lots suivants From 74c5de0bda791c309350eda0f1959cd17d45c083 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 07:27:30 +0200 Subject: [PATCH 16/32] =?UTF-8?q?docs(tableau):=20plan=20d'impl=C3=A9menta?= =?UTF-8?q?tion=20migration=20AG=20Grid=20Lot=201=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-10-migration-ag-grid-tableau.md | 1335 +++++++++++++++++ 1 file changed, 1335 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-migration-ag-grid-tableau.md diff --git a/docs/superpowers/plans/2026-07-10-migration-ag-grid-tableau.md b/docs/superpowers/plans/2026-07-10-migration-ag-grid-tableau.md new file mode 100644 index 0000000..ffee9aa --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-migration-ag-grid-tableau.md @@ -0,0 +1,1335 @@ +# Migration AG Grid — `tableau.py` (Lot 1) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remplacer la `DataTable` de la page `/tableau` par une grille `dash-ag-grid` (scroll infini, server-side) en préservant toutes les fonctionnalités, avec un moteur de requête AST → SQL DuckDB comme socle canonique. + +**Architecture:** AG Grid n'est qu'une grille d'affichage en `rowModelType="infinite"` ; c'est un callback Dash qui reçoit `getRowsRequest` (filterModel + sortModel), le compile via un AST booléen en SQL DuckDB paramétré, et renvoie `getRowsResponse`. Le même AST alimente l'export Excel et les vues sauvegardées. Le DSL de filtre historique et l'URL riche sont retirés. + +**Tech Stack:** Dash 3.4, dash-ag-grid, DuckDB, Polars, flask-caching, SQLite (vues), pytest + DashComposite/Selenium. + +**Spec de référence:** `docs/superpowers/specs/2026-07-09-migration-ag-grid-design.md` + +## Global Constraints + +- Périmètre = **`tableau.py` uniquement**. Ne pas toucher `acheteur.py`, `titulaire.py`, `observatoire.py`, `recherche.py`, `admin/liste.py`, `figures.make_table`. +- Imports internes toujours préfixés `src.` (ex. `src.utils.query_ast`). +- **Apparence de base d'AG Grid** : aucun thème/override CSS custom dans ce lot. +- **Scroll infini** (pas de pagination numérotée) ; grille à hauteur fixe, jamais `domLayout: "autoHeight"`. +- **Export Excel** via DuckDB (AST→SQL), pas de pipeline Polars. +- Sécurité : identifiants de colonnes validés contre `schema.names()`, valeurs **toujours** liées via `?` (jamais concaténées). +- Lancer `pre-commit run --files ` avant chaque `git add`/commit (le hook prettier/ruff peut reformater — re-`git add` puis committer). +- Tests par fichier pendant le lot ; `uv run pytest` complet **uniquement** à la dernière tâche. +- Ne pas supprimer `filter_query_to_sql` / `clean_filters` (encore utilisés par les autres pages) — seulement les débrancher de `tableau.py`. + +**Fonctions réutilisables (ne pas réécrire) :** + +- `src/db.py` : `schema` (pl.Schema), `query_marches(where_sql, params, columns, order_by, limit, offset)`, `count_marches(where_sql, params)`, `count_unique_marches(where_sql, params)`. +- `src/utils/table_sql.py` : `tokenize_text_filter(column, text, col_is_date=False) -> (where_clause, params)`, `sort_by_to_sql(sort_by, schema) -> str`. +- `src/utils/table.py` : `postprocess_page(dff) -> pl.DataFrame`, `setup_table_columns(dff, hideable, exclude) -> (columns, tooltip)`, `get_default_hidden_columns(page) -> list`, `invert_columns(columns) -> list`, `write_styled_excel(df, buffer, worksheet="DECP")`, `COLUMNS` (= `schema.names()`). +- `src/figures.py` : `make_column_picker(page)`, `DATA_SCHEMA` (dict `{col: {title, description, type, name}}`). +- `src/saved_views/db.py` : `list_views(user_id, table_name)`, `get(view_id, user_id)`, `upsert(user_id, table_name, name, query)`. +- `src/pages/_compte_shell.py` : `current_user_has_subscription()`. + +--- + +## File Structure + +- **Create** `src/utils/query_ast.py` — types AST (`Condition/And/Or/Not`), `ast_to_sql`, `filtermodel_to_ast`, `sort_model_to_sql`, sérialisation `ast_to_dict`/`ast_from_dict`. +- **Create** `src/utils/grid.py` — `fetch_grid_page(...)` (colle filterModel/sortModel → SQL → page + total) et `grid_column_defs(...)`. +- **Create** `tests/test_query_ast.py`, `tests/test_grid.py`. +- **Modify** `src/figures.py` — ajouter la fabrique `ag_grid(...)`. +- **Modify** `src/pages/tableau.py` — layout + callbacks réécrits. +- **Modify** `src/saved_views/*` si besoin (le champ `query` stocke désormais un JSON). +- **Modify** `pyproject.toml` / `uv.lock` — dépendance `dash-ag-grid`. +- **Modify** `src/assets/dash_clientside.js` — retirer l'usage `clean_filters` côté tableau (garder la fonction pour les autres pages). + +--- + +## Task 1: Ajouter la dépendance `dash-ag-grid` + +**Files:** + +- Modify: `pyproject.toml`, `uv.lock` + +- [ ] **Step 1: Ajouter la dépendance** + +Run: `uv add dash-ag-grid` +Expected: `pyproject.toml` gagne `dash-ag-grid` dans `dependencies`, `uv.lock` mis à jour. + +- [ ] **Step 2: Vérifier l'import et la version** + +Run: `uv run python -c "import dash_ag_grid as dag; print(dag.__version__)"` +Expected: une version s'affiche (35.x attendu), pas d'erreur. + +- [ ] **Step 3: Vérifier la compatibilité Dash (démarrage app)** + +Run: `uv run python -c "import dash_ag_grid; from src import app; print('ok')"` +Expected: `ok` (pas de conflit de version Dash au chargement). + +- [ ] **Step 4: Commit** + +```bash +pre-commit run --files pyproject.toml uv.lock +git add pyproject.toml uv.lock +git commit -m "build: ajouter la dépendance dash-ag-grid (#41)" +``` + +--- + +## Task 2: Types AST + `ast_to_sql` + +Compilateur canonique AST → SQL DuckDB paramétré. Réutilise `tokenize_text_filter` pour les feuilles texte (insensibilité casse/accents, `*`, `+`, multi-mots) ; mirroir de la logique numérique/date de `filter_query_to_sql`. + +**Files:** + +- Create: `src/utils/query_ast.py` +- Test: `tests/test_query_ast.py` + +**Interfaces:** + +- Produces: + + - `Condition(column: str, operator: str, value=None, value2=None)` — operators : `"contains"`, `"notContains"`, `"eq"`, `"neq"`, `"gt"`, `"gte"`, `"lt"`, `"lte"`, `"range"`, `"blank"`, `"notBlank"`. + - `And(children: list)`, `Or(children: list)`, `Not(child)`. + - `ast_to_sql(node, schema) -> tuple[str, list]` — renvoie `(where_sql, params)`. `node=None` ou `And([])` → `("TRUE", [])`. + +- [ ] **Step 1: Écrire les tests qui échouent** + +```python +# tests/test_query_ast.py +import polars as pl +import pytest +from src.utils.query_ast import Condition, And, Or, Not, ast_to_sql + +SCHEMA = pl.Schema( + { + "acheteur_nom": pl.String, + "objet": pl.String, + "montant": pl.Float64, + "dureeMois": pl.Int64, + "dateNotification": pl.Date, + } +) + + +def _run(node): + """Compile et retourne (sql, params).""" + return ast_to_sql(node, SCHEMA) + + +def test_none_is_true(): + assert _run(None) == ("TRUE", []) + + +def test_empty_and_is_true(): + assert _run(And([])) == ("TRUE", []) + + +def test_text_contains_uses_ilike_and_params(): + sql, params = _run(Condition("objet", "contains", "voirie")) + assert "ILIKE ?" in sql + assert params == ["%voirie%"] + + +def test_text_contains_multiword_is_and(): + sql, params = _run(Condition("objet", "contains", "metropole rennes")) + assert sql.count("ILIKE ?") == 2 + assert params == ["%metropole%", "%rennes%"] + + +def test_text_contains_wildcard_and_phrase(): + _, params = _run(Condition("objet", "contains", "distri* metropole+rennes")) + assert params == ["distri%", "%metropole rennes%"] + + +def test_text_notcontains_negates(): + sql, params = _run(Condition("objet", "notContains", "construction")) + assert "NOT (" in sql + assert params == ["%construction%"] + + +def test_numeric_gt(): + sql, params = _run(Condition("montant", "gt", 40000)) + assert '"montant" > ?' in sql + assert params == [40000.0] + + +def test_numeric_eq_int_column(): + sql, params = _run(Condition("dureeMois", "eq", "12")) + assert '"dureeMois" = ?' in sql + assert params == [12] + + +def test_numeric_range(): + sql, params = _run(Condition("montant", "range", 100, 200)) + assert params == [100.0, 200.0] + assert "BETWEEN" in sql or ("> ?" in sql and "< ?" in sql) + + +def test_numeric_invalid_value_is_true(): + # valeur non numérique -> condition neutralisée (TRUE), pas d'exception + assert _run(Condition("montant", "gt", "abc")) == ("TRUE", []) + + +def test_date_gt_casts_varchar(): + sql, params = _run(Condition("dateNotification", "gt", "2022")) + assert "VARCHAR" in sql + assert params == ["2022"] + + +def test_blank_and_notblank(): + sql_b, _ = _run(Condition("objet", "blank")) + assert "IS NULL" in sql_b + sql_nb, _ = _run(Condition("objet", "notBlank")) + assert "IS NOT NULL" in sql_nb + + +def test_unknown_column_is_true(): + assert _run(Condition("colonne_inexistante", "contains", "x")) == ("TRUE", []) + + +def test_and_or_not_grouping(): + node = And( + [ + Or([Condition("objet", "contains", "beton"), Condition("objet", "contains", "ciment")]), + Not(Condition("objet", "contains", "demolition")), + ] + ) + sql, params = _run(node) + assert " OR " in sql and " AND " in sql and "NOT (" in sql + assert params == ["%beton%", "%ciment%", "%demolition%"] +``` + +- [ ] **Step 2: Lancer les tests (échec attendu)** + +Run: `uv run pytest tests/test_query_ast.py -q` +Expected: FAIL (`ModuleNotFoundError: src.utils.query_ast`). + +- [ ] **Step 3: Écrire l'implémentation** + +```python +# src/utils/query_ast.py +"""Représentation canonique d'un filtre (AST booléen) et compilation en SQL DuckDB. + +Ce module est indépendant de l'UI : plusieurs producteurs (filtres de colonne +AG Grid, futur champ de requête booléenne #97) construisent le même AST, compilé +ici en SQL paramétré. Les identifiants de colonnes sont validés contre le schéma ; +les valeurs passent toujours par le binding `?` (jamais concaténées). +""" + +from dataclasses import dataclass + +import polars as pl + +from src.utils import logger +from src.utils.table_sql import tokenize_text_filter + + +@dataclass +class Condition: + column: str + operator: str + value: object = None + value2: object = None + + +@dataclass +class And: + children: list + + +@dataclass +class Or: + children: list + + +@dataclass +class Not: + child: object + + +Node = object # Condition | And | Or | Not | None + + +def ast_to_sql(node, schema: pl.Schema) -> tuple[str, list]: + """Compile un AST en (where_sql, params). Nœud neutre -> ('TRUE', []).""" + if node is None: + return "TRUE", [] + if isinstance(node, And): + return _join(node.children, "AND", schema) + if isinstance(node, Or): + return _join(node.children, "OR", schema) + if isinstance(node, Not): + sql, params = ast_to_sql(node.child, schema) + if sql == "TRUE": + return "TRUE", [] + return f"NOT ({sql})", params + if isinstance(node, Condition): + return _condition_to_sql(node, schema) + logger.warning(f"Nœud AST inconnu ignoré : {node!r}") + return "TRUE", [] + + +def _join(children, op: str, schema: pl.Schema) -> tuple[str, list]: + fragments: list[str] = [] + params: list = [] + for child in children: + sql, child_params = ast_to_sql(child, schema) + if sql == "TRUE": + continue + fragments.append(f"({sql})") + params.extend(child_params) + if not fragments: + return "TRUE", [] + return f" {op} ".join(fragments), params + + +def _condition_to_sql(cond: Condition, schema: pl.Schema) -> tuple[str, list]: + col = cond.column + if col not in schema.names(): + logger.warning(f"Colonne inconnue ignorée : {col!r}") + return "TRUE", [] + + col_type = schema[col] + quoted = f'"{col}"' + + if cond.operator == "blank": + return f"({quoted} IS NULL OR {quoted} = '')", [] + if cond.operator == "notBlank": + return f"({quoted} IS NOT NULL AND {quoted} <> '')", [] + + is_numeric = col_type.is_numeric() + col_is_date = col_type == pl.Date + + if is_numeric: + return _numeric_to_sql(cond, col_type, quoted) + + # texte / date : traité comme texte (parité avec l'existant) + if cond.operator == "contains": + return tokenize_text_filter(col, str(cond.value), col_is_date) + if cond.operator == "notContains": + where, params = tokenize_text_filter(col, str(cond.value), col_is_date) + return f"NOT ({where})", params + + target = f'CAST({quoted} AS VARCHAR)' if col_is_date else quoted + op_map = {"eq": "=", "neq": "<>", "gt": ">", "gte": ">=", "lt": "<", "lte": "<="} + if cond.operator in op_map: + return f"{quoted} IS NOT NULL AND {target} {op_map[cond.operator]} ?", [str(cond.value)] + if cond.operator == "startsWith": + return f"{quoted} ILIKE ?", [f"{cond.value}%"] + if cond.operator == "endsWith": + return f"{quoted} ILIKE ?", [f"%{cond.value}"] + logger.warning(f"Opérateur texte invalide : {cond.operator!r}") + return "TRUE", [] + + +def _coerce_number(value, col_type): + try: + return int(value) if col_type.is_integer() else float(value) + except (TypeError, ValueError): + logger.warning(f"Valeur numérique invalide ignorée : {value!r}") + return None + + +def _numeric_to_sql(cond: Condition, col_type, quoted: str) -> tuple[str, list]: + op_map = {"eq": "=", "neq": "<>", "gt": ">", "gte": ">=", "lt": "<", "lte": "<="} + if cond.operator in op_map: + v = _coerce_number(cond.value, col_type) + if v is None: + return "TRUE", [] + return f"{quoted} IS NOT NULL AND {quoted} {op_map[cond.operator]} ?", [v] + if cond.operator == "range": + v1 = _coerce_number(cond.value, col_type) + v2 = _coerce_number(cond.value2, col_type) + if v1 is None or v2 is None: + return "TRUE", [] + return f"{quoted} BETWEEN ? AND ?", [v1, v2] + logger.warning(f"Opérateur numérique invalide : {cond.operator!r}") + return "TRUE", [] +``` + +- [ ] **Step 4: Lancer les tests (succès attendu)** + +Run: `uv run pytest tests/test_query_ast.py -q` +Expected: PASS (tous). + +- [ ] **Step 5: Commit** + +```bash +pre-commit run --files src/utils/query_ast.py tests/test_query_ast.py +git add src/utils/query_ast.py tests/test_query_ast.py +git commit -m "feat(query): AST de filtre + compilateur ast_to_sql (#41)" +``` + +--- + +## Task 3: `filtermodel_to_ast` + +Traduit le `filterModel` d'AG Grid en AST. Chaque colonne devient une (ou deux) `Condition`, combinées entre colonnes par `And`. Une colonne à deux conditions utilise `operator` `AND`/`OR`. + +**Files:** + +- Modify: `src/utils/query_ast.py` +- Test: `tests/test_query_ast.py` + +**Interfaces:** + +- Produces: `filtermodel_to_ast(filter_model: dict | None, schema) -> Node` (renvoie `And([...])` ou `None` si vide). + +- [ ] **Step 1: Ajouter les tests qui échouent** + +```python +# tests/test_query_ast.py (append) +from src.utils.query_ast import filtermodel_to_ast, ast_to_sql + + +def test_filtermodel_empty_is_none(): + assert filtermodel_to_ast(None, SCHEMA) is None + assert filtermodel_to_ast({}, SCHEMA) is None + + +def test_filtermodel_text_contains(): + fm = {"objet": {"filterType": "text", "type": "contains", "filter": "voirie"}} + _, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA) + assert params == ["%voirie%"] + + +def test_filtermodel_number_greaterthan(): + fm = {"montant": {"filterType": "number", "type": "greaterThan", "filter": 40000}} + sql, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA) + assert '"montant"' in sql and params == [40000.0] + + +def test_filtermodel_number_inrange(): + fm = {"montant": {"filterType": "number", "type": "inRange", "filter": 100, "filterTo": 200}} + _, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA) + assert params == [100.0, 200.0] + + +def test_filtermodel_date_uses_datefrom(): + fm = {"dateNotification": {"filterType": "date", "type": "greaterThan", "dateFrom": "2022-01-01"}} + _, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA) + assert params == ["2022-01-01"] + + +def test_filtermodel_two_conditions_or(): + fm = { + "objet": { + "filterType": "text", + "operator": "OR", + "condition1": {"filterType": "text", "type": "contains", "filter": "beton"}, + "condition2": {"filterType": "text", "type": "contains", "filter": "ciment"}, + } + } + sql, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA) + assert " OR " in sql and params == ["%beton%", "%ciment%"] + + +def test_filtermodel_multiple_columns_are_anded(): + fm = { + "objet": {"filterType": "text", "type": "contains", "filter": "voirie"}, + "montant": {"filterType": "number", "type": "greaterThan", "filter": 1000}, + } + sql, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA) + assert " AND " in sql and set(params) == {"%voirie%", 1000.0} +``` + +- [ ] **Step 2: Lancer (échec attendu)** + +Run: `uv run pytest tests/test_query_ast.py -q -k filtermodel` +Expected: FAIL (`filtermodel_to_ast` non défini). + +- [ ] **Step 3: Implémenter** + +```python +# src/utils/query_ast.py (append) + +_TEXT_TYPE = { + "contains": "contains", + "notContains": "notContains", + "equals": "eq", + "notEqual": "neq", + "startsWith": "startsWith", + "endsWith": "endsWith", + "blank": "blank", + "notBlank": "notBlank", +} +_NUM_TYPE = { + "equals": "eq", + "notEqual": "neq", + "lessThan": "lt", + "lessThanOrEqual": "lte", + "greaterThan": "gt", + "greaterThanOrEqual": "gte", + "inRange": "range", + "blank": "blank", + "notBlank": "notBlank", +} + + +def _leaf(column: str, spec: dict): + """Convertit une condition AG Grid unitaire en Condition.""" + ftype = spec.get("filterType", "text") + ag_type = spec.get("type") + if ftype == "date": + op = _NUM_TYPE.get(ag_type) + if op == "range": + return Condition(column, "range", spec.get("dateFrom"), spec.get("dateTo")) + return Condition(column, op, spec.get("dateFrom")) if op else None + if ftype == "number": + op = _NUM_TYPE.get(ag_type) + if op == "range": + return Condition(column, "range", spec.get("filter"), spec.get("filterTo")) + return Condition(column, op, spec.get("filter")) if op else None + # texte + op = _TEXT_TYPE.get(ag_type) + return Condition(column, op, spec.get("filter")) if op else None + + +def filtermodel_to_ast(filter_model, schema): + """Traduit un filterModel AG Grid en AST. Colonnes combinées en And.""" + if not filter_model: + return None + children = [] + for column, spec in filter_model.items(): + if column not in schema.names(): + logger.warning(f"Filtre sur colonne inconnue ignoré : {column!r}") + continue + if "operator" in spec: # deux conditions + c1 = _leaf(column, spec.get("condition1", {})) + c2 = _leaf(column, spec.get("condition2", {})) + parts = [c for c in (c1, c2) if c is not None] + if not parts: + continue + node = And(parts) if spec["operator"] == "AND" else Or(parts) + else: + node = _leaf(column, spec) + if node is None: + continue + children.append(node) + return And(children) if children else None +``` + +- [ ] **Step 4: Lancer (succès attendu)** + +Run: `uv run pytest tests/test_query_ast.py -q` +Expected: PASS (tous, y compris Task 2). + +- [ ] **Step 5: Commit** + +```bash +pre-commit run --files src/utils/query_ast.py tests/test_query_ast.py +git add src/utils/query_ast.py tests/test_query_ast.py +git commit -m "feat(query): filtermodel_to_ast (filterModel AG Grid -> AST) (#41)" +``` + +--- + +## Task 4: `sort_model_to_sql` + sérialisation AST + +`sortModel` AG Grid = `[{"colId": "montant", "sort": "desc"}]`. On l'adapte vers `sort_by_to_sql` existant (qui attend `[{"column_id", "direction"}]`). On ajoute aussi la (dé)sérialisation JSON de l'AST pour les vues sauvegardées. + +**Files:** + +- Modify: `src/utils/query_ast.py` +- Test: `tests/test_query_ast.py` + +**Interfaces:** + +- Produces: + + - `sort_model_to_sql(sort_model: list | None, schema) -> str` (fragment ORDER BY, `''` si vide). + - `ast_to_dict(node) -> dict | None` / `ast_from_dict(data) -> Node` (round-trip JSON). + +- [ ] **Step 1: Ajouter les tests qui échouent** + +```python +# tests/test_query_ast.py (append) +from src.utils.query_ast import sort_model_to_sql, ast_to_dict, ast_from_dict + + +def test_sort_model_to_sql(): + sm = [{"colId": "montant", "sort": "desc"}, {"colId": "dureeMois", "sort": "asc"}] + out = sort_model_to_sql(sm, SCHEMA) + assert out == '"montant" DESC NULLS LAST, "dureeMois" ASC NULLS LAST' + + +def test_sort_model_empty(): + assert sort_model_to_sql(None, SCHEMA) == "" + assert sort_model_to_sql([], SCHEMA) == "" + + +def test_ast_dict_roundtrip(): + node = And([Or([Condition("objet", "contains", "beton")]), Not(Condition("objet", "contains", "x"))]) + restored = ast_from_dict(ast_to_dict(node)) + assert ast_to_sql(restored, SCHEMA) == ast_to_sql(node, SCHEMA) + + +def test_ast_dict_none(): + assert ast_to_dict(None) is None + assert ast_from_dict(None) is None +``` + +- [ ] **Step 2: Lancer (échec attendu)** + +Run: `uv run pytest tests/test_query_ast.py -q -k "sort_model or roundtrip or dict_none"` +Expected: FAIL. + +- [ ] **Step 3: Implémenter** + +```python +# src/utils/query_ast.py (append) +from src.utils.table_sql import sort_by_to_sql + + +def sort_model_to_sql(sort_model, schema) -> str: + if not sort_model: + return "" + sort_by = [ + {"column_id": s.get("colId"), "direction": s.get("sort")} for s in sort_model + ] + return sort_by_to_sql(sort_by, schema) + + +def ast_to_dict(node): + if node is None: + return None + if isinstance(node, Condition): + return {"t": "cond", "column": node.column, "operator": node.operator, + "value": node.value, "value2": node.value2} + if isinstance(node, And): + return {"t": "and", "children": [ast_to_dict(c) for c in node.children]} + if isinstance(node, Or): + return {"t": "or", "children": [ast_to_dict(c) for c in node.children]} + if isinstance(node, Not): + return {"t": "not", "child": ast_to_dict(node.child)} + return None + + +def ast_from_dict(data): + if data is None: + return None + t = data.get("t") + if t == "cond": + return Condition(data["column"], data["operator"], data.get("value"), data.get("value2")) + if t == "and": + return And([ast_from_dict(c) for c in data["children"]]) + if t == "or": + return Or([ast_from_dict(c) for c in data["children"]]) + if t == "not": + return Not(ast_from_dict(data["child"])) + return None +``` + +- [ ] **Step 4: Lancer (succès attendu)** + +Run: `uv run pytest tests/test_query_ast.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +pre-commit run --files src/utils/query_ast.py tests/test_query_ast.py +git add src/utils/query_ast.py tests/test_query_ast.py +git commit -m "feat(query): sort_model_to_sql + sérialisation AST (#41)" +``` + +--- + +## Task 5: `fetch_grid_page` (datasource server-side) + +Fonction pure qui prend une requête AG Grid infinite et renvoie la page + le total, en réutilisant la couche DuckDB et `postprocess_page`. C'est le cœur du callback `getRows`. + +**Files:** + +- Create: `src/utils/grid.py` +- Test: `tests/test_grid.py` + +**Interfaces:** + +- Consumes: `filtermodel_to_ast`, `ast_to_sql`, `sort_model_to_sql` (Task 2-4) ; `query_marches`, `count_marches`, `postprocess_page`. +- Produces: `fetch_grid_page(filter_model, sort_model, start_row, end_row, base_where_sql="TRUE", base_params=()) -> tuple[list[dict], int]` — `(row_data, total_count)`. + +- [ ] **Step 1: Écrire le test (utilise `tests/test.parquet` via `src.db`)** + +```python +# tests/test_grid.py +from src.utils.grid import fetch_grid_page + + +def test_fetch_grid_page_returns_rows_and_count(): + rows, total = fetch_grid_page(None, None, 0, 20) + assert isinstance(rows, list) + assert isinstance(total, int) + assert total >= len(rows) + if rows: + # postprocess_page ajoute une colonne 'marche' avec un lien + assert "marche" in rows[0] + + +def test_fetch_grid_page_filter_reduces_count(): + _, total_all = fetch_grid_page(None, None, 0, 1) + fm = {"objet": {"filterType": "text", "type": "contains", "filter": "zzzzzznope"}} + rows, total_filtered = fetch_grid_page(fm, None, 0, 20) + assert total_filtered <= total_all + assert rows == [] and total_filtered == 0 + + +def test_fetch_grid_page_offset_slicing(): + rows, _ = fetch_grid_page(None, None, 0, 5) + assert len(rows) <= 5 +``` + +> Note : `tests/test.parquet` est petit et peut ne pas contenir toutes les colonnes ; les tests ci-dessus n'assument que `objet`/`uid`. + +- [ ] **Step 2: Lancer (échec attendu)** + +Run: `uv run pytest tests/test_grid.py -q` +Expected: FAIL (`src.utils.grid` absent). + +- [ ] **Step 3: Implémenter** + +```python +# src/utils/grid.py +"""Datasource server-side pour AG Grid (infinite row model).""" + +from src.db import count_marches, query_marches, schema +from src.utils.query_ast import ast_to_sql, filtermodel_to_ast, sort_model_to_sql +from src.utils.table import postprocess_page + + +def fetch_grid_page( + filter_model, + sort_model, + start_row: int, + end_row: int, + base_where_sql: str = "TRUE", + base_params: tuple = (), +) -> tuple[list[dict], int]: + """Renvoie (row_data, total_count) pour un bloc [start_row, end_row).""" + ast = filtermodel_to_ast(filter_model, schema) + filter_sql, filter_params = ast_to_sql(ast, schema) + where_sql = f"({base_where_sql}) AND ({filter_sql})" + params = [*base_params, *filter_params] + + order_by = sort_model_to_sql(sort_model, schema) or None + total = count_marches(where_sql, params) + + limit = max(0, end_row - start_row) + page = query_marches( + where_sql=where_sql, + params=params, + order_by=order_by, + limit=limit, + offset=start_row, + ) + page = postprocess_page(page) + return page.to_dicts(), total +``` + +- [ ] **Step 4: Lancer (succès attendu)** + +Run: `uv run pytest tests/test_grid.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +pre-commit run --files src/utils/grid.py tests/test_grid.py +git add src/utils/grid.py tests/test_grid.py +git commit -m "feat(grid): fetch_grid_page datasource server-side AG Grid (#41)" +``` + +--- + +## Task 6: `grid_column_defs` + fabrique `ag_grid` + +Construit les `columnDefs` (type de filtre par colonne, headerTooltip = définition, cellRenderer markdown pour les liens, colonnes masquées) et la fabrique du composant `dag.AgGrid`. + +**Files:** + +- Modify: `src/utils/grid.py` (ajout `grid_column_defs`) +- Modify: `src/figures.py` (ajout `ag_grid`) +- Test: `tests/test_grid.py` + +**Interfaces:** + +- Consumes: `schema`, `DATA_SCHEMA` (`src/figures.py`), `get_default_hidden_columns`. +- Produces: + - `grid_column_defs(hidden_columns: list[str] | None = None) -> list[dict]`. + - `ag_grid(grid_id: str, column_defs: list[dict]) -> dag.AgGrid`. + +Mapping type de filtre (depuis `schema[col]`) : numérique → `agNumberColumnFilter` ; `pl.Date` → `agDateColumnFilter` ; sinon `agTextColumnFilter`. + +- [ ] **Step 1: Écrire le test** + +```python +# tests/test_grid.py (append) +from src.utils.grid import grid_column_defs + + +def test_column_defs_have_field_and_filter(): + defs = grid_column_defs(hidden_columns=[]) + by_field = {d["field"]: d for d in defs} + assert "objet" in by_field + # filtre texte par défaut + assert by_field["objet"]["filter"] == "agTextColumnFilter" + # montant est numérique + assert by_field["montant"]["filter"] == "agNumberColumnFilter" + # headerTooltip présent (définition de colonne) + assert "headerTooltip" in by_field["objet"] + + +def test_column_defs_hidden_flag(): + defs = grid_column_defs(hidden_columns=["objet"]) + by_field = {d["field"]: d for d in defs} + assert by_field["objet"]["hide"] is True +``` + +- [ ] **Step 2: Lancer (échec attendu)** + +Run: `uv run pytest tests/test_grid.py -q -k column_defs` +Expected: FAIL. + +- [ ] **Step 3: Implémenter `grid_column_defs`** + +```python +# src/utils/grid.py (append) +import polars as pl + +from src.figures import DATA_SCHEMA + +_LINK_COLUMNS = {"marche", "uid", "acheteur_id", "acheteur_nom", "titulaire_id", "titulaire_nom", "sourceFile"} + + +def _filter_for(col_type) -> str: + if col_type.is_numeric(): + return "agNumberColumnFilter" + if col_type == pl.Date: + return "agDateColumnFilter" + return "agTextColumnFilter" + + +def grid_column_defs(hidden_columns=None): + """columnDefs dérivés du schéma DuckDB. + + 'marche' (colonne loupe ajoutée par postprocess_page) est placée en tête. + """ + hidden = set(hidden_columns or []) + defs = [ + { + "field": "marche", + "headerName": "", + "cellRenderer": "markdown", + "filter": False, + "sortable": False, + "maxWidth": 60, + "pinned": "left", + } + ] + for col in schema.names(): + meta = DATA_SCHEMA.get(col, {}) + col_type = schema[col] + col_def = { + "field": col, + "headerName": meta.get("title", col), + "filter": _filter_for(col_type), + "floatingFilter": True, + "sortable": True, + "hide": col in hidden, + } + if meta.get("description"): + col_def["headerTooltip"] = f"{meta.get('title', col)} ({col}) — {meta['description']}" + if col in _LINK_COLUMNS: + col_def["cellRenderer"] = "markdown" + defs.append(col_def) + return defs +``` + +- [ ] **Step 4: Lancer (succès attendu)** + +Run: `uv run pytest tests/test_grid.py -q` +Expected: PASS. + +- [ ] **Step 5: Ajouter la fabrique `ag_grid` dans `figures.py`** + +```python +# src/figures.py (append, en tête ajouter: import dash_ag_grid as dag) + +def ag_grid(grid_id: str, column_defs: list[dict]) -> "dag.AgGrid": + """Grille AG Grid server-side (infinite) pour la page Tableau. + + Apparence de base d'AG Grid (aucun thème custom au Lot 1). + """ + return dag.AgGrid( + id=grid_id, + columnDefs=column_defs, + defaultColDef={"resizable": True, "minWidth": 120, "floatingFilter": True}, + rowModelType="infinite", + dangerously_allow_code=True, # rend le HTML des cellules liens + dashGridOptions={ + "cacheBlockSize": 100, + "maxBlocksInCache": 10, + "rowBuffer": 0, + "infiniteInitialRowCount": 100, + "suppressCellFocus": True, + }, + columnSize="responsiveSizeToFit", + style={"height": "70vh", "width": "100%"}, + persistence=True, + persistence_type="local", + persisted_props=["filterModel", "columnState"], + ) +``` + +- [ ] **Step 6: Vérifier l'import de la fabrique** + +Run: `uv run python -c "from src.figures import ag_grid; from src.utils.grid import grid_column_defs; print(type(ag_grid('t', grid_column_defs([]))).__name__)"` +Expected: `AgGrid`. + +- [ ] **Step 7: Commit** + +```bash +pre-commit run --files src/utils/grid.py src/figures.py tests/test_grid.py +git add src/utils/grid.py src/figures.py tests/test_grid.py +git commit -m "feat(grid): columnDefs + fabrique ag_grid (#41)" +``` + +--- + +## Task 7: Réécrire le layout + le callback `getRows` de `tableau.py` + +Remplacer le composant `DATATABLE` (DataTable) par la grille AG Grid, et le callback `update_table` par un callback `getRowsResponse ← getRowsRequest`. Mettre à jour `nb_rows` et le bouton de téléchargement via un `dcc.Store` du dernier total. + +**Files:** + +- Modify: `src/pages/tableau.py` + +**Interfaces:** + +- Consumes: `ag_grid`, `grid_column_defs`, `fetch_grid_page`. +- Produces: composant grille `id="tableau_grid"` ; `dcc.Store id="tableau-total"`. + +- [ ] **Step 1: Remplacer le composant table** + +Dans `src/pages/tableau.py`, remplacer le bloc `DATATABLE = html.Div(... DataTable(...))` (≈ lignes 65-79) par : + +```python +from src.figures import make_column_picker, ag_grid # (remplace l'import DataTable) +from src.utils.grid import fetch_grid_page, grid_column_defs + +DATATABLE = html.Div( + className="marches_table", + children=ag_grid("tableau_grid", grid_column_defs(get_default_hidden_columns("tableau"))), +) +``` + +Ajouter `get_default_hidden_columns` à l'import depuis `src.utils.table` (déjà importé) et un store dans `layout` (à côté des autres `dcc.Store`) : + +```python +dcc.Store(id="tableau-total"), +``` + +- [ ] **Step 2: Remplacer le callback `update_table` par le datasource** + +Supprimer l'ancien `@callback def update_table(...)` (≈ lignes 436-478) et le remplacer par : + +```python +@callback( + Output("tableau_grid", "getRowsResponse"), + Output("tableau-total", "data"), + Input("tableau_grid", "getRowsRequest"), + prevent_initial_call=True, +) +def get_rows_tableau(request): + if request is None: + return no_update, no_update + filter_model = request.get("filterModel") or None + sort_model = request.get("sortModel") or None + if filter_model: + track_search(json.dumps(filter_model), "tableau") + rows, total = fetch_grid_page( + filter_model, + sort_model, + request.get("startRow", 0), + request.get("endRow", 100), + ) + return {"rowData": rows, "rowCount": total}, total +``` + +- [ ] **Step 3: Mettre à jour `nb_rows` + bouton téléchargement depuis le total** + +Ajouter un callback qui réagit au store `tableau-total` : + +```python +@callback( + Output("nb_rows", "children"), + Output("btn-download-data", "disabled"), + Output("download-hint", "children"), + Input("tableau-total", "data"), +) +def update_meta(total): + total = total or 0 + too_many = total > 65000 + hint = " · Filtrez sous 65 000 lignes pour activer le téléchargement" if too_many else "" + return f"{total} lignes", too_many, hint +``` + +- [ ] **Step 4: Vérifier que la page se charge (smoke test manuel)** + +Run: `uv run pytest tests/test_main.py::test_001_logo_and_search -q` (démarre l'app en intégration ; vérifie qu'aucune erreur d'import/callback ne casse le boot). +Expected: PASS. Si échec pour cause d'ID manquant, corriger les références d'ID. + +- [ ] **Step 5: Commit** + +```bash +pre-commit run --files src/pages/tableau.py +git add src/pages/tableau.py +git commit -m "feat(tableau): grille AG Grid + datasource getRows server-side (#41)" +``` + +--- + +## Task 8: Sélecteur de colonnes → `columnState`/`hide` + +Piloter la visibilité des colonnes de la grille depuis le sélecteur existant (`make_column_picker`) via la prop `columnDefs` (ou `columnState`) au lieu de `hidden_columns` de la DataTable. + +**Files:** + +- Modify: `src/pages/tableau.py` + +**Interfaces:** + +- Consumes: `grid_column_defs`, `make_column_picker`, `get_default_hidden_columns`, `invert_columns`, `COLUMNS`. + +- [ ] **Step 1: Adapter les callbacks colonnes** + +Remplacer les callbacks qui écrivaient `Output("tableau_datatable", "hidden_columns")` par un callback qui régénère `columnDefs` : + +```python +@callback( + Output("tableau_grid", "columnDefs"), + Input("tableau-hidden-columns", "data"), +) +def apply_hidden_columns(hidden_columns): + if hidden_columns is None: + hidden_columns = get_default_hidden_columns("tableau") + return grid_column_defs(hidden_columns) +``` + +Conserver les callbacks existants qui alimentent `tableau-hidden-columns` depuis les cases à cocher (`update_hidden_columns_from_checkboxes`, `update_checkboxes_from_hidden_columns`) — ils manipulent `COLUMNS`/`selected_rows`, indépendants d'AG Grid. Supprimer `store_hidden_columns` s'il ne cible plus que l'ancienne table. + +- [ ] **Step 2: Test intégration du picker** + +Run: `uv run pytest tests/test_main.py -q -k tableau` (s'il existe un test tableau ; sinon vérification manuelle décrite en Task 12). +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +pre-commit run --files src/pages/tableau.py +git add src/pages/tableau.py +git commit -m "feat(tableau): sélecteur de colonnes piloté par columnDefs (#41)" +``` + +--- + +## Task 9: Export Excel via DuckDB (AST→SQL) + +Réécrire `download_data` pour partir du `filterModel`/`sortModel`/`columnState` courants de la grille, compiler en SQL, tirer les lignes de DuckDB et styler l'Excel. + +**Files:** + +- Modify: `src/pages/tableau.py` +- Modify: `src/utils/grid.py` (ajout `export_dataframe`) +- Test: `tests/test_grid.py` + +**Interfaces:** + +- Produces: `export_dataframe(filter_model, sort_model, hidden_columns) -> pl.DataFrame` (lignes filtrées/triées, colonnes masquées exclues, **non** post-traitées HTML — valeurs brutes pour l'Excel). + +- [ ] **Step 1: Test `export_dataframe`** + +```python +# tests/test_grid.py (append) +from src.utils.grid import export_dataframe + + +def test_export_dataframe_excludes_hidden_columns(): + df = export_dataframe(None, None, hidden_columns=["objet"]) + assert "objet" not in df.columns + + +def test_export_dataframe_applies_filter(): + fm = {"objet": {"filterType": "text", "type": "contains", "filter": "zzzzzznope"}} + df = export_dataframe(fm, None, hidden_columns=[]) + assert df.height == 0 +``` + +- [ ] **Step 2: Lancer (échec attendu)** + +Run: `uv run pytest tests/test_grid.py -q -k export` +Expected: FAIL. + +- [ ] **Step 3: Implémenter `export_dataframe`** + +```python +# src/utils/grid.py (append) + +def export_dataframe(filter_model, sort_model, hidden_columns) -> "pl.DataFrame": + ast = filtermodel_to_ast(filter_model, schema) + filter_sql, params = ast_to_sql(ast, schema) + order_by = sort_model_to_sql(sort_model, schema) or None + visible = [c for c in schema.names() if c not in set(hidden_columns or [])] + return query_marches( + where_sql=filter_sql, + params=params, + columns=visible, + order_by=order_by, + ) +``` + +- [ ] **Step 4: Réécrire le callback `download_data`** + +```python +# src/pages/tableau.py (remplace download_data) +@callback( + Output("download-data", "data"), + Input("btn-download-data", "n_clicks"), + State("tableau_grid", "filterModel"), + State("tableau_grid", "columnState"), + prevent_initial_call=True, +) +def download_data(n_clicks, filter_model, column_state): + from src.utils.grid import export_dataframe + sort_model = [ + {"colId": c["colId"], "sort": c["sort"]} + for c in (column_state or []) if c.get("sort") + ] + hidden_columns = [c["colId"] for c in (column_state or []) if c.get("hide")] + df = export_dataframe(filter_model, sort_model, hidden_columns) + + def to_bytes(buffer): + write_styled_excel(df, buffer) + + date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") + return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx") +``` + +> `columnState` porte à la fois le tri (`sort`/`sortIndex`) et la visibilité (`hide`) : une seule `State` suffit pour les deux. + +- [ ] **Step 5: Lancer les tests** + +Run: `uv run pytest tests/test_grid.py -q` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +pre-commit run --files src/utils/grid.py src/pages/tableau.py tests/test_grid.py +git add src/utils/grid.py src/pages/tableau.py tests/test_grid.py +git commit -m "feat(tableau): export Excel via DuckDB (AST->SQL) (#41)" +``` + +--- + +## Task 10: Vues sauvegardées (AST + columnState) et retrait de l'URL riche + +Les vues stockent désormais un JSON `{filterModel, columnState}` dans la colonne `query`. Le rappel applique `filterModel` + `columnState` à la grille. Retirer `restore_view_from_url`, `sync_url_and_reset_button`, le bouton « Partager la vue » et le clientside `clean_filters` de `tableau.py`. + +**Files:** + +- Modify: `src/pages/tableau.py` +- Modify: `src/saved_views/ui.py` si `prepare_view_to_save` référence l'ancien format (sinon inchangé). + +- [ ] **Step 1: Sauvegarde de vue au nouveau format** + +Réécrire `save_view` pour stocker le JSON de la vue : + +```python +@callback( + Output("save-view-modal", "is_open", allow_duplicate=True), + Output("save-view-feedback", "children"), + Output("saved-views-refresh", "data"), + Input("btn-save-view-confirm", "n_clicks"), + State("save-view-name", "value"), + State("tableau_grid", "filterModel"), + State("tableau_grid", "columnState"), + prevent_initial_call=True, +) +def save_view(_n, name, filter_model, column_state): + has_sub = current_user_has_subscription() + clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name) + if error: + return True, html.Span(error, style={"color": "red"}), no_update + query = json.dumps({"filterModel": filter_model or {}, "columnState": column_state or []}) + saved_views_db.upsert(current_user.id, "tableau", clean_name, query) + return ( + False, + html.Span(f"Vue « {clean_name} » enregistrée.", style={"color": "green"}), + clean_name, + ) +``` + +- [ ] **Step 2: Rappel d'une vue → applique filterModel + columnState** + +Remplacer `restore_view_from_url` par un callback déclenché par la sélection dans le menu « Mes vues » (le menu `saved-views-menu` produit des items ; réutiliser leur `id`/`value` existant). Exemple de callback de rappel : + +```python +@callback( + Output("tableau_grid", "filterModel"), + Output("tableau_grid", "columnState"), + Input({"type": "saved-view-item", "index": ALL}, "n_clicks"), + State({"type": "saved-view-item", "index": ALL}, "id"), + prevent_initial_call=True, +) +def apply_saved_view(n_clicks, ids): + triggered = ctx.triggered_id + if not triggered or not any(n_clicks): + return no_update, no_update + row = saved_views_db.get(triggered["index"], current_user.id) + if not row: + return no_update, no_update + view = json.loads(row["query"]) + return view.get("filterModel") or {}, view.get("columnState") or [] +``` + +> Adapter les `id`/pattern-matching au format réel produit par `saved_views_ui.saved_views_items` (lire `src/saved_views/ui.py`). Importer `ctx`, `ALL` depuis `dash`. + +- [ ] **Step 3: Retirer l'URL riche et le clientside** + +Supprimer de `tableau.py` : + +- le callback `restore_view_from_url` (bloc `?filtres/tris/colonnes`), +- le callback `sync_url_and_reset_button` et le composant `dcc.Clipboard`/bouton « Partager la vue » (`copy-container`, `share-url`), +- `show_confirmation`, +- le `clientside_callback` `clean_filters` (Output `filter-cleanup-trigger-tableau`) et le `dcc.Store(id="filter-cleanup-trigger-tableau")`, +- l'`Output(..., "filter-cleanup-trigger-tableau", ...)` résiduel. + +Adapter le bouton **Réinitialiser** : + +```python +@callback( + Output("tableau_grid", "filterModel", allow_duplicate=True), + Input("btn-tableau-reset", "n_clicks"), + prevent_initial_call=True, +) +def reset_view(n_clicks): + return {} +``` + +- [ ] **Step 4: Smoke test app** + +Run: `uv run pytest tests/test_main.py::test_001_logo_and_search -q` +Expected: PASS (aucun ID orphelin, pas de callback cassé). + +- [ ] **Step 5: Commit** + +```bash +pre-commit run --files src/pages/tableau.py src/saved_views/ui.py +git add src/pages/tableau.py src/saved_views/ui.py +git commit -m "feat(tableau): vues sauvegardées en JSON AST, retrait URL riche (#41)" +``` + +--- + +## Task 11: Mode d'emploi + nettoyage + +Réécrire l'aide (`dcc.Markdown`) pour décrire les filtres de colonne AG Grid ; retirer les exemples d'URL riche codés en dur et la légende des boutons devenus obsolètes. + +**Files:** + +- Modify: `src/pages/tableau.py` + +- [ ] **Step 1: Réécrire le corps du mode d'emploi** + +Dans le `dcc.Markdown` du modal `tableau_help`, remplacer les sections « Appliquer des filtres » (syntaxe DSL `icontains`), « Partager une vue » et l'intro avec les deux liens `?filtres=…` par une description des **filtres de colonne** AG Grid : cliquer sur l'icône filtre / saisir dans le champ flottant sous l'en-tête, opérateurs contient/égal/commence par, filtres numériques `<`/`>`/plage, tri par clic d'en-tête, sélecteur de colonnes, scroll infini, export Excel. Retirer les deux liens d'exemple du `dcc.Markdown` d'intro (≈ ligne 204). + +- [ ] **Step 2: Mettre à jour `_help_button_legend`** + +Retirer la ligne « Partager la vue » de `_help_button_legend()` (le bouton a été supprimé). + +- [ ] **Step 3: Vérifier l'absence de références mortes** + +Run: `uv run python -c "import src.pages.tableau; print('ok')"` +Expected: `ok`. +Run: `rg -n "filter_query|hidden_columns|tableau_datatable|clean_filters|Partager" src/pages/tableau.py` +Expected: aucune occurrence résiduelle liée à l'ancienne table. + +- [ ] **Step 4: Commit** + +```bash +pre-commit run --files src/pages/tableau.py +git add src/pages/tableau.py +git commit -m "docs(tableau): mode d'emploi réécrit pour AG Grid (#41)" +``` + +--- + +## Task 12: Test d'intégration end-to-end + suite complète + +Valider le parcours complet via DashComposite/Selenium, puis lancer toute la suite. + +**Files:** + +- Create: `tests/test_tableau_ag_grid.py` + +- [ ] **Step 1: Écrire le test d'intégration** + +```python +# tests/test_tableau_ag_grid.py +from src.app import app + + +def test_tableau_grid_loads_and_filters(dash_duo): + dash_duo.start_server(app) + dash_duo.wait_for_page("http://localhost:{}/tableau".format(dash_duo.server_port)) + # La grille AG Grid est présente + dash_duo.wait_for_element(".ag-root", timeout=8) + # Au moins une ligne rendue (row) après chargement du premier bloc + dash_duo.wait_for_element(".ag-center-cols-container .ag-row", timeout=8) + assert dash_duo.get_logs() == [] or all( + "SEVERE" not in log["level"] for log in dash_duo.get_logs() + ) +``` + +> Ajuster les sélecteurs si besoin (`.ag-root-wrapper`, `.ag-header-cell`). Ces tests nécessitent Chromium (cf. CLAUDE.md). + +- [ ] **Step 2: Lancer le test d'intégration** + +Run: `uv run pytest tests/test_tableau_ag_grid.py -q` +Expected: PASS. + +- [ ] **Step 3: Vérification manuelle (drive de l'app)** + +Utiliser la compétence `run` / `verify` pour lancer l'app (`uv run run.py`) et vérifier sur `/tableau` : chargement de la grille, en-têtes figés au scroll, filtre de colonne (texte `voirie`, numérique `> 40000`), tri par clic d'en-tête, scroll infini (nouveau bloc chargé), sélecteur de colonnes, export Excel (fichier téléchargé cohérent avec les filtres), persistance après refresh, sauvegarde + rappel d'une vue (compte abonné). + +- [ ] **Step 4: Lancer la suite complète** + +Run: `uv run pytest` +Expected: PASS (aucune régression sur les autres pages, qui utilisent encore la `DataTable`). + +- [ ] **Step 5: Commit** + +```bash +pre-commit run --files tests/test_tableau_ag_grid.py +git add tests/test_tableau_ag_grid.py +git commit -m "test(tableau): intégration AG Grid end-to-end (#41)" +``` + +--- + +## Verification (récapitulatif) + +- `uv run pytest tests/test_query_ast.py tests/test_grid.py -q` — moteur de requête + datasource. +- `uv run pytest tests/test_tableau_ag_grid.py -q` — intégration grille. +- `uv run pytest` — non-régression globale (dernière tâche uniquement). +- Drive manuel de `/tableau` (compétence `run`/`verify`) pour l'UX scroll infini, filtres, tri, colonnes, export, persistance, vues sauvegardées. + +## Notes de mise en œuvre + +- **Colonnes masquées par défaut** : `get_default_hidden_columns("tableau")` lit l'env `DISPLAYED_COLUMNS` ; conserver ce comportement. +- **`track_search`** : on logue désormais le `filterModel` JSON (au lieu du DSL) ; format différent mais même intention. +- **Post-traitement HTML** : `postprocess_page` caste tout en `String` et injecte des `` — parfait pour `cellRenderer: "markdown"` + `dangerously_allow_code=True`. L'export, lui, part des valeurs brutes DuckDB (pas de post-traitement HTML). +- **Ne pas** supprimer `filter_query_to_sql`, `clean_filters`, la classe `DataTable` : encore utilisés par les pages non migrées (Lots 2/3). From 7454d0db32b52afd9ddc37a8a4977ef097468799 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 07:41:19 +0200 Subject: [PATCH 17/32] =?UTF-8?q?build:=20ajouter=20la=20d=C3=A9pendance?= =?UTF-8?q?=20dash-ag-grid=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- pyproject.toml | 1 + uv.lock | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 42d9407..80eecd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "marshmallow>=3.20.0", "boto3", "cryptography", + "dash-ag-grid>=35.2.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index b504384..241db79 100644 --- a/uv.lock +++ b/uv.lock @@ -590,6 +590,7 @@ dependencies = [ { name = "brevo-python" }, { name = "cryptography" }, { name = "dash", extra = ["compress"] }, + { name = "dash-ag-grid" }, { name = "dash-bootstrap-components" }, { name = "dash-extensions", version = "1.0.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "dash-extensions", version = "2.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -636,6 +637,7 @@ requires-dist = [ { name = "brevo-python", specifier = "==5.0.0rc1" }, { name = "cryptography" }, { name = "dash", extras = ["compress"], specifier = "==4.4.0" }, + { name = "dash-ag-grid", specifier = ">=35.2.0" }, { name = "dash-bootstrap-components" }, { name = "dash-extensions" }, { name = "dash-leaflet" }, @@ -787,6 +789,18 @@ testing = [ { name = "waitress" }, ] +[[package]] +name = "dash-ag-grid" +version = "35.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/4d/259d21112a087a23ecd2f7dd651412755bf0547835805c38f3c77c971052/dash_ag_grid-35.2.0.tar.gz", hash = "sha256:507f5dccf7235bf1b9af2c59d4cd0f12205db03c3489f605948f13c72bcece4f", size = 5794514, upload-time = "2026-04-03T10:46:26.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/71/7f97793a3449ee5218f0fb820c03c15be11a7c6d0c61c37d38779b753d4e/dash_ag_grid-35.2.0-py3-none-any.whl", hash = "sha256:b8b33780faa7322101b0559658da2a301a58a3329ff6a2c45f1490f0c3719df9", size = 5836952, upload-time = "2026-04-03T10:46:23.957Z" }, +] + [[package]] name = "dash-bootstrap-components" version = "2.0.4" From 68f615370fdcbab6bb6a44b430f3e11e17aabf2b Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 07:44:30 +0200 Subject: [PATCH 18/32] feat(query): AST de filtre + compilateur ast_to_sql (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute une représentation canonique du filtre sous forme d'AST booléen (Condition/And/Or/Not) et son compilateur vers SQL DuckDB paramétré (ast_to_sql). Réutilise tokenize_text_filter pour les feuilles texte. Fondation pour la migration /tableau vers dash-ag-grid : cet AST sera alimenté par le filterModel d'AG Grid (tâche suivante) et, plus tard, par un champ de requête booléenne libre (#97). --- src/utils/query_ast.py | 139 ++++++++++++++++++++++++++++++++++++++++ tests/test_query_ast.py | 106 ++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 src/utils/query_ast.py create mode 100644 tests/test_query_ast.py diff --git a/src/utils/query_ast.py b/src/utils/query_ast.py new file mode 100644 index 0000000..9affe48 --- /dev/null +++ b/src/utils/query_ast.py @@ -0,0 +1,139 @@ +"""Représentation canonique d'un filtre (AST booléen) et compilation en SQL DuckDB. + +Ce module est indépendant de l'UI : plusieurs producteurs (filtres de colonne +AG Grid, futur champ de requête booléenne #97) construisent le même AST, compilé +ici en SQL paramétré. Les identifiants de colonnes sont validés contre le schéma ; +les valeurs passent toujours par le binding `?` (jamais concaténées). +""" + +from dataclasses import dataclass + +import polars as pl + +from src.utils import logger +from src.utils.table_sql import tokenize_text_filter + + +@dataclass +class Condition: + column: str + operator: str + value: object = None + value2: object = None + + +@dataclass +class And: + children: list + + +@dataclass +class Or: + children: list + + +@dataclass +class Not: + child: object + + +Node = object # Condition | And | Or | Not | None + + +def ast_to_sql(node, schema: pl.Schema) -> tuple[str, list]: + """Compile un AST en (where_sql, params). Nœud neutre -> ('TRUE', []).""" + if node is None: + return "TRUE", [] + if isinstance(node, And): + return _join(node.children, "AND", schema) + if isinstance(node, Or): + return _join(node.children, "OR", schema) + if isinstance(node, Not): + sql, params = ast_to_sql(node.child, schema) + if sql == "TRUE": + return "TRUE", [] + return f"NOT ({sql})", params + if isinstance(node, Condition): + return _condition_to_sql(node, schema) + logger.warning(f"Nœud AST inconnu ignoré : {node!r}") + return "TRUE", [] + + +def _join(children, op: str, schema: pl.Schema) -> tuple[str, list]: + fragments: list[str] = [] + params: list = [] + for child in children: + sql, child_params = ast_to_sql(child, schema) + if sql == "TRUE": + continue + fragments.append(f"({sql})") + params.extend(child_params) + if not fragments: + return "TRUE", [] + return f" {op} ".join(fragments), params + + +def _condition_to_sql(cond: Condition, schema: pl.Schema) -> tuple[str, list]: + col = cond.column + if col not in schema.names(): + logger.warning(f"Colonne inconnue ignorée : {col!r}") + return "TRUE", [] + + col_type = schema[col] + quoted = f'"{col}"' + + if cond.operator == "blank": + return f"({quoted} IS NULL OR {quoted} = '')", [] + if cond.operator == "notBlank": + return f"({quoted} IS NOT NULL AND {quoted} <> '')", [] + + is_numeric = col_type.is_numeric() + col_is_date = col_type == pl.Date + + if is_numeric: + return _numeric_to_sql(cond, col_type, quoted) + + # texte / date : traité comme texte (parité avec l'existant) + if cond.operator == "contains": + return tokenize_text_filter(col, str(cond.value), col_is_date) + if cond.operator == "notContains": + where, params = tokenize_text_filter(col, str(cond.value), col_is_date) + return f"NOT ({where})", params + + target = f"CAST({quoted} AS VARCHAR)" if col_is_date else quoted + op_map = {"eq": "=", "neq": "<>", "gt": ">", "gte": ">=", "lt": "<", "lte": "<="} + if cond.operator in op_map: + return f"{quoted} IS NOT NULL AND {target} {op_map[cond.operator]} ?", [ + str(cond.value) + ] + if cond.operator == "startsWith": + return f"{quoted} ILIKE ?", [f"{cond.value}%"] + if cond.operator == "endsWith": + return f"{quoted} ILIKE ?", [f"%{cond.value}"] + logger.warning(f"Opérateur texte invalide : {cond.operator!r}") + return "TRUE", [] + + +def _coerce_number(value, col_type): + try: + return int(value) if col_type.is_integer() else float(value) + except (TypeError, ValueError): + logger.warning(f"Valeur numérique invalide ignorée : {value!r}") + return None + + +def _numeric_to_sql(cond: Condition, col_type, quoted: str) -> tuple[str, list]: + op_map = {"eq": "=", "neq": "<>", "gt": ">", "gte": ">=", "lt": "<", "lte": "<="} + if cond.operator in op_map: + v = _coerce_number(cond.value, col_type) + if v is None: + return "TRUE", [] + return f"{quoted} IS NOT NULL AND {quoted} {op_map[cond.operator]} ?", [v] + if cond.operator == "range": + v1 = _coerce_number(cond.value, col_type) + v2 = _coerce_number(cond.value2, col_type) + if v1 is None or v2 is None: + return "TRUE", [] + return f"{quoted} BETWEEN ? AND ?", [v1, v2] + logger.warning(f"Opérateur numérique invalide : {cond.operator!r}") + return "TRUE", [] diff --git a/tests/test_query_ast.py b/tests/test_query_ast.py new file mode 100644 index 0000000..8d58ffb --- /dev/null +++ b/tests/test_query_ast.py @@ -0,0 +1,106 @@ +import polars as pl + +from src.utils.query_ast import And, Condition, Not, Or, ast_to_sql + +SCHEMA = pl.Schema( + { + "acheteur_nom": pl.String, + "objet": pl.String, + "montant": pl.Float64, + "dureeMois": pl.Int64, + "dateNotification": pl.Date, + } +) + + +def _run(node): + """Compile et retourne (sql, params).""" + return ast_to_sql(node, SCHEMA) + + +def test_none_is_true(): + assert _run(None) == ("TRUE", []) + + +def test_empty_and_is_true(): + assert _run(And([])) == ("TRUE", []) + + +def test_text_contains_uses_ilike_and_params(): + sql, params = _run(Condition("objet", "contains", "voirie")) + assert "ILIKE ?" in sql + assert params == ["%voirie%"] + + +def test_text_contains_multiword_is_and(): + sql, params = _run(Condition("objet", "contains", "metropole rennes")) + assert sql.count("ILIKE ?") == 2 + assert params == ["%metropole%", "%rennes%"] + + +def test_text_contains_wildcard_and_phrase(): + _, params = _run(Condition("objet", "contains", "distri* metropole+rennes")) + assert params == ["distri%", "%metropole rennes%"] + + +def test_text_notcontains_negates(): + sql, params = _run(Condition("objet", "notContains", "construction")) + assert "NOT (" in sql + assert params == ["%construction%"] + + +def test_numeric_gt(): + sql, params = _run(Condition("montant", "gt", 40000)) + assert '"montant" > ?' in sql + assert params == [40000.0] + + +def test_numeric_eq_int_column(): + sql, params = _run(Condition("dureeMois", "eq", "12")) + assert '"dureeMois" = ?' in sql + assert params == [12] + + +def test_numeric_range(): + sql, params = _run(Condition("montant", "range", 100, 200)) + assert params == [100.0, 200.0] + assert "BETWEEN" in sql or ("> ?" in sql and "< ?" in sql) + + +def test_numeric_invalid_value_is_true(): + # valeur non numérique -> condition neutralisée (TRUE), pas d'exception + assert _run(Condition("montant", "gt", "abc")) == ("TRUE", []) + + +def test_date_gt_casts_varchar(): + sql, params = _run(Condition("dateNotification", "gt", "2022")) + assert "VARCHAR" in sql + assert params == ["2022"] + + +def test_blank_and_notblank(): + sql_b, _ = _run(Condition("objet", "blank")) + assert "IS NULL" in sql_b + sql_nb, _ = _run(Condition("objet", "notBlank")) + assert "IS NOT NULL" in sql_nb + + +def test_unknown_column_is_true(): + assert _run(Condition("colonne_inexistante", "contains", "x")) == ("TRUE", []) + + +def test_and_or_not_grouping(): + node = And( + [ + Or( + [ + Condition("objet", "contains", "beton"), + Condition("objet", "contains", "ciment"), + ] + ), + Not(Condition("objet", "contains", "demolition")), + ] + ) + sql, params = _run(node) + assert " OR " in sql and " AND " in sql and "NOT (" in sql + assert params == ["%beton%", "%ciment%", "%demolition%"] From 979874760de37b26a3c99407dfc34a177b98eb02 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 07:49:43 +0200 Subject: [PATCH 19/32] =?UTF-8?q?fix(query):=20range=20sur=20colonnes=20no?= =?UTF-8?q?n-num=C3=A9riques=20+=20blank/notBlank=20sur=20num=C3=A9rique/d?= =?UTF-8?q?ate=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `range` était ignoré silencieusement (TRUE, pas de filtre) sur les colonnes texte/date car seul `_numeric_to_sql` le gérait. Ajout du cas `range` dans la branche texte/date (BETWEEN, CAST VARCHAR pour les dates, comme les autres opérateurs de comparaison). - `blank`/`notBlank` comparaient toujours à `''`, ce qui fait planter DuckDB (Conversion Error) sur les colonnes numériques/date. Le check est déplacé après le calcul de is_numeric/col_is_date : ces types utilisent IS [NOT] NULL sans comparaison à chaîne vide. --- src/utils/query_ast.py | 19 ++++++++++++++----- tests/test_query_ast.py | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/utils/query_ast.py b/src/utils/query_ast.py index 9affe48..802ba14 100644 --- a/src/utils/query_ast.py +++ b/src/utils/query_ast.py @@ -82,14 +82,18 @@ def _condition_to_sql(cond: Condition, schema: pl.Schema) -> tuple[str, list]: col_type = schema[col] quoted = f'"{col}"' - if cond.operator == "blank": - return f"({quoted} IS NULL OR {quoted} = '')", [] - if cond.operator == "notBlank": - return f"({quoted} IS NOT NULL AND {quoted} <> '')", [] - is_numeric = col_type.is_numeric() col_is_date = col_type == pl.Date + if cond.operator == "blank": + if is_numeric or col_is_date: + return f"{quoted} IS NULL", [] + return f"({quoted} IS NULL OR {quoted} = '')", [] + if cond.operator == "notBlank": + if is_numeric or col_is_date: + return f"{quoted} IS NOT NULL", [] + return f"({quoted} IS NOT NULL AND {quoted} <> '')", [] + if is_numeric: return _numeric_to_sql(cond, col_type, quoted) @@ -106,6 +110,11 @@ def _condition_to_sql(cond: Condition, schema: pl.Schema) -> tuple[str, list]: return f"{quoted} IS NOT NULL AND {target} {op_map[cond.operator]} ?", [ str(cond.value) ] + if cond.operator == "range": + return f"{quoted} IS NOT NULL AND {target} BETWEEN ? AND ?", [ + str(cond.value), + str(cond.value2), + ] if cond.operator == "startsWith": return f"{quoted} ILIKE ?", [f"{cond.value}%"] if cond.operator == "endsWith": diff --git a/tests/test_query_ast.py b/tests/test_query_ast.py index 8d58ffb..7fd0de7 100644 --- a/tests/test_query_ast.py +++ b/tests/test_query_ast.py @@ -85,6 +85,32 @@ def test_blank_and_notblank(): assert "IS NOT NULL" in sql_nb +def test_date_range_uses_between(): + sql, params = _run( + Condition("dateNotification", "range", "2022-01-01", "2022-12-31") + ) + assert "BETWEEN" in sql + assert params == ["2022-01-01", "2022-12-31"] + + +def test_text_range_uses_between(): + sql, params = _run(Condition("acheteur_nom", "range", "a", "m")) + assert "BETWEEN" in sql + assert params == ["a", "m"] + + +def test_blank_on_numeric_column_no_empty_string(): + sql, params = _run(Condition("montant", "blank")) + assert sql == '"montant" IS NULL' + assert params == [] + + +def test_notblank_on_date_column_no_empty_string(): + sql, params = _run(Condition("dateNotification", "notBlank")) + assert sql == '"dateNotification" IS NOT NULL' + assert params == [] + + def test_unknown_column_is_true(): assert _run(Condition("colonne_inexistante", "contains", "x")) == ("TRUE", []) From 33ea1205cf1fcc21baf1f7c50aee95391c464307 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 07:53:46 +0200 Subject: [PATCH 20/32] feat(query): filtermodel_to_ast (filterModel AG Grid -> AST) (#41) --- src/utils/query_ast.py | 66 ++++++++++++++++++++++++++++++++++++++ tests/test_query_ast.py | 70 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/utils/query_ast.py b/src/utils/query_ast.py index 802ba14..d81e8f1 100644 --- a/src/utils/query_ast.py +++ b/src/utils/query_ast.py @@ -146,3 +146,69 @@ def _numeric_to_sql(cond: Condition, col_type, quoted: str) -> tuple[str, list]: return f"{quoted} BETWEEN ? AND ?", [v1, v2] logger.warning(f"Opérateur numérique invalide : {cond.operator!r}") return "TRUE", [] + + +_TEXT_TYPE = { + "contains": "contains", + "notContains": "notContains", + "equals": "eq", + "notEqual": "neq", + "startsWith": "startsWith", + "endsWith": "endsWith", + "blank": "blank", + "notBlank": "notBlank", +} +_NUM_TYPE = { + "equals": "eq", + "notEqual": "neq", + "lessThan": "lt", + "lessThanOrEqual": "lte", + "greaterThan": "gt", + "greaterThanOrEqual": "gte", + "inRange": "range", + "blank": "blank", + "notBlank": "notBlank", +} + + +def _leaf(column: str, spec: dict): + """Convertit une condition AG Grid unitaire en Condition.""" + ftype = spec.get("filterType", "text") + ag_type = spec.get("type") + if ftype == "date": + op = _NUM_TYPE.get(ag_type) + if op == "range": + return Condition(column, "range", spec.get("dateFrom"), spec.get("dateTo")) + return Condition(column, op, spec.get("dateFrom")) if op else None + if ftype == "number": + op = _NUM_TYPE.get(ag_type) + if op == "range": + return Condition(column, "range", spec.get("filter"), spec.get("filterTo")) + return Condition(column, op, spec.get("filter")) if op else None + # texte + op = _TEXT_TYPE.get(ag_type) + return Condition(column, op, spec.get("filter")) if op else None + + +def filtermodel_to_ast(filter_model, schema): + """Traduit un filterModel AG Grid en AST. Colonnes combinées en And.""" + if not filter_model: + return None + children = [] + for column, spec in filter_model.items(): + if column not in schema.names(): + logger.warning(f"Filtre sur colonne inconnue ignoré : {column!r}") + continue + if "operator" in spec: # deux conditions + c1 = _leaf(column, spec.get("condition1", {})) + c2 = _leaf(column, spec.get("condition2", {})) + parts = [c for c in (c1, c2) if c is not None] + if not parts: + continue + node = And(parts) if spec["operator"] == "AND" else Or(parts) + else: + node = _leaf(column, spec) + if node is None: + continue + children.append(node) + return And(children) if children else None diff --git a/tests/test_query_ast.py b/tests/test_query_ast.py index 7fd0de7..fee6f2c 100644 --- a/tests/test_query_ast.py +++ b/tests/test_query_ast.py @@ -1,6 +1,6 @@ import polars as pl -from src.utils.query_ast import And, Condition, Not, Or, ast_to_sql +from src.utils.query_ast import And, Condition, Not, Or, ast_to_sql, filtermodel_to_ast SCHEMA = pl.Schema( { @@ -130,3 +130,71 @@ def test_and_or_not_grouping(): sql, params = _run(node) assert " OR " in sql and " AND " in sql and "NOT (" in sql assert params == ["%beton%", "%ciment%", "%demolition%"] + + +def test_filtermodel_empty_is_none(): + assert filtermodel_to_ast(None, SCHEMA) is None + assert filtermodel_to_ast({}, SCHEMA) is None + + +def test_filtermodel_text_contains(): + fm = {"objet": {"filterType": "text", "type": "contains", "filter": "voirie"}} + _, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA) + assert params == ["%voirie%"] + + +def test_filtermodel_number_greaterthan(): + fm = {"montant": {"filterType": "number", "type": "greaterThan", "filter": 40000}} + sql, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA) + assert '"montant"' in sql and params == [40000.0] + + +def test_filtermodel_number_inrange(): + fm = { + "montant": { + "filterType": "number", + "type": "inRange", + "filter": 100, + "filterTo": 200, + } + } + _, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA) + assert params == [100.0, 200.0] + + +def test_filtermodel_date_uses_datefrom(): + fm = { + "dateNotification": { + "filterType": "date", + "type": "greaterThan", + "dateFrom": "2022-01-01", + } + } + _, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA) + assert params == ["2022-01-01"] + + +def test_filtermodel_two_conditions_or(): + fm = { + "objet": { + "filterType": "text", + "operator": "OR", + "condition1": {"filterType": "text", "type": "contains", "filter": "beton"}, + "condition2": { + "filterType": "text", + "type": "contains", + "filter": "ciment", + }, + } + } + sql, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA) + assert " OR " in sql and params == ["%beton%", "%ciment%"] + + +def test_filtermodel_multiple_columns_are_anded(): + fm = { + "objet": {"filterType": "text", "type": "contains", "filter": "voirie"}, + "montant": {"filterType": "number", "type": "greaterThan", "filter": 1000}, + } + sql, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA) + assert " AND " in sql and set(params) == {"%voirie%", 1000.0} From a7662a856185e47cbeda9a1a3e4982327b193a1c Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 07:57:41 +0200 Subject: [PATCH 21/32] =?UTF-8?q?feat(query):=20sort=5Fmodel=5Fto=5Fsql=20?= =?UTF-8?q?+=20s=C3=A9rialisation=20AST=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute sort_model_to_sql (adapte le sortModel AG Grid vers sort_by_to_sql existant) et ast_to_dict/ast_from_dict pour le round-trip JSON de l'AST, nécessaire aux vues sauvegardées. --- src/utils/query_ast.py | 51 ++++++++++++++++++++++++++++++++++++++++- tests/test_query_ast.py | 39 ++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/utils/query_ast.py b/src/utils/query_ast.py index d81e8f1..f36a4aa 100644 --- a/src/utils/query_ast.py +++ b/src/utils/query_ast.py @@ -11,7 +11,7 @@ from dataclasses import dataclass import polars as pl from src.utils import logger -from src.utils.table_sql import tokenize_text_filter +from src.utils.table_sql import sort_by_to_sql, tokenize_text_filter @dataclass @@ -212,3 +212,52 @@ def filtermodel_to_ast(filter_model, schema): continue children.append(node) return And(children) if children else None + + +def sort_model_to_sql(sort_model: list | None, schema: pl.Schema) -> str: + """Traduit un sortModel AG Grid en clause ORDER BY DuckDB (adapte à sort_by_to_sql).""" + if not sort_model: + return "" + sort_by = [ + {"column_id": s.get("colId"), "direction": s.get("sort")} for s in sort_model + ] + return sort_by_to_sql(sort_by, schema) + + +def ast_to_dict(node: Node) -> dict | None: + """Sérialise un AST en dict JSON-compatible (pour persistance en base).""" + if node is None: + return None + if isinstance(node, Condition): + return { + "t": "cond", + "column": node.column, + "operator": node.operator, + "value": node.value, + "value2": node.value2, + } + if isinstance(node, And): + return {"t": "and", "children": [ast_to_dict(c) for c in node.children]} + if isinstance(node, Or): + return {"t": "or", "children": [ast_to_dict(c) for c in node.children]} + if isinstance(node, Not): + return {"t": "not", "child": ast_to_dict(node.child)} + return None + + +def ast_from_dict(data: dict | None) -> Node: + """Reconstruit un AST à partir d'un dict produit par ast_to_dict.""" + if data is None: + return None + t = data.get("t") + if t == "cond": + return Condition( + data["column"], data["operator"], data.get("value"), data.get("value2") + ) + if t == "and": + return And([ast_from_dict(c) for c in data["children"]]) + if t == "or": + return Or([ast_from_dict(c) for c in data["children"]]) + if t == "not": + return Not(ast_from_dict(data["child"])) + return None diff --git a/tests/test_query_ast.py b/tests/test_query_ast.py index fee6f2c..60f4a4e 100644 --- a/tests/test_query_ast.py +++ b/tests/test_query_ast.py @@ -1,6 +1,16 @@ import polars as pl -from src.utils.query_ast import And, Condition, Not, Or, ast_to_sql, filtermodel_to_ast +from src.utils.query_ast import ( + And, + Condition, + Not, + Or, + ast_from_dict, + ast_to_dict, + ast_to_sql, + filtermodel_to_ast, + sort_model_to_sql, +) SCHEMA = pl.Schema( { @@ -198,3 +208,30 @@ def test_filtermodel_multiple_columns_are_anded(): } sql, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA) assert " AND " in sql and set(params) == {"%voirie%", 1000.0} + + +def test_sort_model_to_sql(): + sm = [{"colId": "montant", "sort": "desc"}, {"colId": "dureeMois", "sort": "asc"}] + out = sort_model_to_sql(sm, SCHEMA) + assert out == '"montant" DESC NULLS LAST, "dureeMois" ASC NULLS LAST' + + +def test_sort_model_empty(): + assert sort_model_to_sql(None, SCHEMA) == "" + assert sort_model_to_sql([], SCHEMA) == "" + + +def test_ast_dict_roundtrip(): + node = And( + [ + Or([Condition("objet", "contains", "beton")]), + Not(Condition("objet", "contains", "x")), + ] + ) + restored = ast_from_dict(ast_to_dict(node)) + assert ast_to_sql(restored, SCHEMA) == ast_to_sql(node, SCHEMA) + + +def test_ast_dict_none(): + assert ast_to_dict(None) is None + assert ast_from_dict(None) is None From 2ce52d9eab27e528a0bcca0f90607485f0b53921 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 08:00:59 +0200 Subject: [PATCH 22/32] feat(grid): fetch_grid_page datasource server-side AG Grid (#41) --- src/utils/grid.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_grid.py | 24 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/utils/grid.py create mode 100644 tests/test_grid.py diff --git a/src/utils/grid.py b/src/utils/grid.py new file mode 100644 index 0000000..674b11c --- /dev/null +++ b/src/utils/grid.py @@ -0,0 +1,34 @@ +"""Datasource server-side pour AG Grid (infinite row model).""" + +from src.db import count_marches, query_marches, schema +from src.utils.query_ast import ast_to_sql, filtermodel_to_ast, sort_model_to_sql +from src.utils.table import postprocess_page + + +def fetch_grid_page( + filter_model, + sort_model, + start_row: int, + end_row: int, + base_where_sql: str = "TRUE", + base_params: tuple = (), +) -> tuple[list[dict], int]: + """Renvoie (row_data, total_count) pour un bloc [start_row, end_row).""" + ast = filtermodel_to_ast(filter_model, schema) + filter_sql, filter_params = ast_to_sql(ast, schema) + where_sql = f"({base_where_sql}) AND ({filter_sql})" + params = [*base_params, *filter_params] + + order_by = sort_model_to_sql(sort_model, schema) or None + total = count_marches(where_sql, params) + + limit = max(0, end_row - start_row) + page = query_marches( + where_sql=where_sql, + params=params, + order_by=order_by, + limit=limit, + offset=start_row, + ) + page = postprocess_page(page) + return page.to_dicts(), total diff --git a/tests/test_grid.py b/tests/test_grid.py new file mode 100644 index 0000000..22020be --- /dev/null +++ b/tests/test_grid.py @@ -0,0 +1,24 @@ +from src.utils.grid import fetch_grid_page + + +def test_fetch_grid_page_returns_rows_and_count(): + rows, total = fetch_grid_page(None, None, 0, 20) + assert isinstance(rows, list) + assert isinstance(total, int) + assert total >= len(rows) + if rows: + # postprocess_page ajoute une colonne 'marche' avec un lien + assert "marche" in rows[0] + + +def test_fetch_grid_page_filter_reduces_count(): + _, total_all = fetch_grid_page(None, None, 0, 1) + fm = {"objet": {"filterType": "text", "type": "contains", "filter": "zzzzzznope"}} + rows, total_filtered = fetch_grid_page(fm, None, 0, 20) + assert total_filtered <= total_all + assert rows == [] and total_filtered == 0 + + +def test_fetch_grid_page_offset_slicing(): + rows, _ = fetch_grid_page(None, None, 0, 5) + assert len(rows) <= 5 From 97e1d0b5de894dd23a086304bdd200a52c456aa4 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 08:05:19 +0200 Subject: [PATCH 23/32] feat(grid): columnDefs + fabrique ag_grid (#41) --- src/figures.py | 27 +++++++++++++++++++++ src/utils/grid.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_grid.py | 20 +++++++++++++++- 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/figures.py b/src/figures.py index 77a2d07..974e902 100644 --- a/src/figures.py +++ b/src/figures.py @@ -2,6 +2,7 @@ import math from datetime import datetime from typing import Literal +import dash_ag_grid as dag import dash_bootstrap_components as dbc import dash_leaflet as dl import dash_leaflet.express as dlx @@ -1066,3 +1067,29 @@ def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool = tooltip_header=tooltip, filter_action="native" if filters else "none", ) + + +def ag_grid(grid_id: str, column_defs: list[dict]) -> "dag.AgGrid": + """Grille AG Grid server-side (infinite) pour la page Tableau. + + Apparence de base d'AG Grid (aucun thème custom au Lot 1). + """ + return dag.AgGrid( + id=grid_id, + columnDefs=column_defs, + defaultColDef={"resizable": True, "minWidth": 120, "floatingFilter": True}, + rowModelType="infinite", + dangerously_allow_code=True, # rend le HTML des cellules liens + dashGridOptions={ + "cacheBlockSize": 100, + "maxBlocksInCache": 10, + "rowBuffer": 0, + "infiniteInitialRowCount": 100, + "suppressCellFocus": True, + }, + columnSize="responsiveSizeToFit", + style={"height": "70vh", "width": "100%"}, + persistence=True, + persistence_type="local", + persisted_props=["filterModel", "columnState"], + ) diff --git a/src/utils/grid.py b/src/utils/grid.py index 674b11c..f175b97 100644 --- a/src/utils/grid.py +++ b/src/utils/grid.py @@ -1,6 +1,9 @@ """Datasource server-side pour AG Grid (infinite row model).""" +import polars as pl + from src.db import count_marches, query_marches, schema +from src.figures import DATA_SCHEMA from src.utils.query_ast import ast_to_sql, filtermodel_to_ast, sort_model_to_sql from src.utils.table import postprocess_page @@ -32,3 +35,60 @@ def fetch_grid_page( ) page = postprocess_page(page) return page.to_dicts(), total + + +_LINK_COLUMNS = { + "marche", + "uid", + "acheteur_id", + "acheteur_nom", + "titulaire_id", + "titulaire_nom", + "sourceFile", +} + + +def _filter_for(col_type) -> str: + if col_type.is_numeric(): + return "agNumberColumnFilter" + if col_type == pl.Date: + return "agDateColumnFilter" + return "agTextColumnFilter" + + +def grid_column_defs(hidden_columns=None): + """columnDefs dérivés du schéma DuckDB. + + 'marche' (colonne loupe ajoutée par postprocess_page) est placée en tête. + """ + hidden = set(hidden_columns or []) + defs = [ + { + "field": "marche", + "headerName": "", + "cellRenderer": "markdown", + "filter": False, + "sortable": False, + "maxWidth": 60, + "pinned": "left", + } + ] + for col in schema.names(): + meta = DATA_SCHEMA.get(col, {}) + col_type = schema[col] + col_def = { + "field": col, + "headerName": meta.get("title", col), + "filter": _filter_for(col_type), + "floatingFilter": True, + "sortable": True, + "hide": col in hidden, + } + if meta.get("description"): + col_def["headerTooltip"] = ( + f"{meta.get('title', col)} ({col}) — {meta['description']}" + ) + if col in _LINK_COLUMNS: + col_def["cellRenderer"] = "markdown" + defs.append(col_def) + return defs diff --git a/tests/test_grid.py b/tests/test_grid.py index 22020be..9d603ac 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -1,4 +1,22 @@ -from src.utils.grid import fetch_grid_page +from src.utils.grid import fetch_grid_page, grid_column_defs + + +def test_column_defs_have_field_and_filter(): + defs = grid_column_defs(hidden_columns=[]) + by_field = {d["field"]: d for d in defs} + assert "objet" in by_field + # filtre texte par défaut + assert by_field["objet"]["filter"] == "agTextColumnFilter" + # montant est numérique + assert by_field["montant"]["filter"] == "agNumberColumnFilter" + # headerTooltip présent (définition de colonne) + assert "headerTooltip" in by_field["objet"] + + +def test_column_defs_hidden_flag(): + defs = grid_column_defs(hidden_columns=["objet"]) + by_field = {d["field"]: d for d in defs} + assert by_field["objet"]["hide"] is True def test_fetch_grid_page_returns_rows_and_count(): From 4c1c1cc987497aef5da0573cfe10a9c94553cddb Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 08:11:06 +0200 Subject: [PATCH 24/32] feat(tableau): grille AG Grid + datasource getRows server-side (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace le composant DataTable et le callback update_table de tableau.py par la grille dash-ag-grid (mode ligne infini) et un callback get_rows_tableau piloté par getRowsRequest/getRowsResponse, plus un callback update_meta qui synchronise nb_rows/btn-download-data/download-hint via un dcc.Store du total. Les autres callbacks de la page (sélecteur de colonnes, vues sauvegardées, export, restauration URL, aide) référencent encore les anciens id/props (tableau_datatable, hidden_columns, filter_query, sort_by) et seront mis à jour dans les tâches suivantes (8-11) du plan de migration #41. --- src/pages/tableau.py | 84 ++++++++++++++++++-------------------------- 1 file changed, 35 insertions(+), 49 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 80999c0..c50dc6c 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -21,11 +21,12 @@ from dash import ( from flask_login import current_user from src.db import query_marches, schema -from src.figures import DataTable, make_column_picker +from src.figures import ag_grid, make_column_picker from src.pages._compte_shell import current_user_has_subscription from src.saved_views import db as saved_views_db from src.saved_views import ui as saved_views_ui from src.utils import get_data_update_timestamp, logger +from src.utils.grid import fetch_grid_page, grid_column_defs from src.utils.seo import META_CONTENT from src.utils.table import ( COLUMNS, @@ -33,7 +34,6 @@ from src.utils.table import ( filter_table_data, get_default_hidden_columns, invert_columns, - prepare_table_data, sort_table_data, write_styled_excel, ) @@ -64,17 +64,8 @@ register_page( DATATABLE = html.Div( className="marches_table", - children=DataTable( - dtid="tableau_datatable", - persisted_props=["filter_query", "sort_by"], - persistence_type="local", - persistence=True, - page_size=20, - page_action="custom", - filter_action="custom", - sort_action="custom", - hidden_columns=[], - columns=[{"id": col, "name": col} for col in schema.names()], + children=ag_grid( + "tableau_grid", grid_column_defs(get_default_hidden_columns("tableau")) ), ) @@ -139,6 +130,7 @@ layout = [ dcc.Store(id="filter-cleanup-trigger-tableau"), dcc.Store(id="tableau-hidden-columns", storage_type="local"), dcc.Store(id="tableau-table"), + dcc.Store(id="tableau-total"), html.Script( type="application/ld+json", id="dataset_jsonld", @@ -434,48 +426,42 @@ layout = [ @callback( - Output("tableau_datatable", "data"), - Output("tableau_datatable", "columns"), - Output("tableau_datatable", "tooltip_header"), - Output("tableau_datatable", "data_timestamp"), - Output("nb_rows", "children"), - Output("btn-download-data", "disabled"), - Output("btn-download-data", "children"), - Output("btn-download-data", "title"), - Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True), - Output("download-hint", "children"), - Input("tableau_url", "href"), - Input("tableau_datatable", "page_current"), - Input("tableau_datatable", "page_size"), - Input("tableau_datatable", "filter_query"), - Input("tableau_datatable", "sort_by"), - State("tableau_datatable", "data_timestamp"), + Output("tableau_grid", "getRowsResponse"), + Output("tableau-total", "data"), + Input("tableau_grid", "getRowsRequest"), prevent_initial_call=True, ) -def update_table(href, page_current, page_size, filter_query, sort_by, data_timestamp): - result = list( - prepare_table_data( - None, - data_timestamp, - filter_query, - page_current, - page_size, - sort_by, - "tableau", - ) +def get_rows_tableau(request): + if request is None: + return no_update, no_update + filter_model = request.get("filterModel") or None + sort_model = request.get("sortModel") or None + if filter_model: + track_search(json.dumps(filter_model), "tableau") + rows, total = fetch_grid_page( + filter_model, + sort_model, + request.get("startRow", 0), + request.get("endRow", 100), ) - # Libellé court et constant ; la raison d'un éventuel blocage est affichée - # en clair dans la ligne d'infos (fiable cross-browser, contrairement à une - # infobulle sur bouton désactivé). index 5 = disabled, 6 = children, 7 = title. - result[6] = "Télécharger (Excel)" - download_blocked_too_many = result[5] and result[7] - download_hint = ( + return {"rowData": rows, "rowCount": total}, total + + +@callback( + Output("nb_rows", "children"), + Output("btn-download-data", "disabled"), + Output("download-hint", "children"), + Input("tableau-total", "data"), +) +def update_meta(total): + total = total or 0 + too_many = total > 65000 + hint = ( " · Filtrez sous 65 000 lignes pour activer le téléchargement" - if download_blocked_too_many + if too_many else "" ) - result.append(download_hint) - return tuple(result) + return f"{total} lignes", too_many, hint @callback( From 436906911b8e904bee123620b9073fc2e22c9751 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 08:17:44 +0200 Subject: [PATCH 25/32] =?UTF-8?q?feat(tableau):=20s=C3=A9lecteur=20de=20co?= =?UTF-8?q?lonnes=20pilot=C3=A9=20par=20columnDefs=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply_hidden_columns régénère columnDefs (via grid_column_defs) sur tableau_grid quand le Store tableau-hidden-columns change, remplaçant store_hidden_columns qui ciblait l'ancienne prop hidden_columns de la DataTable (supprimée en tâche 7). update_checkboxes_from_hidden_columns lit désormais directement le Store plutôt que la prop de la DataTable disparue, pour que la synchronisation cases à cocher <-> colonnes masquées continue de fonctionner. Co-Authored-By: Claude Sonnet 5 --- src/pages/tableau.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index c50dc6c..09a6aad 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -636,21 +636,18 @@ def update_hidden_columns_from_checkboxes(selected_columns): @callback( - Output("tableau_datatable", "hidden_columns"), - Input( - "tableau-hidden-columns", - "data", - ), + Output("tableau_grid", "columnDefs"), + Input("tableau-hidden-columns", "data"), ) -def store_hidden_columns(hidden_columns): +def apply_hidden_columns(hidden_columns): if hidden_columns is None: hidden_columns = get_default_hidden_columns("tableau") - return hidden_columns + return grid_column_defs(hidden_columns) @callback( Output("tableau_column_list", "selected_rows"), - Input("tableau_datatable", "hidden_columns"), + Input("tableau-hidden-columns", "data"), State("tableau_column_list", "selected_rows"), # pour éviter la boucle infinie ) def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes): From fbea2ebd53313568d9794789481b04808dc89d30 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 08:26:10 +0200 Subject: [PATCH 26/32] feat(tableau): export Excel via DuckDB (AST->SQL) (#41) Rewrite download_data to read filterModel/columnState from the AG Grid component and add export_dataframe (compiles filterModel to SQL via filtermodel_to_ast/ast_to_sql, builds ORDER BY, excludes hidden columns, queries DuckDB via query_marches). Replaces the old Polars filter_query pipeline tied to the removed DataTable. Co-Authored-By: Claude Sonnet 5 --- src/pages/tableau.py | 35 +++++++++++++++-------------------- src/utils/grid.py | 17 +++++++++++++++++ tests/test_grid.py | 13 ++++++++++++- tests/test_main.py | 2 +- 4 files changed, 45 insertions(+), 22 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 09a6aad..02339d3 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -5,7 +5,6 @@ import uuid from datetime import datetime import dash_bootstrap_components as dbc -import polars as pl from dash import ( ClientsideFunction, Input, @@ -20,7 +19,7 @@ from dash import ( ) from flask_login import current_user -from src.db import query_marches, schema +from src.db import schema from src.figures import ag_grid, make_column_picker from src.pages._compte_shell import current_user_has_subscription from src.saved_views import db as saved_views_db @@ -31,10 +30,8 @@ from src.utils.seo import META_CONTENT from src.utils.table import ( COLUMNS, build_view_query, - filter_table_data, get_default_hidden_columns, invert_columns, - sort_table_data, write_styled_excel, ) from src.utils.tracking import track_search @@ -467,27 +464,25 @@ def update_meta(total): @callback( Output("download-data", "data"), Input("btn-download-data", "n_clicks"), - State("tableau_datatable", "filter_query"), - State("tableau_datatable", "sort_by"), - State("tableau_datatable", "hidden_columns"), + State("tableau_grid", "filterModel"), + State("tableau_grid", "columnState"), prevent_initial_call=True, ) -def download_data(n_clicks, filter_query, sort_by, hidden_columns: list | None = None): - lff: pl.LazyFrame = query_marches().lazy() +def download_data(n_clicks, filter_model, column_state): + from src.utils.grid import export_dataframe - # Les colonnes masquées sont supprimées - if hidden_columns: - lff = lff.drop(hidden_columns) - - if filter_query: - track_search(filter_query, "tab download") - lff = filter_table_data(lff, filter_query) - - if sort_by and len(sort_by) > 0: - lff = sort_table_data(lff, sort_by) + sort_model = [ + {"colId": c["colId"], "sort": c["sort"]} + for c in (column_state or []) + if c.get("sort") + ] + hidden_columns = [c["colId"] for c in (column_state or []) if c.get("hide")] + if filter_model: + track_search(json.dumps(filter_model), "tab download") + df = export_dataframe(filter_model, sort_model, hidden_columns) def to_bytes(buffer): - write_styled_excel(lff.collect(engine="streaming"), buffer) + write_styled_excel(df, buffer) date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx") diff --git a/src/utils/grid.py b/src/utils/grid.py index f175b97..c3e125b 100644 --- a/src/utils/grid.py +++ b/src/utils/grid.py @@ -37,6 +37,23 @@ def fetch_grid_page( return page.to_dicts(), total +def export_dataframe(filter_model, sort_model, hidden_columns) -> pl.DataFrame: + """Renvoie les lignes filtrées/triées pour l'export Excel. + + Colonnes masquées exclues, valeurs brutes (non post-traitées HTML). + """ + ast = filtermodel_to_ast(filter_model, schema) + filter_sql, params = ast_to_sql(ast, schema) + order_by = sort_model_to_sql(sort_model, schema) or None + visible = [c for c in schema.names() if c not in set(hidden_columns or [])] + return query_marches( + where_sql=filter_sql, + params=params, + columns=visible, + order_by=order_by, + ) + + _LINK_COLUMNS = { "marche", "uid", diff --git a/tests/test_grid.py b/tests/test_grid.py index 9d603ac..8303966 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -1,4 +1,4 @@ -from src.utils.grid import fetch_grid_page, grid_column_defs +from src.utils.grid import export_dataframe, fetch_grid_page, grid_column_defs def test_column_defs_have_field_and_filter(): @@ -40,3 +40,14 @@ def test_fetch_grid_page_filter_reduces_count(): def test_fetch_grid_page_offset_slicing(): rows, _ = fetch_grid_page(None, None, 0, 5) assert len(rows) <= 5 + + +def test_export_dataframe_excludes_hidden_columns(): + df = export_dataframe(None, None, hidden_columns=["objet"]) + assert "objet" not in df.columns + + +def test_export_dataframe_applies_filter(): + fm = {"objet": {"filterType": "text", "type": "contains", "filter": "zzzzzznope"}} + df = export_dataframe(fm, None, hidden_columns=[]) + assert df.height == 0 diff --git a/tests/test_main.py b/tests/test_main.py index b458dc3..0c9f1b9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -99,7 +99,7 @@ def test_003_tableau_download(dash_duo: DashComposite): print(app.server.name) outputs = [ - download_data(1, "", [], None), + download_data(1, None, None), download_acheteur_data(1, "/acheteurs/123", "2025", "ACHETEUR 1"), download_titulaire_data(1, "/titulaires/345", "2025", "TITULAIRE 1"), ] From 8efd8db07eb33a3df07959d3c2164d01f38e19e2 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 08:35:31 +0200 Subject: [PATCH 27/32] =?UTF-8?q?feat(tableau):=20vues=20sauvegard=C3=A9es?= =?UTF-8?q?=20en=20JSON=20AST,=20retrait=20URL=20riche=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les vues stockent désormais {filterModel, columnState} en JSON dans saved_views.query au lieu d'une query string DSL. Le rappel d'une vue applique filterModel + columnState à la grille AG Grid via un nouveau callback apply_saved_view, déclenché par pattern-matching sur les items du menu "Mes vues" (désormais cliquables au lieu de liens href). Retire restore_view_from_url, sync_url_and_reset_button, show_confirmation, le bouton "Partager la vue" et le clientside clean_filters de tableau.py - la migration AG Grid retire le mécanisme d'URL riche sans rétrocompatibilité. Le lien "Ouvrir" de compte/vues.py pointe maintenant vers /tableau nu (limitation connue documentée, le rappel cross-page reste à faire). --- src/pages/tableau.py | 163 +++++++---------------------------- src/saved_views/ui.py | 11 ++- tests/saved_views/test_ui.py | 5 +- 3 files changed, 41 insertions(+), 138 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 02339d3..ba9740b 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -1,17 +1,16 @@ import json import os -import urllib.parse -import uuid from datetime import datetime import dash_bootstrap_components as dbc from dash import ( - ClientsideFunction, + ALL, Input, Output, State, callback, clientside_callback, + ctx, dcc, html, no_update, @@ -24,14 +23,12 @@ from src.figures import ag_grid, make_column_picker from src.pages._compte_shell import current_user_has_subscription from src.saved_views import db as saved_views_db from src.saved_views import ui as saved_views_ui -from src.utils import get_data_update_timestamp, logger +from src.utils import get_data_update_timestamp from src.utils.grid import fetch_grid_page, grid_column_defs from src.utils.seo import META_CONTENT from src.utils.table import ( COLUMNS, - build_view_query, get_default_hidden_columns, - invert_columns, write_styled_excel, ) from src.utils.tracking import track_search @@ -124,7 +121,6 @@ def _help_button_legend(): layout = [ dcc.Location(id="tableau_url", refresh=False), - dcc.Store(id="filter-cleanup-trigger-tableau"), dcc.Store(id="tableau-hidden-columns", storage_type="local"), dcc.Store(id="tableau-table"), dcc.Store(id="tableau-total"), @@ -364,8 +360,6 @@ layout = [ ), ], ), - html.Div(id="copy-container"), - dcc.Input(id="share-url", readOnly=True, style={"display": "none"}), dbc.Button( "Télécharger (Excel)", id="btn-download-data", @@ -488,123 +482,6 @@ def download_data(n_clicks, filter_model, column_state): return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx") -@callback( - Output("tableau_datatable", "filter_query"), - Output("tableau_datatable", "sort_by"), - Output("tableau-hidden-columns", "data"), - Output("tableau_url", "search"), - Output("filter-cleanup-trigger-tableau", "data"), - Input("tableau_url", "search"), - State("tableau_datatable", "filter_query"), - State("tableau_datatable", "sort_by"), -) -def restore_view_from_url(search, stored_filters, stored_sort): - if not search and not stored_filters: - return no_update, no_update, no_update, no_update, no_update - - params = urllib.parse.parse_qs(search.lstrip("?")) if search else {} - logger.debug("params " + json.dumps(params, indent=2)) - - filter_query = no_update - sort_by = no_update - hidden_columns = no_update - trigger_cleanup = no_update - - if "filtres" in params: - filter_query = params["filtres"][0] - trigger_cleanup = str(uuid.uuid4()) - elif stored_filters: - filter_query = stored_filters - trigger_cleanup = str(uuid.uuid4()) - - if "tris" in params: - try: - sort_by = json.loads(params["tris"][0]) - except json.JSONDecodeError: - pass - elif stored_sort: - sort_by = stored_sort - - if "colonnes" in params: - table_columns = params["colonnes"][0].split(",") - verified_columns = [ - column for column in table_columns if column in schema.names() - ] - hidden_columns = invert_columns(verified_columns) - - return filter_query, sort_by, hidden_columns, "", trigger_cleanup - - -# Pour nettoyer les icontains et i< des filtres -# voir aussi src/assets/dash_clientside.js -clientside_callback( - ClientsideFunction( - namespace="clientside", - function_name="clean_filters", - ), - Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True), - Input("filter-cleanup-trigger-tableau", "data"), - prevent_initial_call=True, -) - - -@callback( - Output("share-url", "value"), - Output("copy-container", "children"), - Input("tableau_datatable", "filter_query"), - Input("tableau_datatable", "sort_by"), - Input("tableau_datatable", "hidden_columns"), - State("tableau_url", "href"), - prevent_initial_call=True, -) -def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href): - if not href: - return no_update, no_update - - # Extract base URL (remove existing query params) - base_url = href.split("?")[0] - - query_string = build_view_query(filter_query, sort_by, hidden_columns) - full_url = f"{base_url}?{query_string}" if query_string else base_url - - copy_button = dcc.Clipboard( - id="btn-copy-url", - target_id="share-url", - title="Copier l'URL de cette vue", - style={ - "display": "inline-block", - "fontSize": 20, - "verticalAlign": "top", - "cursor": "pointer", - }, - className="fa fa-link", - children=[ - dbc.Button( - "Partager la vue", - color="secondary", - size="sm", - title="Copier l'adresse de cette vue (filtres, tris, choix de colonnes) pour la partager.", - ) - ], - ) - - return full_url, copy_button - - -@callback( - Output("copy-container", "children", allow_duplicate=True), - Input("btn-copy-url", "n_clicks", allow_optional=True), - prevent_initial_call=True, -) -def show_confirmation(n_clicks): - if n_clicks: - return html.Span( - "Adresse de la vue copiée", - style={"color": "green", "fontWeight": "bold", "marginLeft": "10px"}, - ) - return no_update - - @callback( Output("tableau_help", "is_open"), [Input("tableau_help_open", "n_clicks"), Input("tableau_help_close", "n_clicks")], @@ -666,13 +543,12 @@ def toggle_tableau_columns(click_open, click_close, is_open): @callback( - Output("tableau_datatable", "filter_query", allow_duplicate=True), - Output("tableau_datatable", "sort_by", allow_duplicate=True), + Output("tableau_grid", "filterModel", allow_duplicate=True), Input("btn-tableau-reset", "n_clicks"), prevent_initial_call=True, ) def reset_view(n_clicks): - return "", [] + return {} @callback( @@ -698,17 +574,18 @@ def toggle_save_view_modal(_open): Output("saved-views-refresh", "data"), Input("btn-save-view-confirm", "n_clicks"), State("save-view-name", "value"), - State("tableau_datatable", "filter_query"), - State("tableau_datatable", "sort_by"), - State("tableau_datatable", "hidden_columns"), + State("tableau_grid", "filterModel"), + State("tableau_grid", "columnState"), prevent_initial_call=True, ) -def save_view(_n, name, filter_query, sort_by, hidden_columns): +def save_view(_n, name, filter_model, column_state): has_sub = current_user_has_subscription() clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name) if error: return True, html.Span(error, style={"color": "red"}), no_update - query = build_view_query(filter_query, sort_by, hidden_columns) + query = json.dumps( + {"filterModel": filter_model or {}, "columnState": column_state or []} + ) saved_views_db.upsert(current_user.id, "tableau", clean_name, query) return ( False, @@ -729,6 +606,24 @@ def populate_saved_views_menu(_pathname, _refresh): return saved_views_ui.saved_views_items(views) +@callback( + Output("tableau_grid", "filterModel"), + Output("tableau_grid", "columnState"), + Input({"type": "saved-view-item", "index": ALL}, "n_clicks"), + State({"type": "saved-view-item", "index": ALL}, "id"), + prevent_initial_call=True, +) +def apply_saved_view(n_clicks, ids): + triggered = ctx.triggered_id + if not triggered or not any(n_clicks): + return no_update, no_update + row = saved_views_db.get(triggered["index"], current_user.id) + if not row: + return no_update, no_update + view = json.loads(row["query"]) + return view.get("filterModel") or {}, view.get("columnState") or [] + + @callback( Output("overwrite-view-select", "options"), Input("save-view-modal", "is_open"), diff --git a/src/saved_views/ui.py b/src/saved_views/ui.py index 1b17f8b..e253f20 100644 --- a/src/saved_views/ui.py +++ b/src/saved_views/ui.py @@ -23,7 +23,11 @@ def prepare_view_to_save( def saved_views_items(views) -> list: return [ - dbc.DropdownMenuItem(view["name"], href=f"/tableau?{view['query']}") + dbc.DropdownMenuItem( + view["name"], + id={"type": "saved-view-item", "index": view["id"]}, + n_clicks=0, + ) for view in views ] @@ -35,8 +39,11 @@ def _view_row(view) -> html.Div: children=[ html.Span(view["name"], className="flex-grow-1"), dbc.Button( + # Limitation connue : la vue n'est pas appliquée automatiquement + # en arrivant sur /tableau depuis cette page. Le mécanisme de + # rappel multi-page reste à faire (voir tableau.py `apply_saved_view`). "Ouvrir", - href=f"/tableau?{view['query']}", + href="/tableau", color="link", size="sm", ), diff --git a/tests/saved_views/test_ui.py b/tests/saved_views/test_ui.py index 88a1678..ad7f7aa 100644 --- a/tests/saved_views/test_ui.py +++ b/tests/saved_views/test_ui.py @@ -38,13 +38,14 @@ def test_prepare_accepts_valid(): assert err is None -def test_saved_views_items_build_links(): +def test_saved_views_items_build_clickable_entries(): items = ui.saved_views_items( [_view(1, "Vue A", "filtres=a"), _view(2, "Vue B", "tris=b")] ) assert len(items) == 2 - assert items[0].href == "/tableau?filtres=a" + assert items[0].id == {"type": "saved-view-item", "index": 1} assert items[0].children == "Vue A" + assert items[1].id == {"type": "saved-view-item", "index": 2} def test_views_table_empty_state(): From 055b0edc7e7a1bb1a2bccc283dc8d0cae7741d36 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 13:53:19 +0200 Subject: [PATCH 28/32] =?UTF-8?q?docs(tableau):=20mode=20d'emploi=20r?= =?UTF-8?q?=C3=A9=C3=A9crit=20pour=20AG=20Grid=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Décrit les filtres de colonne AG Grid (flottant/entonnoir, texte/numérique/ date, combinaison ET/OU), le tri multi-colonne (Maj+clic) et le défilement infini, à la place de l'ancienne syntaxe icontains/i et des liens d'exemple ?filtres=... (mécanisme retiré à la tâche 10). Retire la ligne "Partager la vue" de la légende des boutons (bouton supprimé). Co-Authored-By: Claude Sonnet 5 --- src/pages/tableau.py | 48 ++++++++++++++++++-------------------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index ba9740b..d7d8068 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -80,10 +80,6 @@ def _help_button_legend(): dbc.Button("Mes vues ▾", color="secondary", size="sm"), "Rouvrir une vue que vous avez enregistrée (abonnés).", ), - ( - dbc.Button("Partager la vue", color="secondary", size="sm"), - "Copier l'adresse de la vue actuelle pour la partager ou la conserver.", - ), ( dbc.Button("Télécharger (Excel)", color="secondary", size="sm"), "Télécharger les données filtrées et triées au format Excel.", @@ -186,7 +182,7 @@ layout = [ ], ), dcc.Markdown( - f"Ce tableau contient tous les marchés attribués en France. Il vous permet d'appliquer un filtre sur une ou plusieurs colonnes, et ainsi produire la liste de marchés dont vous avez besoin (exemples : [marchés de voirie < 40 k€ en 2025](/tableau?filtres=%7Bacheteur_id%7D+icontains+24350013900189+%26%26+%7BdateNotification%7D+icontains+2025%2A+%26%26+%7Bmontant%7D+i%3C+40000+%26%26+%7Bobjet%7D+icontains+voirie&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2Cacheteur_departement_code%2CsourceDataset), [marchés > 500 k€ avec clause sociale attribués à des PME à plus de 100 km dans le Var](/tableau?filtres=%7Btitulaire_categorie%7D+icontains+PME+%26%26+%7Btitulaire_distance%7D+i%3E+100+%26%26+%7Bmontant%7D+i%3E+500000+%26%26+%7Bacheteur_departement_code%7D+icontains+83+%26%26+%7BconsiderationsSociales%7D+icontains+clause&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2CconsiderationsSociales%2Ctitulaire_distance%2Cacheteur_departement_code%2Ctitulaire_categorie%2CsourceDataset)). Par défaut seules quelques colonnes sont affichées, mais vous pouvez en afficher jusqu'à {len(schema.names())} en cliquant sur le bouton **Colonnes**. Cet outil est assez puissant, je vous recommande de lire le mode d'emploi pour en tirer pleinement partie.", + f"Ce tableau contient tous les marchés attribués en France. Il vous permet d'appliquer un filtre sur une ou plusieurs colonnes, et ainsi produire la liste de marchés dont vous avez besoin. Par défaut seules quelques colonnes sont affichées, mais vous pouvez en afficher jusqu'à {len(schema.names())} en cliquant sur le bouton **Colonnes**. Cet outil est assez puissant, je vous recommande de lire le mode d'emploi pour en tirer pleinement partie.", style={"maxWidth": "1000px"}, ), html.Div( @@ -226,51 +222,45 @@ layout = [ Les filtres, les tris et le choix de colonnes sont automatiquement enregistrés dans votre navigateur et persistent même si vous changez de page ou si vous fermez votre navigateur. À votre retour, vous retrouverez cette page comme vous l'avez laissée. - ##### Appliquer des filtres + ##### Filtrer les colonnes - Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`. + Chaque colonne a son propre filtre : saisissez une valeur dans le champ situé juste sous son en-tête (le filtre « flottant »), ou cliquez sur l'icône entonnoir dans l'en-tête pour ouvrir le filtre complet. - - Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché, n'est sensible ni à la casse (majuscules/minuscules), ni à l'accentuation. - - `rennes` => le texte contient "rennes" - - `metro* *pole` => le texte contient un mot qui commence par "metro" et un mot qui finit par "pole" - - `metropole rennes` => le texte contient les mots "metropole" et "rennes", n'importe où dans le texte - - `métropole+rennes` => le texte contient "metropole rennes" ou "métropole rennes", collé et dans cet ordre - - `metropole+rennes travaux distri*` => le texte contient "metropole rennes", "travaux" et un mot qui commence par "distri" - - Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances` - - Champs numériques (Durée en mois, Montant, ...) : vous pouvez... - - soit taper un nombre pour trouver les valeurs strictement égales. Exemple : `12` ne retourne que des 12 - - soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres. - - Champs date (Date de notification, ...) : - - `< 2024-01-31` pour "avant le 31 janvier 2024" - - `2024` pour "en 2024", `> 2022` pour "à partir de 2022" + - Champs textuels : contient (par défaut), égal à, ne contient pas, commence par, se termine par... + - Champs numériques (Durée en mois, Montant, nombre d'offres...) : égal à, supérieur à, inférieur à, entre (plage)... + - Champs date (Date de notification...) : égal à, avant, après, entre (plage)... - Vous pouvez filtrer plusieurs colonnes à la fois. + Dans le filtre complet (icône entonnoir), vous pouvez combiner deux conditions sur la même colonne avec **ET** ou **OU**. + + Vous pouvez filtrer plusieurs colonnes à la fois ; les filtres de colonnes différentes se cumulent toujours (ET). ##### Trier les données - Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre : + Cliquez sur l'en-tête d'une colonne pour la trier. Chaque clic change le tri dans cet ordre : 1. tri croissant 2. tri décroissant 3. pas de tri - Les tris sont appliqués dans l'ordre : la première colonne que vous triez a la priorité sur la seconde, qui triera uniquement au sein des groupes de valeurs de la première colonne. + Pour trier sur plusieurs colonnes à la fois, maintenez la touche `Maj` (Shift) enfoncée en cliquant sur les en-têtes suivants : la première colonne triée a la priorité, la suivante ne départage qu'au sein des groupes de valeurs identiques de la précédente, et ainsi de suite. + + ##### Défilement + + Le tableau charge les lignes au fur et à mesure que vous faites défiler la page, plutôt que par pages numérotées. Les en-têtes de colonnes (et leurs filtres) restent toujours visibles en haut du tableau pendant le défilement. ##### Afficher plus de colonnes Par défaut, un nombre réduit de colonnes est affiché pour ne pas surcharger la page. Mais vous avez le choix parmi {len(schema.names())} colonnes, ce serait dommage de vous limiter ! - Pour afficher plus de colonnes, cliquez sur le bouton **Choisir les colonnes** et cochez les colonnes pour les afficher. + Pour afficher plus de colonnes, cliquez sur le bouton **Colonnes** et cochez les colonnes à afficher. - ##### Partager une vue + ##### Vues sauvegardées (abonnés) - Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqués. Cliquez sur **Partager** pour copier une adresse Web qui reproduit la vue courante à l'identique : en la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres. - - Pratique pour partager une vue avec un·e collègue, sur les réseaux sociaux, ou la sauvegarder pour plus tard. + Une vue est un ensemble de filtres, de tris et de colonnes affichées que vous avez appliqués. Si vous êtes abonné, le bouton **Sauvegarder la vue** vous permet d'enregistrer la configuration actuelle sous un nom, et le menu **Mes vues** de la rappeler d'un clic plus tard. ##### Télécharger le résultat - Vous pouvez télécharger le résultat de vos filtres et tris, pour les colonnes affichées, en cliquant sur **Télécharger au format Excel**. + Vous pouvez télécharger le résultat de vos filtres et tris, pour les colonnes affichées, en cliquant sur **Télécharger (Excel)**. ##### Liens From c81533aa8e74bb99ca4d90d32052c02657a75d60 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 14:13:43 +0200 Subject: [PATCH 29/32] =?UTF-8?q?test(tableau):=20int=C3=A9gration=20AG=20?= =?UTF-8?q?Grid=20end-to-end=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vérifie via Selenium/DashComposite que /tableau charge et affiche la grille AG Grid avec au moins une ligne rendue, sans erreur console SEVERE. Dernière tâche de la migration DataTable -> AG Grid. --- tests/test_tableau_ag_grid.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/test_tableau_ag_grid.py diff --git a/tests/test_tableau_ag_grid.py b/tests/test_tableau_ag_grid.py new file mode 100644 index 0000000..179cce6 --- /dev/null +++ b/tests/test_tableau_ag_grid.py @@ -0,0 +1,16 @@ +from dash.testing.composite import DashComposite + + +def test_tableau_grid_loads_and_filters(dash_duo: DashComposite): + """La page /tableau charge et affiche la grille AG Grid avec au moins une ligne.""" + from src.app import app + + dash_duo.start_server(app) + dash_duo.wait_for_page(f"{dash_duo.server_url}/tableau") + # La grille AG Grid est présente + dash_duo.wait_for_element(".ag-root", timeout=8) + # Au moins une ligne rendue (row) après chargement du premier bloc + dash_duo.wait_for_element(".ag-center-cols-container .ag-row", timeout=8) + assert dash_duo.get_logs() == [] or all( + "SEVERE" not in log["level"] for log in dash_duo.get_logs() + ) From e59e2e50b37d205b2eaeb62c29b6bd4f225ba48a Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 14:20:27 +0200 Subject: [PATCH 30/32] =?UTF-8?q?test:=20adapter=20les=20tests=20DataTable?= =?UTF-8?q?=20historiques=20=C3=A0=20la=20migration=20AG=20Grid=20de=20/ta?= =?UTF-8?q?bleau=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_main.py | 17 ++++++++++++++--- tests/test_tableau_hscroll.py | 13 ++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 0c9f1b9..0f11f55 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -73,7 +73,13 @@ def test_002_filter_persistence(dash_duo: DashComposite): ) return _filter_input_in_view(dash_duo, filter_input_selector) - for page in ["tableau", "acheteurs/123", "titulaires/345"]: + # /tableau utilise désormais AG Grid (dash-ag-grid) au lieu de dash_table.DataTable ; + # la persistance de ses filtres/colonnes est couverte par persistence=True et + # persisted_props=["filterModel", "columnState"] configurés dans la fabrique + # ag_grid() de src/figures.py, et a été vérifiée manuellement via un navigateur + # réel (Task 12 du plan #41). Une couverture Selenium dédiée à AG Grid pourrait + # être ajoutée ultérieurement. + for page in ["acheteurs/123", "titulaires/345"]: filter_input = open_page_and_check_filter_input() filter_input.send_keys("11") # valeur quelconque, on teste la persistance filter_input.send_keys(Keys.ENTER) @@ -347,13 +353,18 @@ def test_014_get_distance_histogram_all_nulls(): assert isinstance(result, dcc.Graph) -def test_015_tableau_filter_date(dash_duo: DashComposite): +def test_015_org_pages_filter_date(dash_duo: DashComposite): from src.app import app dash_duo.start_server(app) dash_duo.wait_for_text_to_equal(".logo > h1", "colibre", timeout=4) - for page in ["tableau", "acheteurs/123", "titulaires/345"]: + # /tableau utilise désormais AG Grid ; le filtrage de sa colonne date est + # couvert par les tests unitaires de compilation SQL dans + # tests/test_query_ast.py (ex. test_date_range_uses_between) et a été + # vérifié manuellement (Task 12 du plan #41). Une couverture Selenium + # dédiée au filtre de date AG Grid pourrait être ajoutée ultérieurement. + for page in ["acheteurs/123", "titulaires/345"]: dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}") filter_input = '.marches_table th[data-dash-column="dateNotification"] input' filter_cell_result = '.marches_table td[data-dash-column="dateNotification"] p' diff --git a/tests/test_tableau_hscroll.py b/tests/test_tableau_hscroll.py index 8bd9920..e9e0e12 100644 --- a/tests/test_tableau_hscroll.py +++ b/tests/test_tableau_hscroll.py @@ -1,12 +1,19 @@ from dash.testing.composite import DashComposite -def test_tableau_hscroll_bar_present(dash_duo: DashComposite): - """La barre de défilement est injectée et le conteneur scroll horizontalement.""" +def test_marches_table_hscroll_bar_present(dash_duo: DashComposite): + """La barre de défilement est injectée et le conteneur scroll horizontalement. + + Testé via /acheteurs/123 : depuis la migration de /tableau vers AG Grid + (qui gère son propre défilement horizontal nativement), cette page-ci ne + rend plus de dash_table.DataTable. .marches_table (et table_hscroll.js) + reste utilisé par acheteur.py, observatoire.py et titulaire.py, donc ce + comportement reste couvert via une de ces pages. + """ from src.app import app dash_duo.start_server(app) - dash_duo.wait_for_page(f"{dash_duo.server_url}/tableau") + dash_duo.wait_for_page(f"{dash_duo.server_url}/acheteurs/123") dash_duo.wait_for_element(".marches_table", timeout=20) # Barre injectée par table_hscroll.js dash_duo.wait_for_element(".marches_table .dt-hscroll", timeout=10) From 0bfac680a5af8d919f73b2171b5cabd578989da3 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 14:34:47 +0200 Subject: [PATCH 31/32] =?UTF-8?q?fix(tableau):=20vues=20sauvegard=C3=A9es?= =?UTF-8?q?=20pr=C3=A9-migration=20+=20double=20comptage=20track=5Fsearch?= =?UTF-8?q?=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- src/pages/tableau.py | 25 ++++++-- tests/saved_views/test_apply_saved_view.py | 71 ++++++++++++++++++++++ tests/test_grid.py | 17 ++++++ 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 tests/saved_views/test_apply_saved_view.py diff --git a/src/pages/tableau.py b/src/pages/tableau.py index d7d8068..9a11642 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -23,7 +23,7 @@ from src.figures import ag_grid, make_column_picker from src.pages._compte_shell import current_user_has_subscription from src.saved_views import db as saved_views_db from src.saved_views import ui as saved_views_ui -from src.utils import get_data_update_timestamp +from src.utils import get_data_update_timestamp, logger from src.utils.grid import fetch_grid_page, grid_column_defs from src.utils.seo import META_CONTENT from src.utils.table import ( @@ -417,7 +417,11 @@ def get_rows_tableau(request): return no_update, no_update filter_model = request.get("filterModel") or None sort_model = request.get("sortModel") or None - if filter_model: + # AG Grid renvoie une nouvelle requête getRowsRequest pour chaque bloc de + # défilement infini, avec le même filterModel tant que le filtre ne change + # pas. Ne compter qu'une recherche par changement de filtre/tri (bloc 0), + # pas une par bloc chargé au défilement. + if filter_model and request.get("startRow", 0) == 0: track_search(json.dumps(filter_model), "tableau") rows, total = fetch_grid_page( filter_model, @@ -610,8 +614,21 @@ def apply_saved_view(n_clicks, ids): row = saved_views_db.get(triggered["index"], current_user.id) if not row: return no_update, no_update - view = json.loads(row["query"]) - return view.get("filterModel") or {}, view.get("columnState") or [] + try: + view = json.loads(row["query"]) + filter_model = view.get("filterModel") or {} + column_state = view.get("columnState") or [] + except (json.JSONDecodeError, TypeError, AttributeError): + # Vue enregistrée avant la migration vers AG Grid (Task 10) : row["query"] + # est encore une query string (ex. "filtres=a&tris=b"), pas du JSON. On + # échoue proprement plutôt que de planter le callback ; pas de + # migration automatique de l'ancien format. + logger.warning( + "Vue sauvegardée au format pré-migration, impossible de l'appliquer : " + f"id={row['id']!r} name={row['name']!r}" + ) + return no_update, no_update + return filter_model, column_state @callback( diff --git a/tests/saved_views/test_apply_saved_view.py b/tests/saved_views/test_apply_saved_view.py new file mode 100644 index 0000000..47f8d6c --- /dev/null +++ b/tests/saved_views/test_apply_saved_view.py @@ -0,0 +1,71 @@ +"""Régression revue finale #41 : apply_saved_view (callback qui RAPPELLE une vue +sauvegardée) ne doit pas planter si row["query"] est encore au format +pré-migration (query string, ex. "filtres=a&tris=b"), stocké par l'ancienne +build_view_query avant que Task 10 ne migre save_view vers du JSON +{"filterModel": ..., "columnState": ...}. +""" + +from unittest.mock import patch + +import dash + +import src.app # noqa: F401 # instancie l'app → register_page() des pages +from src.auth import db as auth_db +from src.pages import tableau +from src.saved_views import db as saved_views_db + + +def _make_user(email="u@ex.fr"): + auth_db.init_schema() + return auth_db.create_user(email, "hash") + + +def _fake_user(user_id, authenticated=True): + user = type("U", (), {})() + user.is_authenticated = authenticated + user.id = user_id + return user + + +class _Ctx: + triggered_id = None + + +def test_apply_saved_view_old_format_returns_no_update(monkeypatch, users_db_path): + saved_views_db.init_schema() + uid = _make_user() + saved_views_db.upsert(uid, "tableau", "Vue historique", "filtres=a&tris=b") + view_id = saved_views_db.list_views(uid, "tableau")[0]["id"] + + _Ctx.triggered_id = {"type": "saved-view-item", "index": view_id} + monkeypatch.setattr(tableau, "ctx", _Ctx) + + with patch.object(tableau, "current_user", _fake_user(uid)): + filter_model, column_state = tableau.apply_saved_view( + [1], [{"type": "saved-view-item", "index": view_id}] + ) + + assert filter_model is dash.no_update + assert column_state is dash.no_update + + +def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path): + saved_views_db.init_schema() + uid = _make_user() + query = ( + '{"filterModel": {"objet": {"filterType": "text", "filter": "route"}}, ' + '"columnState": [{"colId": "montant", "sort": "desc"}]}' + ) + saved_views_db.upsert(uid, "tableau", "Vue récente", query) + view_id = saved_views_db.list_views(uid, "tableau")[0]["id"] + + _Ctx.triggered_id = {"type": "saved-view-item", "index": view_id} + monkeypatch.setattr(tableau, "ctx", _Ctx) + + with patch.object(tableau, "current_user", _fake_user(uid)): + filter_model, column_state = tableau.apply_saved_view( + [1], [{"type": "saved-view-item", "index": view_id}] + ) + + assert filter_model == {"objet": {"filterType": "text", "filter": "route"}} + assert column_state == [{"colId": "montant", "sort": "desc"}] diff --git a/tests/test_grid.py b/tests/test_grid.py index 8303966..89abf48 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -1,3 +1,7 @@ +from unittest.mock import patch + +import src.app # noqa: F401 # instancie l'app → register_page() des pages +from src.pages.tableau import get_rows_tableau from src.utils.grid import export_dataframe, fetch_grid_page, grid_column_defs @@ -51,3 +55,16 @@ def test_export_dataframe_applies_filter(): fm = {"objet": {"filterType": "text", "type": "contains", "filter": "zzzzzznope"}} df = export_dataframe(fm, None, hidden_columns=[]) assert df.height == 0 + + +def test_get_rows_tableau_tracks_search_once_per_filter_not_per_scroll_block(): + """Régression revue finale #41 : AG Grid envoie une getRowsRequest par bloc + de défilement infini, avec le même filterModel tant que le filtre ne + change pas. track_search ne doit être appelé qu'une fois par filtre (au + premier bloc, startRow == 0), pas une fois par bloc défilé.""" + fm = {"objet": {"filterType": "text", "type": "contains", "filter": "route"}} + with patch("src.pages.tableau.track_search") as mocked: + get_rows_tableau({"filterModel": fm, "startRow": 0, "endRow": 100}) + get_rows_tableau({"filterModel": fm, "startRow": 100, "endRow": 200}) + get_rows_tableau({"filterModel": fm, "startRow": 200, "endRow": 300}) + mocked.assert_called_once() From 4f36022443d76e845a0a1c174125dfc69c5d843b Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 14:44:14 +0200 Subject: [PATCH 32/32] =?UTF-8?q?fix(tableau):=20vues=20sauvegard=C3=A9es?= =?UTF-8?q?=20en=20AST=20canonique,=20cache=20du=20comptage,=20synchro=20v?= =?UTF-8?q?isibilit=C3=A9=20colonnes=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/tableau.py | 37 ++++-- src/utils/grid.py | 15 ++- src/utils/query_ast.py | 135 +++++++++++++++++++++ tests/saved_views/test_apply_saved_view.py | 69 +++++++++-- tests/test_grid.py | 51 ++++++++ tests/test_query_ast.py | 101 +++++++++++++++ 6 files changed, 389 insertions(+), 19 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 9a11642..3d7b1b6 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -25,6 +25,12 @@ from src.saved_views import db as saved_views_db from src.saved_views import ui as saved_views_ui from src.utils import get_data_update_timestamp, logger from src.utils.grid import fetch_grid_page, grid_column_defs +from src.utils.query_ast import ( + ast_from_dict, + ast_to_dict, + ast_to_filtermodel, + filtermodel_to_ast, +) from src.utils.seo import META_CONTENT from src.utils.table import ( COLUMNS, @@ -577,9 +583,11 @@ def save_view(_n, name, filter_model, column_state): clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name) if error: return True, html.Span(error, style={"color": "red"}), no_update - query = json.dumps( - {"filterModel": filter_model or {}, "columnState": column_state or []} - ) + # On stocke l'AST canonique (indépendant de l'UI), pas le filterModel brut + # d'AG Grid : cf. spec de conception, "l'AST (JSON) + columnState, + # indépendant de l'UI". + ast = filtermodel_to_ast(filter_model, schema) + query = json.dumps({"ast": ast_to_dict(ast), "columnState": column_state or []}) saved_views_db.upsert(current_user.id, "tableau", clean_name, query) return ( False, @@ -603,6 +611,7 @@ def populate_saved_views_menu(_pathname, _refresh): @callback( Output("tableau_grid", "filterModel"), Output("tableau_grid", "columnState"), + Output("tableau-hidden-columns", "data", allow_duplicate=True), Input({"type": "saved-view-item", "index": ALL}, "n_clicks"), State({"type": "saved-view-item", "index": ALL}, "id"), prevent_initial_call=True, @@ -610,13 +619,19 @@ def populate_saved_views_menu(_pathname, _refresh): def apply_saved_view(n_clicks, ids): triggered = ctx.triggered_id if not triggered or not any(n_clicks): - return no_update, no_update + return no_update, no_update, no_update row = saved_views_db.get(triggered["index"], current_user.id) if not row: - return no_update, no_update + return no_update, no_update, no_update try: view = json.loads(row["query"]) - filter_model = view.get("filterModel") or {} + # L'AST canonique est stocké (pas le filterModel brut d'AG Grid) : + # cf. save_view. `ast_from_dict(None)` -> None et + # `ast_to_filtermodel(None, schema)` -> {} si la vue est d'un ancien + # format (sans clé "ast") : dégradation propre, la vue se rappelle + # sans filtre plutôt que de planter. + ast = ast_from_dict(view.get("ast")) + filter_model = ast_to_filtermodel(ast, schema) column_state = view.get("columnState") or [] except (json.JSONDecodeError, TypeError, AttributeError): # Vue enregistrée avant la migration vers AG Grid (Task 10) : row["query"] @@ -627,8 +642,14 @@ def apply_saved_view(n_clicks, ids): "Vue sauvegardée au format pré-migration, impossible de l'appliquer : " f"id={row['id']!r} name={row['name']!r}" ) - return no_update, no_update - return filter_model, column_state + return no_update, no_update, no_update + # tableau-hidden-columns pilote les cases à cocher du sélecteur de colonnes + # (update_checkboxes_from_hidden_columns) et la régénération des + # columnDefs (apply_hidden_columns) ; sans cette sortie, ce store restait + # désynchronisé du columnState rappelé (revue finale #41). Même extraction + # que download_data. + hidden_columns = [c["colId"] for c in column_state if c.get("hide")] + return filter_model, column_state, hidden_columns @callback( diff --git a/src/utils/grid.py b/src/utils/grid.py index c3e125b..9f48924 100644 --- a/src/utils/grid.py +++ b/src/utils/grid.py @@ -4,10 +4,23 @@ import polars as pl from src.db import count_marches, query_marches, schema from src.figures import DATA_SCHEMA +from src.utils.cache import cache from src.utils.query_ast import ast_to_sql, filtermodel_to_ast, sort_model_to_sql from src.utils.table import postprocess_page +@cache.memoize() +def _cached_count(where_sql: str, params: tuple) -> int: + """Cache le COUNT(*) sur (where_sql, params). + + AG Grid envoie une requête par bloc de défilement infini ; pour un même + filtre, tous les blocs partagent le même (where_sql, params) et donc le + même total — inutile de recompter un COUNT(*) sur ~1,5M lignes à chaque + bloc chargé (cf. `src.utils.table._fetch_page_sql`, même schéma). + """ + return count_marches(where_sql, params) + + def fetch_grid_page( filter_model, sort_model, @@ -23,7 +36,7 @@ def fetch_grid_page( params = [*base_params, *filter_params] order_by = sort_model_to_sql(sort_model, schema) or None - total = count_marches(where_sql, params) + total = _cached_count(where_sql, tuple(params)) limit = max(0, end_row - start_row) page = query_marches( diff --git a/src/utils/query_ast.py b/src/utils/query_ast.py index f36a4aa..f035f12 100644 --- a/src/utils/query_ast.py +++ b/src/utils/query_ast.py @@ -214,6 +214,141 @@ def filtermodel_to_ast(filter_model, schema): return And(children) if children else None +_TEXT_TYPE_INV = {v: k for k, v in _TEXT_TYPE.items()} +_NUM_TYPE_INV = {v: k for k, v in _NUM_TYPE.items()} + + +def _condition_to_filterspec(cond: Condition, schema: pl.Schema) -> dict | None: + """Convertit une Condition en spec de filtre AG Grid unitaire (une colonne). + + Inverse de `_leaf`. Renvoie None (avec warning) si la colonne est inconnue + ou si l'opérateur n'a pas d'équivalent AG Grid — ne devrait pas arriver + pour un AST produit par `filtermodel_to_ast`, mais on reste défensif. + """ + if cond.column not in schema.names(): + logger.warning( + f"Colonne inconnue ignorée (ast_to_filtermodel) : {cond.column!r}" + ) + return None + + col_type = schema[cond.column] + if col_type.is_numeric(): + filter_type = "number" + elif col_type == pl.Date: + filter_type = "date" + else: + filter_type = "text" + + if filter_type in ("number", "date") and cond.operator == "range": + if filter_type == "number": + return { + "filterType": "number", + "type": "inRange", + "filter": cond.value, + "filterTo": cond.value2, + } + return { + "filterType": "date", + "type": "inRange", + "dateFrom": cond.value, + "dateTo": cond.value2, + } + + if filter_type == "date": + ag_type = _NUM_TYPE_INV.get(cond.operator) + if ag_type is None: + logger.warning( + f"Opérateur sans équivalent AG Grid ignoré : {cond.operator!r} " + f"(colonne {cond.column!r})" + ) + return None + return {"filterType": "date", "type": ag_type, "dateFrom": cond.value} + + if filter_type == "number": + ag_type = _NUM_TYPE_INV.get(cond.operator) + if ag_type is None: + logger.warning( + f"Opérateur sans équivalent AG Grid ignoré : {cond.operator!r} " + f"(colonne {cond.column!r})" + ) + return None + return {"filterType": "number", "type": ag_type, "filter": cond.value} + + # texte + ag_type = _TEXT_TYPE_INV.get(cond.operator) + if ag_type is None: + logger.warning( + f"Opérateur sans équivalent AG Grid ignoré : {cond.operator!r} " + f"(colonne {cond.column!r})" + ) + return None + return {"filterType": "text", "type": ag_type, "filter": cond.value} + + +def _child_to_filterspec(child, schema: pl.Schema): + """Convertit un enfant du And de haut niveau en (colonne, spec filterModel). + + Ne sait inverser que les deux formes produites par `filtermodel_to_ast` : + une Condition seule, ou un And/Or à 2 enfants portant sur la MÊME colonne + (filtre AG Grid natif à deux conditions). Toute autre forme (Not, And/Or + imbriqué plus profondément, colonnes différentes, plus de 2 enfants) est + ignorée avec un warning : un filterModel est par nature par-colonne et ne + peut représenter une expression booléenne arbitraire (cf. #97). + """ + if isinstance(child, Condition): + spec = _condition_to_filterspec(child, schema) + if spec is None: + return None + return child.column, spec + + if isinstance(child, (And, Or)) and len(child.children) == 2: + c1, c2 = child.children + if ( + isinstance(c1, Condition) + and isinstance(c2, Condition) + and c1.column == c2.column + ): + spec1 = _condition_to_filterspec(c1, schema) + spec2 = _condition_to_filterspec(c2, schema) + if spec1 is None or spec2 is None: + return None + operator = "AND" if isinstance(child, And) else "OR" + return c1.column, { + "filterType": spec1["filterType"], + "operator": operator, + "condition1": spec1, + "condition2": spec2, + } + + logger.warning(f"Nœud AST non représentable en filterModel, ignoré : {child!r}") + return None + + +def ast_to_filtermodel(node: Node, schema: pl.Schema) -> dict: + """Traduit un AST en filterModel AG Grid. Inverse de `filtermodel_to_ast`. + + Seules les formes que `filtermodel_to_ast` peut effectivement produire sont + garanties d'être inversées correctement (voir `_child_to_filterspec`). + """ + if node is None: + return {} + if isinstance(node, And) and not node.children: + return {} + + # Le niveau supérieur est normalement un And multi-enfants (un enfant par + # colonne filtrée) ; on tolère aussi un nœud "nu" (une seule colonne). + children = node.children if isinstance(node, And) else [node] + + filter_model: dict = {} + for child in children: + entry = _child_to_filterspec(child, schema) + if entry is None: + continue + column, spec = entry + filter_model[column] = spec + return filter_model + + def sort_model_to_sql(sort_model: list | None, schema: pl.Schema) -> str: """Traduit un sortModel AG Grid en clause ORDER BY DuckDB (adapte à sort_by_to_sql).""" if not sort_model: diff --git a/tests/saved_views/test_apply_saved_view.py b/tests/saved_views/test_apply_saved_view.py index 47f8d6c..0ebabaf 100644 --- a/tests/saved_views/test_apply_saved_view.py +++ b/tests/saved_views/test_apply_saved_view.py @@ -1,10 +1,16 @@ """Régression revue finale #41 : apply_saved_view (callback qui RAPPELLE une vue sauvegardée) ne doit pas planter si row["query"] est encore au format pré-migration (query string, ex. "filtres=a&tris=b"), stocké par l'ancienne -build_view_query avant que Task 10 ne migre save_view vers du JSON -{"filterModel": ..., "columnState": ...}. +build_view_query avant que Task 10 ne migre save_view vers du JSON. + +Depuis le round 2 de la revue finale, le format JSON stocké est +{"ast": ..., "columnState": ...} (AST canonique, indépendant de l'UI) plutôt +que {"filterModel": ..., "columnState": ...} (filterModel brut d'AG Grid) : +cf. spec de conception. apply_saved_view doit aussi resynchroniser +tableau-hidden-columns à partir du columnState rappelé. """ +import json from unittest.mock import patch import dash @@ -13,6 +19,7 @@ import src.app # noqa: F401 # instancie l'app → register_page() des pages from src.auth import db as auth_db from src.pages import tableau from src.saved_views import db as saved_views_db +from src.utils.query_ast import And, Condition, ast_to_dict def _make_user(email="u@ex.fr"): @@ -41,21 +48,26 @@ def test_apply_saved_view_old_format_returns_no_update(monkeypatch, users_db_pat monkeypatch.setattr(tableau, "ctx", _Ctx) with patch.object(tableau, "current_user", _fake_user(uid)): - filter_model, column_state = tableau.apply_saved_view( + filter_model, column_state, hidden_columns = tableau.apply_saved_view( [1], [{"type": "saved-view-item", "index": view_id}] ) assert filter_model is dash.no_update assert column_state is dash.no_update + assert hidden_columns is dash.no_update def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path): + """row["query"] au format post-round-2 : {"ast": ..., "columnState": ...}, + AST canonique plutôt que filterModel brut d'AG Grid.""" saved_views_db.init_schema() uid = _make_user() - query = ( - '{"filterModel": {"objet": {"filterType": "text", "filter": "route"}}, ' - '"columnState": [{"colId": "montant", "sort": "desc"}]}' - ) + ast = And([Condition("objet", "contains", "route")]) + column_state = [ + {"colId": "montant", "sort": "desc"}, + {"colId": "acheteur_nom", "hide": True}, + ] + query = json.dumps({"ast": ast_to_dict(ast), "columnState": column_state}) saved_views_db.upsert(uid, "tableau", "Vue récente", query) view_id = saved_views_db.list_views(uid, "tableau")[0]["id"] @@ -63,9 +75,46 @@ def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path): monkeypatch.setattr(tableau, "ctx", _Ctx) with patch.object(tableau, "current_user", _fake_user(uid)): - filter_model, column_state = tableau.apply_saved_view( + filter_model, returned_column_state, hidden_columns = tableau.apply_saved_view( [1], [{"type": "saved-view-item", "index": view_id}] ) - assert filter_model == {"objet": {"filterType": "text", "filter": "route"}} - assert column_state == [{"colId": "montant", "sort": "desc"}] + assert filter_model == { + "objet": {"filterType": "text", "type": "contains", "filter": "route"} + } + assert returned_column_state == column_state + # tableau-hidden-columns doit être resynchronisé à partir du columnState + # rappelé (revue finale #41, round 2) : seules les colonnes avec hide=True. + assert hidden_columns == ["acheteur_nom"] + + +def test_apply_saved_view_missing_ast_key_degrades_gracefully( + monkeypatch, users_db_path +): + """Vue stockée dans un format intermédiaire (sans clé "ast", ex. l'ancien + format {"filterModel": ..., "columnState": ...} produit avant le round 2) : + ast_from_dict(None) -> None, ast_to_filtermodel(None, schema) -> {} — la + vue se rappelle sans filtre plutôt que de planter le callback.""" + saved_views_db.init_schema() + uid = _make_user() + column_state = [{"colId": "montant", "sort": "desc"}] + query = json.dumps( + { + "filterModel": {"objet": {"filterType": "text", "filter": "route"}}, + "columnState": column_state, + } + ) + saved_views_db.upsert(uid, "tableau", "Vue ancien format", query) + view_id = saved_views_db.list_views(uid, "tableau")[0]["id"] + + _Ctx.triggered_id = {"type": "saved-view-item", "index": view_id} + monkeypatch.setattr(tableau, "ctx", _Ctx) + + with patch.object(tableau, "current_user", _fake_user(uid)): + filter_model, returned_column_state, hidden_columns = tableau.apply_saved_view( + [1], [{"type": "saved-view-item", "index": view_id}] + ) + + assert filter_model == {} + assert returned_column_state == column_state + assert hidden_columns == [] diff --git a/tests/test_grid.py b/tests/test_grid.py index 89abf48..70cc5e9 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -1,10 +1,38 @@ from unittest.mock import patch +import pytest + import src.app # noqa: F401 # instancie l'app → register_page() des pages from src.pages.tableau import get_rows_tableau +from src.utils import grid as grid_module from src.utils.grid import export_dataframe, fetch_grid_page, grid_column_defs +@pytest.fixture(scope="module") +def flask_app(): + """Minimal Flask app with SimpleCache so @cache.memoize() works in tests + (même pattern que tests/test_table.py).""" + from flask import Flask + + from src.utils.cache import cache + + app = Flask(__name__) + cache.init_app(app, config={"CACHE_TYPE": "SimpleCache"}) + return app + + +@pytest.fixture(autouse=True) +def reset_cache(flask_app): + from src.utils.cache import cache + + with flask_app.app_context(): + try: + cache.clear() + except (RuntimeError, AttributeError): + pass + yield + + def test_column_defs_have_field_and_filter(): defs = grid_column_defs(hidden_columns=[]) by_field = {d["field"]: d for d in defs} @@ -68,3 +96,26 @@ def test_get_rows_tableau_tracks_search_once_per_filter_not_per_scroll_block(): get_rows_tableau({"filterModel": fm, "startRow": 100, "endRow": 200}) get_rows_tableau({"filterModel": fm, "startRow": 200, "endRow": 300}) mocked.assert_called_once() + + +def test_fetch_grid_page_caches_count_across_scroll_blocks(flask_app, monkeypatch): + """Régression revue finale #41 : count_marches ne doit être appelé qu'une + fois pour des blocs de défilement successifs partageant le même + where_sql/params (même filtre, start_row différent).""" + call_count = {"n": 0} + real_count_marches = grid_module.count_marches + + def counting_count_marches(where_sql, params): + call_count["n"] += 1 + return real_count_marches(where_sql, params) + + monkeypatch.setattr(grid_module, "count_marches", counting_count_marches) + + fm = {"objet": {"filterType": "text", "type": "contains", "filter": "route"}} + with flask_app.app_context(): + _, total1 = fetch_grid_page(fm, None, 0, 20) + _, total2 = fetch_grid_page(fm, None, 20, 40) + _, total3 = fetch_grid_page(fm, None, 40, 60) + + assert call_count["n"] == 1 + assert total1 == total2 == total3 diff --git a/tests/test_query_ast.py b/tests/test_query_ast.py index 60f4a4e..3c27e4e 100644 --- a/tests/test_query_ast.py +++ b/tests/test_query_ast.py @@ -7,6 +7,7 @@ from src.utils.query_ast import ( Or, ast_from_dict, ast_to_dict, + ast_to_filtermodel, ast_to_sql, filtermodel_to_ast, sort_model_to_sql, @@ -235,3 +236,103 @@ def test_ast_dict_roundtrip(): def test_ast_dict_none(): assert ast_to_dict(None) is None assert ast_from_dict(None) is None + + +def _roundtrip_sql(fm): + """Compile fm -> ast -> filterModel -> ast à nouveau, renvoie (sql, params) + de la première et de la seconde compilation, pour vérifier l'équivalence + sémantique du round-trip (pas l'égalité dict-à-dict).""" + ast1 = filtermodel_to_ast(fm, SCHEMA) + rebuilt_fm = ast_to_filtermodel(ast1, SCHEMA) + ast2 = filtermodel_to_ast(rebuilt_fm, SCHEMA) + return ast_to_sql(ast1, SCHEMA), ast_to_sql(ast2, SCHEMA) + + +def test_ast_to_filtermodel_roundtrip_text_contains(): + fm = {"objet": {"filterType": "text", "type": "contains", "filter": "voirie"}} + original, rebuilt = _roundtrip_sql(fm) + assert original == rebuilt + + +def test_ast_to_filtermodel_roundtrip_number_greaterthan(): + fm = {"montant": {"filterType": "number", "type": "greaterThan", "filter": 40000}} + original, rebuilt = _roundtrip_sql(fm) + assert original == rebuilt + + +def test_ast_to_filtermodel_roundtrip_number_inrange(): + fm = { + "montant": { + "filterType": "number", + "type": "inRange", + "filter": 100, + "filterTo": 200, + } + } + original, rebuilt = _roundtrip_sql(fm) + assert original == rebuilt + + +def test_ast_to_filtermodel_roundtrip_date_greaterthan(): + fm = { + "dateNotification": { + "filterType": "date", + "type": "greaterThan", + "dateFrom": "2022-01-01", + } + } + original, rebuilt = _roundtrip_sql(fm) + assert original == rebuilt + + +def test_ast_to_filtermodel_roundtrip_two_conditions_or(): + fm = { + "objet": { + "filterType": "text", + "operator": "OR", + "condition1": {"filterType": "text", "type": "contains", "filter": "beton"}, + "condition2": { + "filterType": "text", + "type": "contains", + "filter": "ciment", + }, + } + } + original, rebuilt = _roundtrip_sql(fm) + assert original == rebuilt + + +def test_ast_to_filtermodel_roundtrip_multiple_columns(): + fm = { + "objet": {"filterType": "text", "type": "contains", "filter": "voirie"}, + "montant": {"filterType": "number", "type": "greaterThan", "filter": 1000}, + } + original, rebuilt = _roundtrip_sql(fm) + assert original == rebuilt + + +def test_ast_to_filtermodel_none_and_empty_and(): + assert ast_to_filtermodel(None, SCHEMA) == {} + assert ast_to_filtermodel(And([]), SCHEMA) == {} + + +def test_ast_to_filtermodel_skips_not_with_warning(): + node = And([Not(Condition("objet", "contains", "x"))]) + assert ast_to_filtermodel(node, SCHEMA) == {} + + +def test_ast_to_filtermodel_skips_mismatched_columns_with_warning(): + node = And( + [Or([Condition("objet", "contains", "a"), Condition("montant", "gt", 1)])] + ) + assert ast_to_filtermodel(node, SCHEMA) == {} + + +def test_ast_to_filtermodel_bare_single_condition(): + """filtermodel_to_ast enveloppe toujours dans And, mais on tolère un nœud + non enveloppé (Condition seule) en entrée, défensivement.""" + node = Condition("objet", "contains", "voirie") + fm = ast_to_filtermodel(node, SCHEMA) + assert fm == { + "objet": {"filterType": "text", "type": "contains", "filter": "voirie"} + }