diff --git a/src/figures.py b/src/figures.py
index 482d56e..440d6a5 100644
--- a/src/figures.py
+++ b/src/figures.py
@@ -1017,18 +1017,24 @@ def make_column_picker(page: str):
return table
-def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool = True):
+def _top_org_aggregate(data, org_type: str, extra_columns: list):
+ """Agrégation « top N » commune à get_top_org_table et get_top_org_ag_grid.
+
+ Renvoie le DataFrame agrégé (colonnes castées en str, tri par Attributions
+ décroissant), AVANT add_links, ou None si vide/colonne manquante.
+ """
if isinstance(data, pl.LazyFrame):
lff = data
else:
lff = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
+ extra = list(extra_columns) # copie : ne pas muter la liste du caller
if org_type == "titulaire":
- extra_columns.append("titulaire_typeIdentifiant")
- columns = ["uid", f"{org_type}_id", f"{org_type}_nom"] + extra_columns
+ extra.append("titulaire_typeIdentifiant")
+ columns = ["uid", f"{org_type}_id", f"{org_type}_nom"] + extra
lff = lff.select(columns)
- lff = lff.group_by([f"{org_type}_id", f"{org_type}_nom"] + extra_columns).agg(
+ lff = lff.group_by([f"{org_type}_id", f"{org_type}_nom"] + extra).agg(
pl.len().alias("Attributions")
)
lff = lff.sort(by="Attributions", descending=True, nulls_last=True)
@@ -1037,11 +1043,18 @@ def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool =
try:
dff: pl.DataFrame = lff.collect(engine="streaming")
- except ColumnNotFoundError:
- logger.warning(f"get_top_org_table: column not found. {lff.collect_schema()}")
- return html.Div()
+ except ColumnNotFoundError as e:
+ # Ne pas rappeler lff.collect_schema() ici : sur le même plan lazy
+ # cassé, la résolution du schéma lève à nouveau ColumnNotFoundError
+ # (non rattrapée), au lieu de renvoyer None comme prévu.
+ logger.warning(f"_top_org_aggregate: column not found. {e}")
+ return None
+ return dff if dff.height > 0 else None
- if dff.height == 0:
+
+def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool = True):
+ dff = _top_org_aggregate(data, org_type, extra_columns)
+ if dff is None:
return html.Div()
columns, tooltip = setup_table_columns(
@@ -1049,7 +1062,6 @@ def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool =
)
dff = add_links(dff)
data = dff.to_dicts()
- # data = add_links_in_dict(data, f"{org_type}")
return DataTable(
dtid=f"top10_{org_type}",
@@ -1062,6 +1074,72 @@ def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool =
)
+def get_top_org_ag_grid(data, org_type: str, extra_columns: list, filters: bool = True):
+ """Top N acheteurs/titulaires en AG Grid client-side (rowData directe).
+
+ Remplace get_top_org_table (dash_table) : même agrégation (helper partagé),
+ rendu AG Grid (thème brique, liens markdown). html.Div() si vide/erreur.
+ """
+ dff = _top_org_aggregate(data, org_type, extra_columns)
+ if dff is None:
+ return html.Div()
+
+ dff = add_links(dff)
+
+ # columnDefs : on masque l'id (lien porté par le nom), on rend le nom en
+ # markdown (HTML ), on cache les colonnes techniques *_tooltip.
+ id_col = f"{org_type}_id"
+ nom_col = f"{org_type}_nom"
+ link_cols = {nom_col}
+ column_defs = []
+ for col in dff.columns:
+ if col == id_col or col.endswith("_tooltip"):
+ continue
+ col_def = {
+ "field": col,
+ "headerName": DATA_SCHEMA.get(col, {}).get("title", col),
+ "sortable": True,
+ "filter": "agTextColumnFilter" if filters else False,
+ }
+ if col in link_cols:
+ col_def["cellRenderer"] = "markdown"
+ col_def["flex"] = 1
+ column_defs.append(col_def)
+
+ # ag_grid() du Lot 1 est server-side (rowModelType="infinite", pas de
+ # rowData) : inadapté au top 10, qui est client-side. On construit donc une
+ # AG Grid client-side directe, en réutilisant le thème + localeText.
+ return dag.AgGrid(
+ id=f"top10_{org_type}",
+ columnDefs=column_defs,
+ rowData=dff.to_dicts(),
+ dangerously_allow_code=True, # rend le HTML des cellules liens
+ columnSize="responsiveSizeToFit",
+ dashGridOptions={
+ "domLayout": "autoHeight", # OK ici : row model client-side
+ "pagination": True,
+ "paginationPageSize": 10,
+ "suppressCellFocus": True,
+ "localeText": AG_GRID_LOCALE_FR,
+ "theme": {
+ "function": (
+ "themeQuartz.withParams({"
+ "accentColor: 'rgb(179, 56, 33)',"
+ "headerTextColor: 'white',"
+ "headerBackgroundColor: 'rgb(179, 56, 33)',"
+ "oddRowBackgroundColor: 'rgba(255, 240, 240, 0.4)',"
+ "borderColor: '#ccc',"
+ "fontFamily: 'Inter, sans-serif',"
+ "fontSize: 16"
+ "})"
+ )
+ },
+ },
+ style={"width": "100%"},
+ persistence=False,
+ )
+
+
# Libellés du menu de filtre AG Grid, traduits en français (option native
# AG Grid localeText, exposée via dashGridOptions — n'affecte pas l'apparence
# de base conservée au Lot 1, seulement le texte).
diff --git a/tests/test_figures.py b/tests/test_figures.py
index 9b91e11..b4aa7a8 100644
--- a/tests/test_figures.py
+++ b/tests/test_figures.py
@@ -405,3 +405,63 @@ def test_ag_grid_defaults_unchanged_for_tableau():
assert grid.id == "tableau_grid"
assert list(grid.persisted_props) == ["filterModel", "columnState"]
assert grid.persistence is True
+
+
+def test_get_top_org_ag_grid_returns_grid_with_rowdata():
+ """Le top 10 devient une AG Grid client-side (rowData directe, pas de
+ getRowsRequest server-side)."""
+ import dash_ag_grid as dag
+
+ from src.db import query_marches
+ from src.figures import get_top_org_ag_grid
+
+ lff = query_marches("TRUE", (), columns=None).lazy()
+ grid = get_top_org_ag_grid(lff, "titulaire", ["titulaire_distance"])
+
+ assert isinstance(grid, dag.AgGrid)
+ assert grid.rowData is not None and len(grid.rowData) > 0
+ # Colonne d'agrégat présente ; colonnes définies.
+ assert any(c.get("field") == "Attributions" for c in grid.columnDefs)
+ # Pas de persistance (petit tableau statique, régénéré à chaque fiche).
+ assert grid.persistence in (False, None)
+
+
+def test_get_top_org_ag_grid_empty_returns_div():
+ """Données vides → html.Div() (parité get_top_org_table)."""
+ import polars as pl
+ from dash import html
+
+ from src.figures import get_top_org_ag_grid
+
+ empty = pl.DataFrame(
+ {"uid": [], "titulaire_id": [], "titulaire_nom": [], "titulaire_distance": []}
+ ).lazy()
+ out = get_top_org_ag_grid(empty, "titulaire", ["titulaire_distance"])
+ assert isinstance(out, html.Div)
+
+
+def test_get_top_org_table_still_returns_datatable_after_refactor():
+ """Non-régression : get_top_org_table (utilisé par observatoire.py) garde
+ son comportement après extraction du helper d'agrégation partagé."""
+ import dash_ag_grid # noqa: F401
+ from dash import html
+
+ from src.db import query_marches
+ from src.figures import DataTable, get_top_org_table
+
+ lff = query_marches("TRUE", (), columns=None).lazy()
+ out = get_top_org_table(lff, "titulaire", ["titulaire_distance"])
+ assert isinstance(out, (DataTable, html.Div))
+
+
+def test_top_org_aggregate_does_not_mutate_extra_columns():
+ """Le helper ne mute pas la liste extra_columns passée (bug latent de
+ l'ancien get_top_org_table, qui faisait extra_columns.append(...))."""
+ from src.db import query_marches
+ from src.figures import _top_org_aggregate
+
+ extra = ["titulaire_distance"]
+ _top_org_aggregate(
+ query_marches("TRUE", (), columns=None).lazy(), "titulaire", extra
+ )
+ assert extra == ["titulaire_distance"]