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:
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
Reference in New Issue
Block a user