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( @callback(
Output("tableau_grid", "filterModel", allow_duplicate=True), Output("tableau_grid", "filterModel", allow_duplicate=True),
Output("tableau_grid", "resetColumnState", allow_duplicate=True),
Input("btn-tableau-reset", "n_clicks"), Input("btn-tableau-reset", "n_clicks"),
prevent_initial_call=True, prevent_initial_call=True,
) )
def reset_view(n_clicks): 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( @callback(
+42 -25
View File
@@ -191,7 +191,14 @@ def _leaf(column: str, spec: dict):
def filtermodel_to_ast(filter_model, schema): 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: if not filter_model:
return None return None
children = [] children = []
@@ -199,7 +206,12 @@ def filtermodel_to_ast(filter_model, schema):
if column not in schema.names(): if column not in schema.names():
logger.warning(f"Filtre sur colonne inconnue ignoré : {column!r}") logger.warning(f"Filtre sur colonne inconnue ignoré : {column!r}")
continue 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", {})) c1 = _leaf(column, spec.get("condition1", {}))
c2 = _leaf(column, spec.get("condition2", {})) c2 = _leaf(column, spec.get("condition2", {}))
parts = [c for c in (c1, c2) if c is not None] 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). """Convertit un enfant du And de haut niveau en (colonne, spec filterModel).
Ne sait inverser que les deux formes produites par `filtermodel_to_ast` : 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 une Condition seule, ou un And/Or à N enfants (N = maxNumConditions)
(filtre AG Grid natif à deux conditions). Toute autre forme (Not, And/Or portant tous sur la MÊME colonne (filtre AG Grid natif multi-conditions,
imbriqué plus profondément, colonnes différentes, plus de 2 enfants) est forme `conditions` — cf. AG Grid >=29.2). Toute autre forme (Not, And/Or
ignorée avec un warning : un filterModel est par nature par-colonne et ne imbriqué plus profondément, colonnes différentes) est ignorée avec un
peut représenter une expression booléenne arbitraire (cf. #97). warning : un filterModel est par nature par-colonne et ne peut représenter
une expression booléenne arbitraire (cf. #97).
""" """
if isinstance(child, Condition): if isinstance(child, Condition):
spec = _condition_to_filterspec(child, schema) spec = _condition_to_filterspec(child, schema)
@@ -301,24 +314,28 @@ def _child_to_filterspec(child, schema: pl.Schema):
return None return None
return child.column, spec return child.column, spec
if isinstance(child, (And, Or)) and len(child.children) == 2: if isinstance(child, (And, Or)) and len(child.children) >= 2:
c1, c2 = child.children conditions = child.children
if ( if not all(isinstance(c, Condition) for c in conditions):
isinstance(c1, Condition) logger.warning(
and isinstance(c2, Condition) f"Nœud AST non représentable en filterModel, ignoré : {child!r}"
and c1.column == c2.column )
): return None
spec1 = _condition_to_filterspec(c1, schema) columns = {c.column for c in conditions}
spec2 = _condition_to_filterspec(c2, schema) if len(columns) != 1:
if spec1 is None or spec2 is None: logger.warning(
return None f"Nœud AST non représentable en filterModel, ignoré : {child!r}"
operator = "AND" if isinstance(child, And) else "OR" )
return c1.column, { return None
"filterType": spec1["filterType"], specs = [_condition_to_filterspec(c, schema) for c in conditions]
"operator": operator, if any(s is None for s in specs):
"condition1": spec1, return None
"condition2": spec2, 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}") logger.warning(f"Nœud AST non représentable en filterModel, ignoré : {child!r}")
return None return None
+9 -1
View File
@@ -3,7 +3,7 @@ from unittest.mock import patch
import pytest import pytest
import src.app # noqa: F401 # instancie l'app → register_page() des pages 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 import grid as grid_module
from src.utils.grid import export_dataframe, fetch_grid_page, grid_column_defs 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 call_count["n"] == 1
assert total1 == total2 == total3 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%"] 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(): def test_filtermodel_multiple_columns_are_anded():
fm = { fm = {
"objet": {"filterType": "text", "type": "contains", "filter": "voirie"}, "objet": {"filterType": "text", "type": "contains", "filter": "voirie"},