diff --git a/.gitignore b/.gitignore
index 5a07f57..f7f699e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,11 @@ users.sqlite
# Copie jetable de tests/users.test.sqlite, générée par tests/conftest.py
tests/users.runtime.sqlite
+# Cache disque isolé pour pytest (CACHE_DIR, cf. pyproject.toml) : évite de
+# partager /tmp/colibre-cache avec le serveur dev (résultats de requêtes
+# faussés par les données de test après un rmtree() croisé, cf. src/app.py)
+tests/cache/
+
# LLM plugins
.superpowers/
.codegraph/
diff --git a/pyproject.toml b/pyproject.toml
index 80eecd7..e172214 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,6 +52,7 @@ pythonpath = ["src"]
testpaths = ["tests"]
env = [
"DATA_FILE_PARQUET_PATH=tests/test.parquet",
+ "CACHE_DIR=tests/cache",
"DEVELOPMENT=true",
"REBUILD_DUCKDB=true",
"DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json",
diff --git a/src/assets/css/style.css b/src/assets/css/style.css
index 7877de9..05c44e1 100644
--- a/src/assets/css/style.css
+++ b/src/assets/css/style.css
@@ -636,6 +636,10 @@ button.show-hide {
--ag-internal-content-line-height: 20px;
}
+.ag-cell {
+ padding-top: 3px;
+}
+
.ag-header-cell-text {
font-family: "Archivo Narrow", sans-serif;
font-size: 18px;
@@ -645,6 +649,43 @@ button.show-hide {
.ag-cell[col-id="marche"] a {
text-decoration: none;
}
+
+/* Numéro de ligne sous la loupe : repère de position pendant le défilement,
+ discret pour ne pas concurrencer le lien. */
+.ag-cell[col-id="marche"] {
+ text-align: center;
+}
+.marche-row-number {
+ color: #bbb;
+ font-size: 12px;
+ line-height: 1.4;
+}
+
+/* Infobulle : largeur bornée avec retour à la ligne plutôt qu'une largeur
+ qui suit tout le texte. .ag-tooltip = infobulle native (ex. colonne
+ "objet" tronquée) ; .ag-tooltip-custom = infobulle rendue par un
+ tooltipComponent personnalisé (ex. en-têtes de colonnes, cf.
+ grid_column_defs) — AG Grid n'ajoute PAS la classe .ag-tooltip dans ce
+ cas, il faut donc cibler les deux. */
+.ag-tooltip,
+.ag-tooltip-custom {
+ max-width: 400px;
+ white-space: normal;
+ word-wrap: break-word;
+ background-color: #fdd;
+}
+
+/* .ag-tooltip-custom n'a, contrairement à .ag-tooltip, aucun style visuel
+ par défaut côté AG Grid (voir dash_ag_grid.min.js : seuls position/z-index
+ sont posés) — on reprend ici les mêmes variables de thème que .ag-tooltip
+ pour un rendu identique (bordure grise, padding, coins arrondis). */
+.ag-tooltip-custom {
+ border: var(--ag-tooltip-border);
+ border-radius: var(--ag-border-radius);
+ color: var(--ag-tooltip-text-color);
+ padding: var(--ag-widget-container-vertical-padding)
+ var(--ag-widget-container-horizontal-padding);
+}
/* fin overrides AG Grid*/
#btn-copy-url:before {
diff --git a/src/figures.py b/src/figures.py
index 40f5982..1b49db2 100644
--- a/src/figures.py
+++ b/src/figures.py
@@ -1131,6 +1131,9 @@ def ag_grid(grid_id: str, column_defs: list[dict]) -> "dag.AgGrid":
# Permet de sélectionner/copier le texte des infobulles (ex.
# colonne "objet" tronquée, cf. tooltipField dans grid_column_defs).
"tooltipInteraction": True,
+ # Délai avant apparition des infobulles natives (200ms au lieu
+ # des 2000ms par défaut d'AG Grid).
+ "tooltipShowDelay": 200,
"localeText": AG_GRID_LOCALE_FR,
"theme": {
"function": (
diff --git a/src/pages/tableau.py b/src/pages/tableau.py
index db9e53a..b454dee 100644
--- a/src/pages/tableau.py
+++ b/src/pages/tableau.py
@@ -24,7 +24,7 @@ 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 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.grid import apply_persisted_layout, fetch_grid_page, grid_column_defs
from src.utils.query_ast import (
ast_from_dict,
ast_to_dict,
@@ -522,11 +522,13 @@ def update_hidden_columns_from_checkboxes(selected_columns):
@callback(
Output("tableau_grid", "columnDefs"),
Input("tableau-hidden-columns", "data"),
+ State("tableau_grid", "columnState"),
)
-def apply_hidden_columns(hidden_columns):
+def apply_hidden_columns(hidden_columns, column_state):
if hidden_columns is None:
hidden_columns = get_default_hidden_columns("tableau")
- return grid_column_defs(hidden_columns)
+ defs = grid_column_defs(hidden_columns)
+ return apply_persisted_layout(defs, column_state)
@callback(
@@ -560,16 +562,20 @@ 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),
+ Output("tableau_grid", "columnState", allow_duplicate=True),
Input("btn-tableau-reset", "n_clicks"),
+ State("tableau_grid", "columnState"),
prevent_initial_call=True,
)
-def reset_view(n_clicks):
- # 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
+def reset_view(n_clicks, column_state):
+ # On ne touche qu'au tri (sort/sortIndex) : on réécrit le columnState tel
+ # quel pour préserver la largeur, l'épinglage et l'ordre des colonnes
+ # choisis par l'utilisateur (auparavant resetColumnState les effaçait
+ # aussi, cf. #47).
+ cleared_sort = [
+ {**col, "sort": None, "sortIndex": None} for col in (column_state or [])
+ ]
+ return {}, cleared_sort
@callback(
diff --git a/src/utils/grid.py b/src/utils/grid.py
index a795be4..fc29a1e 100644
--- a/src/utils/grid.py
+++ b/src/utils/grid.py
@@ -55,7 +55,17 @@ def fetch_grid_page(
offset=start_row,
)
page = postprocess_page(page)
- return page.to_dicts(), total, total_unique
+ rows = page.to_dicts()
+ # Numéro de ligne absolu (position dans les résultats filtrés/triés),
+ # ajouté sous le lien loupe : repère visuel pendant le défilement infini
+ # de la grille. Calculé ici (pas dans postprocess_page, partagé avec les
+ # dash_table paginées d'acheteur.py/titulaire.py) car il dépend de
+ # `start_row`, propre au chargement par bloc de l'AG Grid.
+ for i, row in enumerate(rows):
+ row["marche"] = (
+ f'{row["marche"]}
{start_row + i + 1}
'
+ )
+ return rows, total, total_unique
def export_dataframe(filter_model, sort_model, hidden_columns) -> pl.DataFrame:
@@ -98,17 +108,28 @@ _BOOLEAN_LIKE_COLUMNS = {
# Codes/identifiants qui ne suivent pas le pattern de suffixe `_id`/`_code`
# (ex. codeCPV, idAccordCadre) mais restent des valeurs courtes.
_SHORT_CODE_COLUMNS = {
- "uid",
- "id",
"codeCPV",
- "idAccordCadre",
"lieuExecution_typeCode",
- "sourceFile",
}
# Colonnes "nom" qui ne suivent pas le suffixe `_nom` (ex. sourceDataset).
_WIDE_LABEL_COLUMNS = {"sourceDataset"}
+# Colonnes avec infobulle systématique sur les cellules (valeur complète au
+# survol, pas seulement quand le texte est tronqué).
+_CELL_TOOLTIP_COLUMNS = {
+ "procedure",
+ "considerationsSociales",
+ "considerationsEnvironnementales",
+ "objet",
+ "ccag",
+ "modalitesExecution",
+ "acheteur_nom",
+ "titulaire_nom",
+ "acheteur_id",
+ "titulaire_id",
+}
+
def _filter_for(col_type) -> str:
if col_type.is_numeric():
@@ -121,23 +142,24 @@ def _filter_for(col_type) -> str:
def _column_width(col: str, col_type) -> dict:
"""Largeur par défaut selon la nature de la colonne.
- `columnSize="responsiveSizeToFit"` (cf. ag_grid()) étire les colonnes
- proportionnellement à cette largeur pour remplir l'espace disponible :
- sans ça, les codes/booléens s'étirent autant que les noms/libellés.
+ Sert de base à l'affichage initial (pas de columnSize="responsiveSizeToFit",
+ cf. ag_grid() : les colonnes ne s'étirent/compressent pas automatiquement).
+ Pas de maxWidth ici : resizable=True (defaultColDef) doit pouvoir élargir
+ librement une colonne à la souris, sans plafond artificiel.
"""
if col == "montant":
- return {"width": 150, "maxWidth": 200}
+ return {"width": 150}
if col_type == pl.Date:
- return {"width": 140, "maxWidth": 160}
+ return {"width": 220}
if col_type.is_numeric():
- return {"width": 120, "maxWidth": 150}
+ return {"width": 150}
if col in _BOOLEAN_LIKE_COLUMNS:
- return {"width": 110, "maxWidth": 140}
- if col in _SHORT_CODE_COLUMNS or col.endswith(("_id", "_code")):
- return {"width": 130, "maxWidth": 170}
+ return {"width": 110}
+ if col in _SHORT_CODE_COLUMNS or col.endswith(("_code")):
+ return {"width": 180}
if col in _WIDE_LABEL_COLUMNS or col.endswith("_nom"):
- return {"width": 200}
- return {"width": 170}
+ return {"width": 300}
+ return {"width": 190}
def grid_column_defs(hidden_columns=None):
@@ -169,11 +191,22 @@ def grid_column_defs(hidden_columns=None):
"hide": col in hidden,
}
if meta.get("description"):
+ # Texte brut (pas de markdown) : le renderer "markdown" côté
+ # dash-ag-grid n'est enregistré que comme cellRenderer, pas
+ # comme tooltipComponent — posé sur un en-tête, il retombe sur
+ # le rendu natif d'AG Grid, qui affiche la chaîne telle quelle
+ # (donc "**gras**" apparaissait littéralement avec les astérisques).
col_def["headerTooltip"] = (
f"{meta.get('title', col)} ({col}) — {meta['description']}"
)
if col in _LINK_COLUMNS:
col_def["cellRenderer"] = "markdown"
+ if col in _CELL_TOOLTIP_COLUMNS:
+ # Valeur complète en infobulle. Pour les colonnes-liens
+ # (_LINK_COLUMNS), la cellule contient du HTML () :
+ # on pointe vers la copie texte brut posée par add_links() dans
+ # src.utils.table plutôt que d'afficher ce balisage tel quel.
+ col_def["tooltipField"] = f"{col}_tooltip" if col in _LINK_COLUMNS else col
if col != "objet":
col_def.update(_column_width(col, col_type))
if col == "objet":
@@ -183,8 +216,33 @@ def grid_column_defs(hidden_columns=None):
# rowHeight fixe côté dashGridOptions plutôt qu'autoHeight ici.
col_def["wrapText"] = True
col_def["minWidth"] = 360
- # Le texte tronqué par la hauteur de ligne fixe reste consultable
- # en entier via l'infobulle native AG Grid au survol.
- col_def["tooltipField"] = "objet"
defs.append(col_def)
return defs
+
+
+def apply_persisted_layout(
+ defs: list[dict], column_state: list[dict] | None
+) -> list[dict]:
+ """Réapplique la largeur et l'ordre des colonnes personnalisés par
+ l'utilisateur (columnState restauré via la persistance AG Grid) par-dessus
+ des columnDefs fraîchement régénérés par grid_column_defs(), qui eux
+ ignorent tout état précédent et ne portent que la largeur par défaut et
+ l'ordre du schéma.
+
+ Sans ça, tout changement de columnDefs (à chaque chargement de page ou
+ changement de colonnes affichées) écrase silencieusement le
+ redimensionnement/réordonnancement fait par l'utilisateur, cf. #47.
+ """
+ if not column_state:
+ return defs
+ order = {col["colId"]: i for i, col in enumerate(column_state)}
+ widths = {col["colId"]: col["width"] for col in column_state if col.get("width")}
+ for col_def in defs:
+ field = col_def["field"]
+ if field in widths:
+ col_def["width"] = widths[field]
+ # Colonnes déjà connues dans leur ordre persisté, colonnes nouvelles
+ # (ex. ajout au schéma, jamais vues dans columnState) à la fin dans
+ # leur ordre par défaut.
+ defs.sort(key=lambda d: order.get(d["field"], len(order)))
+ return defs
diff --git a/src/utils/table.py b/src/utils/table.py
index 1d7681a..846e929 100644
--- a/src/utils/table.py
+++ b/src/utils/table.py
@@ -50,7 +50,26 @@ def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
return dff
+_TOOLTIP_SHADOW_COLUMNS = [
+ "acheteur_nom",
+ "titulaire_nom",
+ "acheteur_id",
+ "titulaire_id",
+]
+
+
def add_links(dff: pl.DataFrame):
+ # Copie du texte brut avant réécriture en HTML ci-dessous, pour que la
+ # grille AG Grid (cf. grid_column_defs dans src.utils.grid) puisse
+ # afficher une infobulle lisible sur ces colonnes plutôt que le balisage
+ # brut.
+ dff = dff.with_columns(
+ [
+ pl.col(c).alias(f"{c}_tooltip")
+ for c in _TOOLTIP_SHADOW_COLUMNS
+ if c in dff.columns
+ ]
+ )
for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
if col in dff.columns:
if col.startswith("titulaire_"):
diff --git a/tests/test_grid.py b/tests/test_grid.py
index d077791..bbed6d7 100644
--- a/tests/test_grid.py
+++ b/tests/test_grid.py
@@ -5,7 +5,12 @@ import pytest
import src.app # noqa: F401 # instancie l'app → register_page() des pages
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
+from src.utils.grid import (
+ apply_persisted_layout,
+ export_dataframe,
+ fetch_grid_page,
+ grid_column_defs,
+)
@pytest.fixture(scope="module")
@@ -52,25 +57,27 @@ def test_column_defs_hidden_flag():
def test_fetch_grid_page_returns_rows_and_count():
- rows, total = fetch_grid_page(None, None, 0, 20)
+ rows, total, total_unique = fetch_grid_page(None, None, 0, 20)
assert isinstance(rows, list)
assert isinstance(total, int)
assert total >= len(rows)
+ assert total_unique <= total
+
if rows:
# postprocess_page ajoute une colonne 'marche' avec un lien
assert "marche" in rows[0]
def test_fetch_grid_page_filter_reduces_count():
- _, total_all = fetch_grid_page(None, None, 0, 1)
+ _, total_all, _ = fetch_grid_page(None, None, 0, 1)
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "zzzzzznope"}}
- rows, total_filtered = fetch_grid_page(fm, None, 0, 20)
+ rows, total_filtered, _ = fetch_grid_page(fm, None, 0, 20)
assert total_filtered <= total_all
assert rows == [] and total_filtered == 0
def test_fetch_grid_page_offset_slicing():
- rows, _ = fetch_grid_page(None, None, 0, 5)
+ rows, _, _ = fetch_grid_page(None, None, 0, 5)
assert len(rows) <= 5
@@ -113,9 +120,9 @@ def test_fetch_grid_page_caches_count_across_scroll_blocks(flask_app, monkeypatc
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "route"}}
with flask_app.app_context():
- _, total1 = fetch_grid_page(fm, None, 0, 20)
- _, total2 = fetch_grid_page(fm, None, 20, 40)
- _, total3 = fetch_grid_page(fm, None, 40, 60)
+ _, total1, _ = fetch_grid_page(fm, None, 0, 20)
+ _, total2, _ = fetch_grid_page(fm, None, 20, 40)
+ _, total3, _ = fetch_grid_page(fm, None, 40, 60)
assert call_count["n"] == 1
assert total1 == total2 == total3
@@ -124,6 +131,83 @@ def test_fetch_grid_page_caches_count_across_scroll_blocks(flask_app, monkeypatc
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)
+ column_state = [{"colId": "objet", "sort": "asc", "sortIndex": 0, "width": 200}]
+ filter_model, new_column_state = reset_view(1, column_state)
assert filter_model == {}
- assert reset_column_state is True
+ assert new_column_state == [
+ {"colId": "objet", "sort": None, "sortIndex": None, "width": 200}
+ ]
+
+
+def test_reset_view_preserves_column_width():
+ """Le bouton Réinitialiser ne doit remettre à zéro que les filtres et les
+ tris, pas la largeur (ni le pinning/ordre) des colonnes."""
+ column_state = [
+ {"colId": "objet", "sort": "asc", "sortIndex": 0, "width": 350, "hide": True},
+ {
+ "colId": "montant",
+ "sort": None,
+ "sortIndex": None,
+ "width": 120,
+ "hide": False,
+ },
+ ]
+ _, new_column_state = reset_view(1, column_state)
+ assert [c["width"] for c in new_column_state] == [350, 120]
+ assert [c["hide"] for c in new_column_state] == [True, False]
+ assert all(c["sort"] is None and c["sortIndex"] is None for c in new_column_state)
+
+
+def test_apply_persisted_layout_restores_width_and_order():
+ """Régression : la largeur et l'ordre des colonnes choisis par
+ l'utilisateur étaient perdus au rechargement de la page, écrasés par les
+ columnDefs régénérés par apply_hidden_columns à chaque chargement (#47)."""
+ defs = [
+ {"field": "marche", "width": 60},
+ {"field": "objet", "minWidth": 360},
+ {"field": "montant", "width": 150},
+ {"field": "acheteur_nom", "width": 200},
+ ]
+ # L'utilisateur a redimensionné "montant" et déplacé "acheteur_nom" avant "objet"
+ column_state = [
+ {"colId": "marche", "width": 60},
+ {"colId": "acheteur_nom", "width": 200},
+ {"colId": "objet", "width": 500},
+ {"colId": "montant", "width": 320},
+ ]
+ result = apply_persisted_layout(defs, column_state)
+ assert [d["field"] for d in result] == [
+ "marche",
+ "acheteur_nom",
+ "objet",
+ "montant",
+ ]
+ assert next(d for d in result if d["field"] == "montant")["width"] == 320
+ # "objet" n'a pas de clé "width" dans columnDefs (minWidth seulement, cf.
+ # grid_column_defs) : columnState en fournit une, elle doit être reprise.
+ assert next(d for d in result if d["field"] == "objet")["width"] == 500
+
+
+def test_apply_persisted_layout_places_unknown_columns_last():
+ """Une colonne absente du columnState persisté (jamais vue par
+ l'utilisateur, ex. ajout au schéma) doit rester à la fin plutôt que de
+ faire planter le tri."""
+ defs = [
+ {"field": "marche", "width": 60},
+ {"field": "objet", "minWidth": 360},
+ {"field": "nouvelle_colonne", "width": 130},
+ ]
+ column_state = [
+ {"colId": "objet", "width": 400},
+ {"colId": "marche", "width": 60},
+ ]
+ result = apply_persisted_layout(defs, column_state)
+ assert [d["field"] for d in result] == ["objet", "marche", "nouvelle_colonne"]
+
+
+def test_apply_persisted_layout_noop_without_prior_state():
+ """Premier chargement (jamais de columnState persisté) : columnDefs
+ inchangés, pas d'erreur sur None/[]."""
+ defs = [{"field": "marche", "width": 60}, {"field": "objet", "minWidth": 360}]
+ assert apply_persisted_layout(defs, None) == defs
+ assert apply_persisted_layout(defs, []) == defs