fix(tableau): vues sauvegardées en AST canonique, cache du comptage, synchro visibilité colonnes (#41)

This commit is contained in:
Colin Maudry
2026-07-10 14:44:14 +02:00
parent 0bfac680a5
commit 4f36022443
6 changed files with 389 additions and 19 deletions
+29 -8
View File
@@ -25,6 +25,12 @@ from src.saved_views import db as saved_views_db
from src.saved_views import ui as saved_views_ui
from src.utils import get_data_update_timestamp, logger
from src.utils.grid import fetch_grid_page, grid_column_defs
from src.utils.query_ast import (
ast_from_dict,
ast_to_dict,
ast_to_filtermodel,
filtermodel_to_ast,
)
from src.utils.seo import META_CONTENT
from src.utils.table import (
COLUMNS,
@@ -577,9 +583,11 @@ def save_view(_n, name, filter_model, column_state):
clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name)
if error:
return True, html.Span(error, style={"color": "red"}), no_update
query = json.dumps(
{"filterModel": filter_model or {}, "columnState": column_state or []}
)
# On stocke l'AST canonique (indépendant de l'UI), pas le filterModel brut
# d'AG Grid : cf. spec de conception, "l'AST (JSON) + columnState,
# indépendant de l'UI".
ast = filtermodel_to_ast(filter_model, schema)
query = json.dumps({"ast": ast_to_dict(ast), "columnState": column_state or []})
saved_views_db.upsert(current_user.id, "tableau", clean_name, query)
return (
False,
@@ -603,6 +611,7 @@ def populate_saved_views_menu(_pathname, _refresh):
@callback(
Output("tableau_grid", "filterModel"),
Output("tableau_grid", "columnState"),
Output("tableau-hidden-columns", "data", allow_duplicate=True),
Input({"type": "saved-view-item", "index": ALL}, "n_clicks"),
State({"type": "saved-view-item", "index": ALL}, "id"),
prevent_initial_call=True,
@@ -610,13 +619,19 @@ def populate_saved_views_menu(_pathname, _refresh):
def apply_saved_view(n_clicks, ids):
triggered = ctx.triggered_id
if not triggered or not any(n_clicks):
return no_update, no_update
return no_update, no_update, no_update
row = saved_views_db.get(triggered["index"], current_user.id)
if not row:
return no_update, no_update
return no_update, no_update, no_update
try:
view = json.loads(row["query"])
filter_model = view.get("filterModel") or {}
# L'AST canonique est stocké (pas le filterModel brut d'AG Grid) :
# cf. save_view. `ast_from_dict(None)` -> None et
# `ast_to_filtermodel(None, schema)` -> {} si la vue est d'un ancien
# format (sans clé "ast") : dégradation propre, la vue se rappelle
# sans filtre plutôt que de planter.
ast = ast_from_dict(view.get("ast"))
filter_model = ast_to_filtermodel(ast, schema)
column_state = view.get("columnState") or []
except (json.JSONDecodeError, TypeError, AttributeError):
# Vue enregistrée avant la migration vers AG Grid (Task 10) : row["query"]
@@ -627,8 +642,14 @@ def apply_saved_view(n_clicks, ids):
"Vue sauvegardée au format pré-migration, impossible de l'appliquer : "
f"id={row['id']!r} name={row['name']!r}"
)
return no_update, no_update
return filter_model, column_state
return no_update, no_update, no_update
# tableau-hidden-columns pilote les cases à cocher du sélecteur de colonnes
# (update_checkboxes_from_hidden_columns) et la régénération des
# columnDefs (apply_hidden_columns) ; sans cette sortie, ce store restait
# désynchronisé du columnState rappelé (revue finale #41). Même extraction
# que download_data.
hidden_columns = [c["colId"] for c in column_state if c.get("hide")]
return filter_model, column_state, hidden_columns
@callback(
+14 -1
View File
@@ -4,10 +4,23 @@ import polars as pl
from src.db import count_marches, query_marches, schema
from src.figures import DATA_SCHEMA
from src.utils.cache import cache
from src.utils.query_ast import ast_to_sql, filtermodel_to_ast, sort_model_to_sql
from src.utils.table import postprocess_page
@cache.memoize()
def _cached_count(where_sql: str, params: tuple) -> int:
"""Cache le COUNT(*) sur (where_sql, params).
AG Grid envoie une requête par bloc de défilement infini ; pour un même
filtre, tous les blocs partagent le même (where_sql, params) et donc le
même total — inutile de recompter un COUNT(*) sur ~1,5M lignes à chaque
bloc chargé (cf. `src.utils.table._fetch_page_sql`, même schéma).
"""
return count_marches(where_sql, params)
def fetch_grid_page(
filter_model,
sort_model,
@@ -23,7 +36,7 @@ def fetch_grid_page(
params = [*base_params, *filter_params]
order_by = sort_model_to_sql(sort_model, schema) or None
total = count_marches(where_sql, params)
total = _cached_count(where_sql, tuple(params))
limit = max(0, end_row - start_row)
page = query_marches(
+135
View File
@@ -214,6 +214,141 @@ def filtermodel_to_ast(filter_model, schema):
return And(children) if children else None
_TEXT_TYPE_INV = {v: k for k, v in _TEXT_TYPE.items()}
_NUM_TYPE_INV = {v: k for k, v in _NUM_TYPE.items()}
def _condition_to_filterspec(cond: Condition, schema: pl.Schema) -> dict | None:
"""Convertit une Condition en spec de filtre AG Grid unitaire (une colonne).
Inverse de `_leaf`. Renvoie None (avec warning) si la colonne est inconnue
ou si l'opérateur n'a pas d'équivalent AG Grid — ne devrait pas arriver
pour un AST produit par `filtermodel_to_ast`, mais on reste défensif.
"""
if cond.column not in schema.names():
logger.warning(
f"Colonne inconnue ignorée (ast_to_filtermodel) : {cond.column!r}"
)
return None
col_type = schema[cond.column]
if col_type.is_numeric():
filter_type = "number"
elif col_type == pl.Date:
filter_type = "date"
else:
filter_type = "text"
if filter_type in ("number", "date") and cond.operator == "range":
if filter_type == "number":
return {
"filterType": "number",
"type": "inRange",
"filter": cond.value,
"filterTo": cond.value2,
}
return {
"filterType": "date",
"type": "inRange",
"dateFrom": cond.value,
"dateTo": cond.value2,
}
if filter_type == "date":
ag_type = _NUM_TYPE_INV.get(cond.operator)
if ag_type is None:
logger.warning(
f"Opérateur sans équivalent AG Grid ignoré : {cond.operator!r} "
f"(colonne {cond.column!r})"
)
return None
return {"filterType": "date", "type": ag_type, "dateFrom": cond.value}
if filter_type == "number":
ag_type = _NUM_TYPE_INV.get(cond.operator)
if ag_type is None:
logger.warning(
f"Opérateur sans équivalent AG Grid ignoré : {cond.operator!r} "
f"(colonne {cond.column!r})"
)
return None
return {"filterType": "number", "type": ag_type, "filter": cond.value}
# texte
ag_type = _TEXT_TYPE_INV.get(cond.operator)
if ag_type is None:
logger.warning(
f"Opérateur sans équivalent AG Grid ignoré : {cond.operator!r} "
f"(colonne {cond.column!r})"
)
return None
return {"filterType": "text", "type": ag_type, "filter": cond.value}
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).
"""
if isinstance(child, Condition):
spec = _condition_to_filterspec(child, schema)
if spec is None:
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,
}
logger.warning(f"Nœud AST non représentable en filterModel, ignoré : {child!r}")
return None
def ast_to_filtermodel(node: Node, schema: pl.Schema) -> dict:
"""Traduit un AST en filterModel AG Grid. Inverse de `filtermodel_to_ast`.
Seules les formes que `filtermodel_to_ast` peut effectivement produire sont
garanties d'être inversées correctement (voir `_child_to_filterspec`).
"""
if node is None:
return {}
if isinstance(node, And) and not node.children:
return {}
# Le niveau supérieur est normalement un And multi-enfants (un enfant par
# colonne filtrée) ; on tolère aussi un nœud "nu" (une seule colonne).
children = node.children if isinstance(node, And) else [node]
filter_model: dict = {}
for child in children:
entry = _child_to_filterspec(child, schema)
if entry is None:
continue
column, spec = entry
filter_model[column] = spec
return filter_model
def sort_model_to_sql(sort_model: list | None, schema: pl.Schema) -> str:
"""Traduit un sortModel AG Grid en clause ORDER BY DuckDB (adapte à sort_by_to_sql)."""
if not sort_model: