fix(tableau): vues sauvegardées pré-migration + double comptage track_search (#41)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+20
-3
@@ -23,7 +23,7 @@ from src.figures import ag_grid, make_column_picker
|
|||||||
from src.pages._compte_shell import current_user_has_subscription
|
from src.pages._compte_shell import current_user_has_subscription
|
||||||
from src.saved_views import db as saved_views_db
|
from src.saved_views import db as saved_views_db
|
||||||
from src.saved_views import ui as saved_views_ui
|
from src.saved_views import ui as saved_views_ui
|
||||||
from src.utils import get_data_update_timestamp
|
from src.utils import get_data_update_timestamp, logger
|
||||||
from src.utils.grid import fetch_grid_page, grid_column_defs
|
from src.utils.grid import fetch_grid_page, grid_column_defs
|
||||||
from src.utils.seo import META_CONTENT
|
from src.utils.seo import META_CONTENT
|
||||||
from src.utils.table import (
|
from src.utils.table import (
|
||||||
@@ -417,7 +417,11 @@ def get_rows_tableau(request):
|
|||||||
return no_update, no_update
|
return no_update, no_update
|
||||||
filter_model = request.get("filterModel") or None
|
filter_model = request.get("filterModel") or None
|
||||||
sort_model = request.get("sortModel") or None
|
sort_model = request.get("sortModel") or None
|
||||||
if filter_model:
|
# AG Grid renvoie une nouvelle requête getRowsRequest pour chaque bloc de
|
||||||
|
# défilement infini, avec le même filterModel tant que le filtre ne change
|
||||||
|
# pas. Ne compter qu'une recherche par changement de filtre/tri (bloc 0),
|
||||||
|
# pas une par bloc chargé au défilement.
|
||||||
|
if filter_model and request.get("startRow", 0) == 0:
|
||||||
track_search(json.dumps(filter_model), "tableau")
|
track_search(json.dumps(filter_model), "tableau")
|
||||||
rows, total = fetch_grid_page(
|
rows, total = fetch_grid_page(
|
||||||
filter_model,
|
filter_model,
|
||||||
@@ -610,8 +614,21 @@ def apply_saved_view(n_clicks, ids):
|
|||||||
row = saved_views_db.get(triggered["index"], current_user.id)
|
row = saved_views_db.get(triggered["index"], current_user.id)
|
||||||
if not row:
|
if not row:
|
||||||
return no_update, no_update
|
return no_update, no_update
|
||||||
|
try:
|
||||||
view = json.loads(row["query"])
|
view = json.loads(row["query"])
|
||||||
return view.get("filterModel") or {}, view.get("columnState") or []
|
filter_model = view.get("filterModel") or {}
|
||||||
|
column_state = view.get("columnState") or []
|
||||||
|
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||||
|
# Vue enregistrée avant la migration vers AG Grid (Task 10) : row["query"]
|
||||||
|
# est encore une query string (ex. "filtres=a&tris=b"), pas du JSON. On
|
||||||
|
# échoue proprement plutôt que de planter le callback ; pas de
|
||||||
|
# migration automatique de l'ancien format.
|
||||||
|
logger.warning(
|
||||||
|
"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
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Régression revue finale #41 : apply_saved_view (callback qui RAPPELLE une vue
|
||||||
|
sauvegardée) ne doit pas planter si row["query"] est encore au format
|
||||||
|
pré-migration (query string, ex. "filtres=a&tris=b"), stocké par l'ancienne
|
||||||
|
build_view_query avant que Task 10 ne migre save_view vers du JSON
|
||||||
|
{"filterModel": ..., "columnState": ...}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import dash
|
||||||
|
|
||||||
|
import src.app # noqa: F401 # instancie l'app → register_page() des pages
|
||||||
|
from src.auth import db as auth_db
|
||||||
|
from src.pages import tableau
|
||||||
|
from src.saved_views import db as saved_views_db
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user(email="u@ex.fr"):
|
||||||
|
auth_db.init_schema()
|
||||||
|
return auth_db.create_user(email, "hash")
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_user(user_id, authenticated=True):
|
||||||
|
user = type("U", (), {})()
|
||||||
|
user.is_authenticated = authenticated
|
||||||
|
user.id = user_id
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class _Ctx:
|
||||||
|
triggered_id = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_saved_view_old_format_returns_no_update(monkeypatch, users_db_path):
|
||||||
|
saved_views_db.init_schema()
|
||||||
|
uid = _make_user()
|
||||||
|
saved_views_db.upsert(uid, "tableau", "Vue historique", "filtres=a&tris=b")
|
||||||
|
view_id = saved_views_db.list_views(uid, "tableau")[0]["id"]
|
||||||
|
|
||||||
|
_Ctx.triggered_id = {"type": "saved-view-item", "index": view_id}
|
||||||
|
monkeypatch.setattr(tableau, "ctx", _Ctx)
|
||||||
|
|
||||||
|
with patch.object(tableau, "current_user", _fake_user(uid)):
|
||||||
|
filter_model, column_state = tableau.apply_saved_view(
|
||||||
|
[1], [{"type": "saved-view-item", "index": view_id}]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert filter_model is dash.no_update
|
||||||
|
assert column_state is dash.no_update
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path):
|
||||||
|
saved_views_db.init_schema()
|
||||||
|
uid = _make_user()
|
||||||
|
query = (
|
||||||
|
'{"filterModel": {"objet": {"filterType": "text", "filter": "route"}}, '
|
||||||
|
'"columnState": [{"colId": "montant", "sort": "desc"}]}'
|
||||||
|
)
|
||||||
|
saved_views_db.upsert(uid, "tableau", "Vue récente", query)
|
||||||
|
view_id = saved_views_db.list_views(uid, "tableau")[0]["id"]
|
||||||
|
|
||||||
|
_Ctx.triggered_id = {"type": "saved-view-item", "index": view_id}
|
||||||
|
monkeypatch.setattr(tableau, "ctx", _Ctx)
|
||||||
|
|
||||||
|
with patch.object(tableau, "current_user", _fake_user(uid)):
|
||||||
|
filter_model, column_state = tableau.apply_saved_view(
|
||||||
|
[1], [{"type": "saved-view-item", "index": view_id}]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert filter_model == {"objet": {"filterType": "text", "filter": "route"}}
|
||||||
|
assert column_state == [{"colId": "montant", "sort": "desc"}]
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import src.app # noqa: F401 # instancie l'app → register_page() des pages
|
||||||
|
from src.pages.tableau import get_rows_tableau
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -51,3 +55,16 @@ def test_export_dataframe_applies_filter():
|
|||||||
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "zzzzzznope"}}
|
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "zzzzzznope"}}
|
||||||
df = export_dataframe(fm, None, hidden_columns=[])
|
df = export_dataframe(fm, None, hidden_columns=[])
|
||||||
assert df.height == 0
|
assert df.height == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_rows_tableau_tracks_search_once_per_filter_not_per_scroll_block():
|
||||||
|
"""Régression revue finale #41 : AG Grid envoie une getRowsRequest par bloc
|
||||||
|
de défilement infini, avec le même filterModel tant que le filtre ne
|
||||||
|
change pas. track_search ne doit être appelé qu'une fois par filtre (au
|
||||||
|
premier bloc, startRow == 0), pas une fois par bloc défilé."""
|
||||||
|
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "route"}}
|
||||||
|
with patch("src.pages.tableau.track_search") as mocked:
|
||||||
|
get_rows_tableau({"filterModel": fm, "startRow": 0, "endRow": 100})
|
||||||
|
get_rows_tableau({"filterModel": fm, "startRow": 100, "endRow": 200})
|
||||||
|
get_rows_tableau({"filterModel": fm, "startRow": 200, "endRow": 300})
|
||||||
|
mocked.assert_called_once()
|
||||||
|
|||||||
Reference in New Issue
Block a user