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(
|
||||
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(
|
||||
|
||||
+38
-21
@@ -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,23 +314,27 @@ 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:
|
||||
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 c1.column, {
|
||||
"filterType": spec1["filterType"],
|
||||
return conditions[0].column, {
|
||||
"filterType": specs[0]["filterType"],
|
||||
"operator": operator,
|
||||
"condition1": spec1,
|
||||
"condition2": spec2,
|
||||
"conditions": specs,
|
||||
}
|
||||
|
||||
logger.warning(f"Nœud AST non représentable en filterModel, ignoré : {child!r}")
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user