247 lines
8.8 KiB
Python
247 lines
8.8 KiB
Python
"""Datasource server-side pour AG Grid (infinite row model)."""
|
|
|
|
import polars as pl
|
|
|
|
from src.db import count_marches, count_unique_marches, query_marches, schema
|
|
from src.figures import DATA_SCHEMA
|
|
from src.utils.cache import cache
|
|
from src.utils.query_ast import ast_to_sql, filtermodel_to_ast, sort_model_to_sql
|
|
from src.utils.table import postprocess_page
|
|
|
|
|
|
@cache.memoize()
|
|
def _cached_count(where_sql: str, params: tuple) -> int:
|
|
"""Cache le COUNT(*) sur (where_sql, params).
|
|
|
|
AG Grid envoie une requête par bloc de défilement infini ; pour un même
|
|
filtre, tous les blocs partagent le même (where_sql, params) et donc le
|
|
même total — inutile de recompter un COUNT(*) sur ~1,5M lignes à chaque
|
|
bloc chargé (cf. `src.utils.table._fetch_page_sql`, même schéma).
|
|
"""
|
|
return count_marches(where_sql, params)
|
|
|
|
|
|
@cache.memoize()
|
|
def _cached_unique_count(where_sql: str, params: tuple) -> int:
|
|
"""Cache le COUNT(DISTINCT uid) sur (where_sql, params), même raison que
|
|
`_cached_count`."""
|
|
return count_unique_marches(where_sql, params)
|
|
|
|
|
|
def fetch_grid_page(
|
|
filter_model,
|
|
sort_model,
|
|
start_row: int,
|
|
end_row: int,
|
|
base_where_sql: str = "TRUE",
|
|
base_params: tuple = (),
|
|
) -> tuple[list[dict], int, int]:
|
|
"""Renvoie (row_data, total_count, total_unique_count) pour un bloc [start_row, end_row)."""
|
|
ast = filtermodel_to_ast(filter_model, schema)
|
|
filter_sql, filter_params = ast_to_sql(ast, schema)
|
|
where_sql = f"({base_where_sql}) AND ({filter_sql})"
|
|
params = [*base_params, *filter_params]
|
|
|
|
order_by = sort_model_to_sql(sort_model, schema) or None
|
|
total = _cached_count(where_sql, tuple(params))
|
|
total_unique = _cached_unique_count(where_sql, tuple(params))
|
|
|
|
limit = max(0, end_row - start_row)
|
|
page = query_marches(
|
|
where_sql=where_sql,
|
|
params=params,
|
|
order_by=order_by,
|
|
limit=limit,
|
|
offset=start_row,
|
|
)
|
|
page = postprocess_page(page)
|
|
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"]}<div class="marche-row-number">{start_row + i + 1}</div>'
|
|
)
|
|
return rows, total, total_unique
|
|
|
|
|
|
def export_dataframe(filter_model, sort_model, hidden_columns) -> pl.DataFrame:
|
|
"""Renvoie les lignes filtrées/triées pour l'export Excel.
|
|
|
|
Colonnes masquées exclues, valeurs brutes (non post-traitées HTML).
|
|
"""
|
|
ast = filtermodel_to_ast(filter_model, schema)
|
|
filter_sql, params = ast_to_sql(ast, schema)
|
|
order_by = sort_model_to_sql(sort_model, schema) or None
|
|
visible = [c for c in schema.names() if c not in set(hidden_columns or [])]
|
|
return query_marches(
|
|
where_sql=filter_sql,
|
|
params=params,
|
|
columns=visible,
|
|
order_by=order_by,
|
|
)
|
|
|
|
|
|
_LINK_COLUMNS = {
|
|
"marche",
|
|
"uid",
|
|
"acheteur_id",
|
|
"acheteur_nom",
|
|
"titulaire_id",
|
|
"titulaire_nom",
|
|
"sourceDataset",
|
|
}
|
|
|
|
# Colonnes oui/non (cf. `booleans_to_strings` dans src.db) : valeur très
|
|
# courte, pas besoin de place.
|
|
_BOOLEAN_LIKE_COLUMNS = {
|
|
"attributionAvance",
|
|
"marcheInnovant",
|
|
"sousTraitanceDeclaree",
|
|
"considerationsSociales",
|
|
"considerationsEnvironnementales",
|
|
}
|
|
|
|
# Codes/identifiants qui ne suivent pas le pattern de suffixe `_id`/`_code`
|
|
# (ex. codeCPV, idAccordCadre) mais restent des valeurs courtes.
|
|
_SHORT_CODE_COLUMNS = {
|
|
"codeCPV",
|
|
"lieuExecution_typeCode",
|
|
}
|
|
|
|
# Colonnes "nom" qui ne suivent pas le suffixe `_nom` (ex. sourceDataset).
|
|
_WIDE_LABEL_COLUMNS = {"objet"}
|
|
|
|
# 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():
|
|
return "agNumberColumnFilter"
|
|
if col_type == pl.Date:
|
|
return "agDateColumnFilter"
|
|
return "agTextColumnFilter"
|
|
|
|
|
|
def _column_width(col: str, col_type) -> dict:
|
|
"""Largeur par défaut selon la nature de la colonne.
|
|
|
|
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}
|
|
if col_type == pl.Date:
|
|
return {"width": 220}
|
|
if col_type.is_numeric():
|
|
return {"width": 150}
|
|
if col in _BOOLEAN_LIKE_COLUMNS:
|
|
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": 350}
|
|
return {"width": 190}
|
|
|
|
|
|
def grid_column_defs(hidden_columns=None):
|
|
"""columnDefs dérivés du schéma DuckDB.
|
|
|
|
'marche' (colonne loupe ajoutée par postprocess_page) est placée en tête.
|
|
"""
|
|
hidden = set(hidden_columns or [])
|
|
defs = [
|
|
{
|
|
"field": "marche",
|
|
"headerName": "",
|
|
"cellRenderer": "markdown",
|
|
"filter": False,
|
|
"sortable": False,
|
|
"maxWidth": 60,
|
|
"pinned": "left",
|
|
}
|
|
]
|
|
for col in schema.names():
|
|
meta = DATA_SCHEMA.get(col, {})
|
|
col_type = schema[col]
|
|
col_def = {
|
|
"field": col,
|
|
"headerName": meta.get("title", col),
|
|
"filter": _filter_for(col_type),
|
|
"floatingFilter": True,
|
|
"sortable": True,
|
|
"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 (<a href=...>) :
|
|
# 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":
|
|
# autoHeight n'est pas supporté avec rowModelType="infinite" (la
|
|
# grille doit pouvoir calculer la position des lignes non
|
|
# chargées, donc une hauteur de ligne fixe) : cf. ag_grid(),
|
|
# rowHeight fixe côté dashGridOptions plutôt qu'autoHeight ici.
|
|
col_def["wrapText"] = True
|
|
col_def.update(_column_width(col, col_type))
|
|
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
|