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"]