- ag_grid() à paramétrer (id dict + persisted_props), export_dataframe() à étendre (base_where_sql/base_params) : extensions rétro-compatibles Lot 1 - reset efface filtres ET tris (approche #47-safe, préserve largeur/ordre) - compteur "X marchés (Y lignes)" reproduit depuis /tableau (total_unique) - columnState relu depuis le store partagé (pas le State grille), anti-boucle
16 KiB
Migration des tables vers Dash AG Grid — Design (Lot 2a : acheteur.py + titulaire.py)
- Issues : #41 (migration AG Grid)
- Date : 2026-07-13
- Fait suite à :
docs/superpowers/specs/2026-07-09-migration-ag-grid-design.md(Lot 1,tableau.py)
Contexte
Le Lot 1 a migré tableau.py de dash_table.DataTable vers dag.AgGrid, avec un moteur de requête générique (AST booléen → SQL DuckDB, src/utils/query_ast.py) et une datasource server-side réutilisable (fetch_grid_page, grid_column_defs, apply_persisted_layout dans src/utils/grid.py, déjà paramétrée par base_where_sql/base_params). Depuis, ag_grid() (src/figures.py) a aussi reçu un thème ("brique", accentColor rgb(179, 56, 33), police Inter) qui remplace l'apparence de base initialement conservée au Lot 1.
acheteur.py et titulaire.py sont deux pages quasi-identiques (même auteur, même pattern, juste paramétrées par le type d'entité) affichant les marchés d'un acheteur ou d'un titulaire donné. Chacune a :
- un tableau principal déjà paginé/filtré/trié server-side sur DuckDB, mais via l'ancien DSL
DataTable(filter_query/sort_by, mono-condition — la même limite qui causait le bug corrigé sur/tableau, cf. commit117ce9a), - deux boutons d'export Excel (toutes les données de la fiche / données filtrées, ce dernier via pipeline Polars),
- un sélecteur de colonnes, un bouton Réinitialiser,
- un petit tableau "top 10" agrégé (
get_top_org_table, données déjà chargées, pagination/filtre natifs sans aller-retour serveur).
Dash abandonnant dash_table.DataTable, cette migration doit couvrir ces deux pages avant que la dépréciation ne devienne bloquante.
Objectifs
- Préserver les fonctionnalités existantes (filtre/tri/pagination server-side scopés à l'entité, export x2, sélecteur de colonnes, reset) en passant à
dag.AgGrid. - Réutiliser au maximum l'infrastructure du Lot 1 (
ag_grid(),grid_column_defs(),fetch_grid_page(),apply_persisted_layout(), thème brique) plutôt que de la redupliquer. - Factoriser la logique commune à acheteur.py/titulaire.py (déjà dupliquée aujourd'hui) dans un nouveau module partagé, plutôt que de dupliquer une troisième fois la logique AG Grid.
- Gagner le filtre multi-conditions (ET/OU) gratuitement, comme sur
/tableau, en remplaçant le DSLfilter_querypar lefilterModelAG Grid + AST.
Non-objectifs (reportés)
observatoire.py: sous-projet séparé (Lot 2b), architecture de données différente (jusqu'à ~1,5M lignes matérialisées, cf.prepare_dashboard_data).recherche.py,admin/liste.py,figures.make_table(page a-propos/sources) : Lot 3, pages plus simples.- #97 (requêtes booléennes texte libre, abonnés) — inchangé, le moteur AST du Lot 1 reste le socle.
- Suppression de l'ancien DSL (
filter_query_to_sql,clean_filters) : reportée à la fin du Lot 3 tant que d'autres pages s'en servent encore.
Décisions d'architecture
Nouveau module src/utils/entity_grid.py
Factorise la logique commune aux deux pages, paramétrée par org_type: Literal["acheteur", "titulaire"] :
entity_grid_column_defs(org_type, hidden_columns, column_state) -> list[dict]:grid_column_defs()du Lot 1 +apply_persisted_layout()par-dessus (réutilisés tels quels, aucune modif requise).fetch_entity_grid_page(filter_model, sort_model, start_row, end_row, base_where_sql, base_params) -> (rows, total, total_unique): appelle directementfetch_grid_page()du Lot 1 (déjà générique, aucune modif requise).export_entity_grid(filter_model, sort_model, hidden_columns, base_where_sql, base_params) -> pl.DataFrame: variante deexport_dataframe()du Lot 1, étendue avecbase_where_sql/base_params.entity_ag_grid(grid_id: dict, ...) -> dag.AgGrid: appelleag_grid()du Lot 1 (même thème, même config) avec unidpattern-matching au lieu d'une string.- Fabrique de callbacks pattern-matching partagée (datasource, reset, application des colonnes masquées, export filtré) enregistrée une fois, paramétrée par
org_type— évite de dupliquer les@callbackdansacheteur.pyettitulaire.py.
Modifications requises côté Lot 1 (grid.py / figures.py) :
export_dataframe(): ajouter les paramètresbase_where_sql="TRUE"/base_params=()(aujourd'hui absents — cf.src/utils/grid.py), composés en(base) AND (filtre)comme le fait déjàfetch_grid_page().tableau.pycontinue de l'appeler sans ces arguments (valeurs par défaut → comportement inchangé).ag_grid(): paramétrer la persistance, aujourd'hui codée en dur àpersistence=True, persisted_props=["filterModel", "columnState"]etid: str. Il faut accepter (a) uniddict (pattern-matching) en plus d'une string, et (b) unpersisted_propsconfigurable. La grille entité ne persistera que["filterModel"](cf. « Persistance découplée » ci-dessous) ;tableau.pygarde["filterModel", "columnState"]par défaut → comportement inchangé.
acheteur.py/titulaire.py gardent leur fichier propre (URL, infos annuaire, carte, stats, histogramme, bouton "toutes les données" — tout ce qui n'est pas la grille) et appellent ce module pour tout ce qui est grille.
layout devient une fonction
layout = [...] (liste statique) devient def layout(acheteur_id=None, **kwargs): ... (resp. titulaire_id) — mécanisme standard Dash pour les routes path_template dynamiques. Dash rappelle cette fonction à chaque navigation vers /acheteurs/<acheteur_id>, ce qui permet de connaître acheteur_id au moment de construire le layout, sans passer par dcc.Location.
Périmètre du changement : seuls la grille et les éléments qui doivent être scopés par entité changent d'id/de câblage. Tout le reste (siret, nom, carte, stats, histogramme, bouton "toutes les données", top 10) garde exactement son câblage actuel (id fixe + Input("acheteur_url", "pathname")) — dcc.Location(id="acheteur_url") est conservé pour ces callbacks, qui n'ont pas besoin de changer.
Id pattern-matching de la grille
grid_id = {"type": "acheteur-grid", "acheteur_id": acheteur_id, "year": ach_year_at_render}
Comme ach_year (dropdown, pas dans l'URL) n'est connu qu'au runtime et non au premier rendu du layout(), l'id inclut une valeur par défaut ("Toutes les années") à la construction ; un changement d'année remonte la grille (nouvel id via un Output(html.Div, "children") qui reconstruit le composant dag.AgGrid avec le nouvel id) plutôt que de tenter un rafraîchissement in-place — le row model infinite d'AG Grid n'a pas de mécanisme pour se rafraîchir sur un changement externe au filtre/tri/scroll. Effet de bord accepté : filterModel se réinitialise aussi au changement d'année (raisonnable, non-régression par rapport au DSL actuel qui ne le préservait pas non plus explicitement).
Callbacks pattern-matching (dans entity_grid.py, via MATCH) :
- datasource :
Input({"type": f"{org_type}-grid", "acheteur_id": MATCH, "year": MATCH}, "getRowsRequest")→Output(..., "getRowsResponse"). - reset : bouton (id fixe, hors grille) → efface filtres ET tris (le libellé du bouton dit « Supprime tous les filtres et les tris »), donc
Output({"type": f"{org_type}-grid", ...}, "filterModel")etOutput(..., "columnState")viaALL(un seul reset visible à la fois, maisALLreste nécessaire car ce n'est pas le composant qui émet l'événement). On reproduit l'approche #47-safe detableau.py::reset_view: le tri est effacé en réécrivantcolumnStateavecsort=None/sortIndex=None, ce qui préserve largeur/ordre/épinglage des colonnes (ne pas utiliserresetColumnState, qui les effacerait aussi). - export filtré : lit
State({"type": f"{org_type}-grid", ...}, "filterModel")/"columnState"viaALL(un seul match actif ; lesortModelest dérivé ducolumnStatecomme danstableau.py::download_data).
Persistance découplée : filterModel vs columnState
filterModel: persistance native AG Grid (persistence=True, persistence_type="local", persisted_props=["filterModel"]) sur l'id pattern-matching ci-dessus → une entrée localStorage distincte par(acheteur_id, year), isolée par fiche.columnState: pas de persistance native AG Grid pour cette prop (elle serait scopée par le même id, donc par entité — indésirable : la disposition des colonnes est une préférence utilisateur globale). À la place :- un
dcc.Store(id="entity-grid-columns-state", storage_type="local")partagé entre acheteur.py et titulaire.py (même schéma DECP dans les deux cas), - un callback pattern-matching
Input({"type": ..., ...}, "columnState")(ALL) →Output("entity-grid-columns-state", "data")écrit dedans à chaque changement, entity_grid_column_defs()lit ce store et réapplique largeur/ordre viaapply_persisted_layout()(déjà prévu pour ça) avant de construire lescolumnDefsde la grille, peu importe l'acheteur/titulaire affiché.
- un
Différence avec
tableau.py: là-bas,apply_hidden_columnslitcolumnStatedepuis leStatelive de la grille (id fixe,columnStatepersisté nativement). Ici, la grille ne persiste pascolumnState(scoping par entité indésirable), donc le callback qui régénère lescolumnDefs(au chargement / changement de colonnes masquées) doit lire le columnState depuis le store partagé, pas depuis la grille.Éviter la boucle : le store est alimenté par
Input(grille.columnState)et relu pour produirecolumnDefs, qui à leur tour peuvent faire ré-émettrecolumnStatepar AG Grid. Pour ne pas boucler, le callback qui régénère lescolumnDefsprend le store enState(pas enInput) — il n'est déclenché que par un changement de colonnes masquées ou un (re)montage de grille, jamais par l'écriture du store elle-même (même logique queapply_hidden_columnsqui prend déjàcolumnStateenState).
Composants non-grille inchangés
update_acheteur_infos, update_acheteur_map, update_acheteur_stats, update_download_button_acheteur, download_acheteur_data, get_top_titulaires (callback autour du top 10, cf. ci-dessous), toggle_acheteur_columns, update_acheteur_distance_histogram (et équivalents titulaire) : aucun changement, toujours pilotés par Input("acheteur_url", "pathname")/Input("acheteur_year", "value") sur des id fixes.
Tableaux "top 10" (get_top_org_table)
Migrés vers AG Grid simple (row model par défaut client-side, rowData fournie directement, pas de getRowsRequest) :
- Nouvelle fonction dans
figures.py, ex.get_top_org_ag_grid(data, org_type, extra_columns, filters=True), même signature/usage que l'actuelleget_top_org_table, réutiliseag_grid()(même thème) mais avec ses proprescolumnDefsdérivés desetup_table_columns()(le sous-ensemble de colonnes du top 10 n'est pas le schéma DECP complet — pas de réutilisation degrid_column_defs()ici). - Pas de persistance (petit tableau statique, régénéré à chaque changement d'acheteur/année de toute façon).
- Utilisé par
acheteur.py(top10_titulaires) ettitulaire.py(top10_acheteurs).observatoire.pyréutilise la même fonction dans son propre sous-projet (Lot 2b) sans travail supplémentaire ici.
Export Excel
Les 2 boutons existants sont conservés (rôles différents, confirmé) :
- "Téléchargement au format Excel" (
download_acheteur_data/download_titulaire_data) : inchangé, toutes les données de la fiche pour l'année sélectionnée, ignore l'état de la grille. - Bouton "filtré" (
btn-download-filtered-data-acheteur/titulaire) : passe du pipeline Polars (filter_table_data/sort_table_datasurfilter_query/sort_by) au chemin DuckDB du Lot 1 —export_entity_grid()recompilefilterModel/sortModelcourants (lus viaStatepattern-matchingALL) → AST → SQL, scopé parbase_where_sql/base_paramsde l'entité. Seuil de 65 000 lignes conservé (dérivé dutotalretourné parfetch_grid_page).
Compteur de lignes (meta)
On reproduit l'affichage de /tableau : « X marchés (Y lignes) », où X = nombre de marchés uniques (COUNT(DISTINCT uid), total_unique) et Y = nombre de lignes (total). Aujourd'hui acheteur/titulaire n'affichent qu'un compteur simple (acheteur_nb_rows/titulaire_nb_rows).
fetch_grid_page()renvoie déjà(rows, total, total_unique)— les deux valeurs sont donc disponibles sans requête supplémentaire.- Comme sur
tableau.py, le datasource écrittotal/total_uniquedans deux stores (par page, ex.acheteur-total/acheteur-total-unique), et un callbackupdate_metaéquivalent produit le libellé + gère le seuil de 65 000 lignes (bouton d'export filtré désactivé au-delà, message d'aide). On réutilise le même format viaformat_number()quetableau.py::update_meta. - Ces stores/meta sont scopés par page (id fixe hors grille), alimentés par le datasource pattern-matching de la grille active.
Dépendances
Aucune nouvelle dépendance (dash-ag-grid déjà installé au Lot 1).
Cas limites & erreurs
- Mêmes garanties que le Lot 1 : colonnes validées contre le schéma, valeurs toujours paramétrées,
getRowsRequest is None→no_update, colonne de filtre inconnue → ignorée +logger.warning. - Changement d'année pendant un chargement de bloc en cours : la grille est remontée (nouvel id), l'ancienne requête devient orpheline sans effet (comportement standard React/Dash au remount).
columnStatevide au premier chargement (nouvel utilisateur) :apply_persisted_layout(defs, None)retournedefsinchangés (déjà géré, cf. Lot 1).
Tests
- Unitaires
entity_grid.py: datasource scopée (base_where_sql/base_paramsappliqués correctement pour acheteur vs titulaire), export scopé,apply_persisted_layoutavec le store partagé. - Unitaires
figures.get_top_org_ag_grid: colonnes dérivées correctement, pas de persistance. - Mise à jour
tests/test_main.py:test_002_filter_persistence(sélecteurs.marches_table th[data-dash-column=...]→ équivalents AG Grid) ettest_003_tableau_download(signaturesfilter_query/sort_by→filterModel/sortModelpour les callbacks d'export acheteur/titulaire). - Suite complète
uv run pytestuniquement en fin de lot.
Décisions tranchées
- Factorisation dans
src/utils/entity_grid.py(pas de duplication acheteur/titulaire pour la nouvelle logique). layout()dynamique + pattern-matching limité à la grille et à ce qui doit être scopé par entité — le reste de la page ne change pas.filterModelpersistant par(entité, année);columnStatepersistant globalement (partagé acheteur+titulaire), découplé via un store dédié.- Changement d'année → remontage de la grille (pas de rafraîchissement in-place).
- Thème "brique" du Lot 1 réutilisé tel quel (pas d'apparence de base non-thémée).
ag_grid()doit être paramétré (id dict +persisted_propsconfigurable) ;export_dataframe()doit recevoirbase_where_sql/base_params— deux extensions rétro-compatibles côté Lot 1.- Reset efface filtres et tris (via réécriture
columnStatesort=None, en préservant largeur/ordre/épinglage — approche #47-safe detableau.py). - Compteur de lignes : on reproduit l'affichage « X marchés (Y lignes) » de
/tableau(viatotal/total_uniquedéjà renvoyés parfetch_grid_page). - Les 2 boutons d'export conservés (rôles différents).
- Top 10 migré vers AG Grid simple (row model client-side), sans persistance.
Reporté
observatoire.py(Lot 2b — sous-projet séparé, spec dédiée).- Lot 3 (
recherche.py,admin/liste.py,figures.make_table) puis suppression de l'ancien DSL.