feat(figures): get_top_org_ag_grid (top 10 en AG Grid client-side) (#41)
This commit is contained in:
+87
-9
@@ -1017,18 +1017,24 @@ def make_column_picker(page: str):
|
|||||||
return table
|
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):
|
if isinstance(data, pl.LazyFrame):
|
||||||
lff = data
|
lff = data
|
||||||
else:
|
else:
|
||||||
lff = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
|
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":
|
if org_type == "titulaire":
|
||||||
extra_columns.append("titulaire_typeIdentifiant")
|
extra.append("titulaire_typeIdentifiant")
|
||||||
columns = ["uid", f"{org_type}_id", f"{org_type}_nom"] + extra_columns
|
columns = ["uid", f"{org_type}_id", f"{org_type}_nom"] + extra
|
||||||
|
|
||||||
lff = lff.select(columns)
|
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")
|
pl.len().alias("Attributions")
|
||||||
)
|
)
|
||||||
lff = lff.sort(by="Attributions", descending=True, nulls_last=True)
|
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:
|
try:
|
||||||
dff: pl.DataFrame = lff.collect(engine="streaming")
|
dff: pl.DataFrame = lff.collect(engine="streaming")
|
||||||
except ColumnNotFoundError:
|
except ColumnNotFoundError as e:
|
||||||
logger.warning(f"get_top_org_table: column not found. {lff.collect_schema()}")
|
# Ne pas rappeler lff.collect_schema() ici : sur le même plan lazy
|
||||||
return html.Div()
|
# 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()
|
return html.Div()
|
||||||
|
|
||||||
columns, tooltip = setup_table_columns(
|
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)
|
dff = add_links(dff)
|
||||||
data = dff.to_dicts()
|
data = dff.to_dicts()
|
||||||
# data = add_links_in_dict(data, f"{org_type}")
|
|
||||||
|
|
||||||
return DataTable(
|
return DataTable(
|
||||||
dtid=f"top10_{org_type}",
|
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 <a>), 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 <a> 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
|
# 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
|
# AG Grid localeText, exposée via dashGridOptions — n'affecte pas l'apparence
|
||||||
# de base conservée au Lot 1, seulement le texte).
|
# de base conservée au Lot 1, seulement le texte).
|
||||||
|
|||||||
@@ -405,3 +405,63 @@ def test_ag_grid_defaults_unchanged_for_tableau():
|
|||||||
assert grid.id == "tableau_grid"
|
assert grid.id == "tableau_grid"
|
||||||
assert list(grid.persisted_props) == ["filterModel", "columnState"]
|
assert list(grid.persisted_props) == ["filterModel", "columnState"]
|
||||||
assert grid.persistence is True
|
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user