diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 3d7b1b6..129a2f3 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -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( diff --git a/src/utils/query_ast.py b/src/utils/query_ast.py index f035f12..fe529c9 100644 --- a/src/utils/query_ast.py +++ b/src/utils/query_ast.py @@ -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 diff --git a/tests/test_grid.py b/tests/test_grid.py index 70cc5e9..d077791 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -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 diff --git a/tests/test_query_ast.py b/tests/test_query_ast.py index 3c27e4e..81178fd 100644 --- a/tests/test_query_ast.py +++ b/tests/test_query_ast.py @@ -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"},