fix(tableau): filtres à conditions multiples ignorés + tri non réinitialisé (#41)

Deux bugs découverts en usage réel sur /tableau :

1. filtermodel_to_ast lisait condition1/condition2 pour un filtre à
   deux conditions (ET/OU) sur une colonne — mais AG Grid >=29.2 (la
   version 35.2.0 utilisée ici) encode ça via une liste `conditions`,
   confirmé par la doc Dash AG Grid "Filter Model & Dash Callbacks" >
   "Filter Model Multiple Conditions". Le filtre était donc
   silencieusement ignoré (colonne exclue du AST, comme si aucun
   filtre n'était posé), d'où des résultats sans rapport avec les
   valeurs saisies. Corrigé : filtermodel_to_ast lit désormais
   `conditions` (N éléments) en priorité, avec repli sur
   condition1/condition2 (forme dépréciée mais "still accepted" selon
   AG Grid). L'inverse (ast_to_filtermodel, utilisé au rappel d'une
   vue sauvegardée) produit aussi la forme `conditions`.

2. Le bouton "Réinitialiser" ("Supprime tous les filtres et les tris")
   ne réinitialisait que filterModel, jamais le tri. Corrigé via
   resetColumnState (remet les colonnes à l'état de columnDefs, qui
   reflète déjà la visibilité choisie via le sélecteur de colonnes —
   seul le tri est donc affecté).

Reproduit et corrigé en TDD (tests/test_query_ast.py,
tests/test_grid.py), vérifié manuellement en navigateur par l'auteur
du rapport de bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-07-10 20:21:53 +02:00
parent 4e07c69a85
commit 117ce9a4ab
4 changed files with 98 additions and 27 deletions
+6 -1
View File
@@ -544,11 +544,16 @@ def toggle_tableau_columns(click_open, click_close, is_open):
@callback(
Output("tableau_grid", "filterModel", allow_duplicate=True),
Output("tableau_grid", "resetColumnState", allow_duplicate=True),
Input("btn-tableau-reset", "n_clicks"),
prevent_initial_call=True,
)
def reset_view(n_clicks):
return {}
# resetColumnState remet les colonnes (tri inclus) à l'état déclaré dans
# columnDefs — qui reflète déjà la visibilité choisie via le sélecteur de
# colonnes (cf. apply_hidden_columns), donc ça ne touche pas au choix de
# colonnes affichées, seulement au tri.
return {}, True
@callback(
+42 -25
View File
@@ -191,7 +191,14 @@ def _leaf(column: str, spec: dict):
def filtermodel_to_ast(filter_model, schema):
"""Traduit un filterModel AG Grid en AST. Colonnes combinées en And."""
"""Traduit un filterModel AG Grid en AST. Colonnes combinées en And.
AG Grid >=29.2 (pinné à 35.2.0 ici) encode un filtre à conditions
multiples via une liste `conditions` (N éléments, cf. `maxNumConditions`),
et non plus via `condition1`/`condition2` (dépréciés depuis 29.2, mais
l'API dit "still accept" ces clés en entrée — on les supporte donc aussi
en repli, sans qu'AG Grid ne les envoie normalement).
"""
if not filter_model:
return None
children = []
@@ -199,7 +206,12 @@ def filtermodel_to_ast(filter_model, schema):
if column not in schema.names():
logger.warning(f"Filtre sur colonne inconnue ignoré : {column!r}")
continue
if "operator" in spec: # deux conditions
if "conditions" in spec: # forme courante AG Grid >=29.2 (N conditions)
parts = [c for c in (_leaf(column, s) for s in spec["conditions"]) if c]
if not parts:
continue
node = And(parts) if spec.get("operator") == "AND" else Or(parts)
elif "operator" in spec: # forme dépréciée condition1/condition2
c1 = _leaf(column, spec.get("condition1", {}))
c2 = _leaf(column, spec.get("condition2", {}))
parts = [c for c in (c1, c2) if c is not None]
@@ -289,11 +301,12 @@ 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).
une Condition seule, ou un And/Or à N enfants (N = maxNumConditions)
portant tous sur la MÊME colonne (filtre AG Grid natif multi-conditions,
forme `conditions` — cf. AG Grid >=29.2). Toute autre forme (Not, And/Or
imbriqué plus profondément, colonnes différentes) 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)
@@ -301,24 +314,28 @@ def _child_to_filterspec(child, schema: pl.Schema):
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,
}
if isinstance(child, (And, Or)) and len(child.children) >= 2:
conditions = child.children
if not all(isinstance(c, Condition) for c in conditions):
logger.warning(
f"Nœud AST non représentable en filterModel, ignoré : {child!r}"
)
return None
columns = {c.column for c in conditions}
if len(columns) != 1:
logger.warning(
f"Nœud AST non représentable en filterModel, ignoré : {child!r}"
)
return None
specs = [_condition_to_filterspec(c, schema) for c in conditions]
if any(s is None for s in specs):
return None
operator = "AND" if isinstance(child, And) else "OR"
return conditions[0].column, {
"filterType": specs[0]["filterType"],
"operator": operator,
"conditions": specs,
}
logger.warning(f"Nœud AST non représentable en filterModel, ignoré : {child!r}")
return None
+9 -1
View File
@@ -3,7 +3,7 @@ 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.pages.tableau import get_rows_tableau, reset_view
from src.utils import grid as grid_module
from src.utils.grid import export_dataframe, fetch_grid_page, grid_column_defs
@@ -119,3 +119,11 @@ def test_fetch_grid_page_caches_count_across_scroll_blocks(flask_app, monkeypatc
assert call_count["n"] == 1
assert total1 == total2 == total3
def test_reset_view_clears_filter_and_sort():
"""Régression : le bouton Réinitialiser ('Supprime tous les filtres et les
tris') ne remettait à zéro que filterModel, jamais le tri (#41)."""
filter_model, reset_column_state = reset_view(1)
assert filter_model == {}
assert reset_column_state is True
+41
View File
@@ -202,6 +202,47 @@ def test_filtermodel_two_conditions_or():
assert " OR " in sql and params == ["%beton%", "%ciment%"]
def test_filtermodel_two_conditions_or_real_ag_grid_shape():
"""AG Grid >=29.2 (pinné à 35.2.0 ici) envoie 'conditions' (liste), pas
condition1/condition2 (déprécié). Voir la doc Dash AG Grid "Filter Model
& Dash Callbacks" > "Filter Model Multiple Conditions". Bug de régression :
filtermodel_to_ast ignorait silencieusement ces filtres (#41)."""
fm = {
"acheteur_nom": {
"filterType": "text",
"operator": "OR",
"conditions": [
{"filterType": "text", "type": "contains", "filter": "91"},
{"filterType": "text", "type": "contains", "filter": "78"},
],
}
}
ast = filtermodel_to_ast(fm, SCHEMA)
assert ast is not None, "le filtre ne doit pas être silencieusement ignoré"
sql, params = ast_to_sql(ast, SCHEMA)
assert " OR " in sql
assert params == ["%91%", "%78%"]
def test_filtermodel_conditions_and_three_values():
"""La liste 'conditions' n'est pas limitée à 2 éléments (maxNumConditions)."""
fm = {
"objet": {
"filterType": "text",
"operator": "AND",
"conditions": [
{"filterType": "text", "type": "contains", "filter": "voirie"},
{"filterType": "text", "type": "notContains", "filter": "reparation"},
{"filterType": "text", "type": "contains", "filter": "route"},
],
}
}
ast = filtermodel_to_ast(fm, SCHEMA)
sql, params = ast_to_sql(ast, SCHEMA)
assert sql.count(" AND ") >= 2
assert params == ["%voirie%", "%reparation%", "%route%"]
def test_filtermodel_multiple_columns_are_anded():
fm = {
"objet": {"filterType": "text", "type": "contains", "filter": "voirie"},