From 439b6f9cdca99debf143b4877e24e62d337dc5f1 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 13 Jul 2026 15:28:54 +0200 Subject: [PATCH] feat(vues): resolveur pur du parametre ?vue= (#112) --- src/saved_views/resolve.py | 59 +++++++++++++++++++++++ tests/saved_views/test_resolve.py | 77 +++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/saved_views/resolve.py create mode 100644 tests/saved_views/test_resolve.py diff --git a/src/saved_views/resolve.py b/src/saved_views/resolve.py new file mode 100644 index 0000000..cbdad6c --- /dev/null +++ b/src/saved_views/resolve.py @@ -0,0 +1,59 @@ +"""Résolution publique d'une vue depuis le paramètre d'URL ?vue=_. + +Fonction pure (aucune dépendance à Dash), testable directement : elle prend le +paramètre brut et le schéma, renvoie un dict décrivant la vue à appliquer ou une +erreur. Le slug est ignoré ; seul le jeton fait foi. +""" + +import json + +from src.saved_views import db, ui +from src.utils import logger +from src.utils.query_ast import ast_from_dict, ast_to_filtermodel + +NOT_FOUND_MESSAGE = "Cette vue est introuvable ou a été supprimée." + + +def _error() -> dict: + return { + "found": False, + "filter_model": None, + "column_state": None, + "hidden_columns": None, + "token": None, + "url": None, + "error": NOT_FOUND_MESSAGE, + } + + +def resolve_vue_param(vue_param: str | None, schema) -> dict: + token = ui.token_from_vue_param(vue_param) + if not token: + return _error() + row = db.get_by_token(token) + if row is None: + return _error() + try: + view = json.loads(row["query"]) + # AST canonique stocké par save_view (cf. spec vues sauvegardées). + 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 pré-migration (query string, pas du JSON) : repli propre, même + # message que pour un jeton inconnu (anti-énumération). + logger.warning( + "Vue partagée au format pré-migration, non applicable : " + f"token={token!r} name={row['name']!r}" + ) + return _error() + hidden_columns = [c["colId"] for c in column_state if c.get("hide")] + return { + "found": True, + "filter_model": filter_model, + "column_state": column_state, + "hidden_columns": hidden_columns, + "token": row["token"], + "url": ui.build_view_url(row["name"], row["token"]), + "error": None, + } diff --git a/tests/saved_views/test_resolve.py b/tests/saved_views/test_resolve.py new file mode 100644 index 0000000..a14e0a7 --- /dev/null +++ b/tests/saved_views/test_resolve.py @@ -0,0 +1,77 @@ +import json + +from src.auth import db as auth_db +from src.db import schema +from src.saved_views import db as saved_views_db +from src.saved_views import resolve +from src.utils.query_ast import And, Condition, ast_to_dict + + +def _make_user(email="u@ex.fr"): + auth_db.init_schema() + return auth_db.create_user(email, "hash") + + +def _seed_view(uid, name="Ma vue"): + ast = And([Condition("objet", "contains", "route")]) + column_state = [ + {"colId": "montant", "sort": "desc"}, + {"colId": "acheteur_nom", "hide": True}, + ] + query = json.dumps({"ast": ast_to_dict(ast), "columnState": column_state}) + return saved_views_db.upsert(uid, "tableau", name, query) + + +def test_resolve_found_applies_view(monkeypatch, users_db_path): + monkeypatch.setattr(resolve.ui, "DOMAIN_NAME", "test.colibre.fr") + saved_views_db.init_schema() + uid = _make_user() + token = _seed_view(uid, "Mes Marchés") + + out = resolve.resolve_vue_param(f"mes-marches_{token}", schema) + + assert out["found"] is True + assert out["filter_model"] == { + "objet": {"filterType": "text", "type": "contains", "filter": "route"} + } + assert out["hidden_columns"] == ["acheteur_nom"] + assert out["token"] == token + assert out["url"] == f"https://test.colibre.fr/tableau?vue=mes-marches_{token}" + assert out["error"] is None + + +def test_resolve_slug_is_ignored(monkeypatch, users_db_path): + monkeypatch.setattr(resolve.ui, "DOMAIN_NAME", "test.colibre.fr") + saved_views_db.init_schema() + uid = _make_user() + token = _seed_view(uid) + # Slug bidon → même résolution. + out = resolve.resolve_vue_param(f"nimportequoi_{token}", schema) + assert out["found"] is True + assert out["token"] == token + + +def test_resolve_unknown_token_returns_error(users_db_path): + saved_views_db.init_schema() + _make_user() + out = resolve.resolve_vue_param("slug_zzzzzz", schema) + assert out["found"] is False + assert out["error"] == resolve.NOT_FOUND_MESSAGE + assert out["filter_model"] is None + + +def test_resolve_empty_param_returns_error(users_db_path): + saved_views_db.init_schema() + out = resolve.resolve_vue_param("", schema) + assert out["found"] is False + assert out["error"] == resolve.NOT_FOUND_MESSAGE + + +def test_resolve_corrupt_query_returns_error(users_db_path): + saved_views_db.init_schema() + uid = _make_user() + # query pré-migration (pas du JSON) → même message de repli. + token = saved_views_db.upsert(uid, "tableau", "Vieille", "filtres=a&tris=b") + out = resolve.resolve_vue_param(f"vieille_{token}", schema) + assert out["found"] is False + assert out["error"] == resolve.NOT_FOUND_MESSAGE