diff --git a/src/utils/table.py b/src/utils/table.py
index 871f89b..612e0a8 100644
--- a/src/utils/table.py
+++ b/src/utils/table.py
@@ -5,6 +5,7 @@ import polars as pl
from dash import no_update
from polars import selectors as cs
+from src.cache import cache
from src.db import query_marches, schema
from src.utils import logger
from src.utils.data import DATA_SCHEMA
@@ -373,6 +374,37 @@ def get_default_hidden_columns(page):
return hidden_columns
+@cache.memoize()
+def _load_filter_sort_postprocess(filter_query, sort_by_key):
+ logger.debug(
+ f"Cache miss — recomputing for filter={filter_query!r} sort={sort_by_key!r}"
+ )
+
+ lff: pl.LazyFrame = query_marches().lazy()
+
+ if filter_query:
+ lff = filter_table_data(lff, filter_query, "tableau")
+
+ if sort_by_key:
+ sort_by = [
+ {"column_id": col, "direction": direction} for col, direction in sort_by_key
+ ]
+ lff = sort_table_data(lff, sort_by)
+
+ lff = lff.cast(pl.String)
+ lff = lff.fill_null("")
+
+ dff: pl.DataFrame = lff.collect()
+
+ dff = add_links(dff)
+ if "sourceFile" in dff.columns:
+ dff = add_resource_link(dff)
+ if dff.height > 0:
+ dff = format_values(dff)
+
+ return dff
+
+
def prepare_table_data(
data, data_timestamp, filter_query, page_current, page_size, sort_by, source_table
):
diff --git a/tests/test_table.py b/tests/test_table.py
index a633332..e67676f 100644
--- a/tests/test_table.py
+++ b/tests/test_table.py
@@ -78,3 +78,80 @@ def test_normalize_sort_by_preserves_order():
[{"column_id": "b", "direction": "asc"}, {"column_id": "a", "direction": "asc"}]
)
assert a_then_b != b_then_a
+
+
+@pytest.fixture(scope="module")
+def flask_app():
+ """Minimal Flask app with SimpleCache so @cache.memoize() works in tests."""
+ from flask import Flask
+
+ from src.cache import cache
+
+ app = Flask(__name__)
+ cache.init_app(app, config={"CACHE_TYPE": "SimpleCache"})
+ return app
+
+
+@pytest.fixture(autouse=True)
+def reset_cache(flask_app):
+ """Ensure the flask-caching backend is empty between tests so that
+ cache-hit assertions are meaningful. Falls back to no-op when no
+ Flask app context is active (NullCache)."""
+ from src.cache import cache
+
+ with flask_app.app_context():
+ try:
+ cache.clear()
+ except (RuntimeError, AttributeError):
+ # No app context — cache is NullCache, nothing to clear
+ pass
+ yield
+
+
+def test_load_filter_sort_postprocess_returns_dataframe(
+ flask_app, monkeypatch, sample_lff
+):
+ from src.utils import table
+
+ monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
+
+ with flask_app.app_context():
+ df = table._load_filter_sort_postprocess(filter_query=None, sort_by_key=())
+
+ assert isinstance(df, pl.DataFrame)
+ assert df.height == 1
+ # All values must be strings after post-processing
+ for col in df.columns:
+ assert df.schema[col] == pl.String
+
+
+def test_load_filter_sort_postprocess_applies_filter(
+ flask_app, monkeypatch, sample_lff
+):
+ from src.utils import table
+
+ monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
+
+ with flask_app.app_context():
+ df = table._load_filter_sort_postprocess(
+ filter_query="{objet} icontains travaux", sort_by_key=()
+ )
+ assert df.height == 1
+
+ df_empty = table._load_filter_sort_postprocess(
+ filter_query="{objet} icontains nonexistent", sort_by_key=()
+ )
+ assert df_empty.height == 0
+
+
+def test_load_filter_sort_postprocess_adds_links(flask_app, monkeypatch, sample_lff):
+ from src.utils import table
+
+ monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
+
+ with flask_app.app_context():
+ df = table._load_filter_sort_postprocess(filter_query=None, sort_by_key=())
+ # add_links injects an wrapper around uid, acheteur_nom, titulaire_nom
+ assert "