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). 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..0fe5f22 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-migration-ag-grid-design.md @@ -0,0 +1,128 @@ +# 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. +- **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 + +- `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`) — 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. +- **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. + +## Décisions tranchées + +- **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 + +- 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`). 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/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/pages/tableau.py b/src/pages/tableau.py index 80999c0..3d7b1b6 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -1,18 +1,16 @@ import json import os -import urllib.parse -import uuid from datetime import datetime import dash_bootstrap_components as dbc -import polars as pl from dash import ( - ClientsideFunction, + ALL, Input, Output, State, callback, clientside_callback, + ctx, dcc, html, no_update, @@ -20,21 +18,23 @@ 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.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 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, - build_view_query, - filter_table_data, get_default_hidden_columns, - invert_columns, - prepare_table_data, - sort_table_data, write_styled_excel, ) from src.utils.tracking import track_search @@ -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")) ), ) @@ -95,10 +86,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.", @@ -136,9 +123,9 @@ 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"), html.Script( type="application/ld+json", id="dataset_jsonld", @@ -201,7 +188,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( @@ -241,51 +228,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 @@ -375,8 +356,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", @@ -434,196 +413,75 @@ 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 + # 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, + 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( 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") -@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")], @@ -650,21 +508,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): @@ -688,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( @@ -720,17 +574,20 @@ 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) + # 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, @@ -751,6 +608,50 @@ def populate_saved_views_menu(_pathname, _refresh): return saved_views_ui.saved_views_items(views) +@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, +) +def apply_saved_view(n_clicks, ids): + triggered = ctx.triggered_id + if not triggered or not any(n_clicks): + 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, no_update + try: + view = json.loads(row["query"]) + # 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"] + # 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, 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( 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/src/utils/grid.py b/src/utils/grid.py new file mode 100644 index 0000000..9f48924 --- /dev/null +++ b/src/utils/grid.py @@ -0,0 +1,124 @@ +"""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.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, + 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 = _cached_count(where_sql, tuple(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 + + +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", + "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/src/utils/query_ast.py b/src/utils/query_ast.py new file mode 100644 index 0000000..f035f12 --- /dev/null +++ b/src/utils/query_ast.py @@ -0,0 +1,398 @@ +"""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 sort_by_to_sql, 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}"' + + 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) + + # 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 == "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": + 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", [] + + +_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 + + +_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: + 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/saved_views/test_apply_saved_view.py b/tests/saved_views/test_apply_saved_view.py new file mode 100644 index 0000000..0ebabaf --- /dev/null +++ b/tests/saved_views/test_apply_saved_view.py @@ -0,0 +1,120 @@ +"""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. + +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 + +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"): + 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, 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() + 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"] + + _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 == { + "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/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(): diff --git a/tests/test_grid.py b/tests/test_grid.py new file mode 100644 index 0000000..70cc5e9 --- /dev/null +++ b/tests/test_grid.py @@ -0,0 +1,121 @@ +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} + 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(): + 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 + + +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 + + +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() + + +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_main.py b/tests/test_main.py index b458dc3..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) @@ -99,7 +105,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"), ] @@ -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_query_ast.py b/tests/test_query_ast.py new file mode 100644 index 0000000..3c27e4e --- /dev/null +++ b/tests/test_query_ast.py @@ -0,0 +1,338 @@ +import polars as pl + +from src.utils.query_ast import ( + And, + Condition, + Not, + Or, + ast_from_dict, + ast_to_dict, + ast_to_filtermodel, + ast_to_sql, + filtermodel_to_ast, + sort_model_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_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", []) + + +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%"] + + +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} + + +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 + + +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"} + } 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() + ) 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) 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"