Files
colibre/docs/superpowers/specs/2026-07-13-migration-ag-grid-acheteur-titulaire-design.md
T
Colin Maudry c9074462d6 docs: aligner la spec Lot 2a sur l'état actuel du code AG Grid (dev)
- 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
2026-07-13 12:03:37 +02:00

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. commit 117ce9a),
  • 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

  1. 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.
  2. 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.
  3. 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.
  4. Gagner le filtre multi-conditions (ET/OU) gratuitement, comme sur /tableau, en remplaçant le DSL filter_query par le filterModel AG 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 directement fetch_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 de export_dataframe() du Lot 1, étendue avec base_where_sql/base_params.
  • entity_ag_grid(grid_id: dict, ...) -> dag.AgGrid : appelle ag_grid() du Lot 1 (même thème, même config) avec un id pattern-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 @callback dans acheteur.py et titulaire.py.

Modifications requises côté Lot 1 (grid.py / figures.py) :

  • export_dataframe() : ajouter les paramètres base_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.py continue 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"] et id: str. Il faut accepter (a) un id dict (pattern-matching) en plus d'une string, et (b) un persisted_props configurable. La grille entité ne persistera que ["filterModel"] (cf. « Persistance découplée » ci-dessous) ; tableau.py garde ["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") et Output(..., "columnState") via ALL (un seul reset visible à la fois, mais ALL reste nécessaire car ce n'est pas le composant qui émet l'événement). On reproduit l'approche #47-safe de tableau.py::reset_view : le tri est effacé en réécrivant columnState avec sort=None/sortIndex=None, ce qui préserve largeur/ordre/épinglage des colonnes (ne pas utiliser resetColumnState, qui les effacerait aussi).
  • export filtré : lit State({"type": f"{org_type}-grid", ...}, "filterModel")/"columnState" via ALL (un seul match actif ; le sortModel est dérivé du columnState comme dans tableau.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 via apply_persisted_layout() (déjà prévu pour ça) avant de construire les columnDefs de la grille, peu importe l'acheteur/titulaire affiché.

Différence avec tableau.py : là-bas, apply_hidden_columns lit columnState depuis le State live de la grille (id fixe, columnState persisté nativement). Ici, la grille ne persiste pas columnState (scoping par entité indésirable), donc le callback qui régénère les columnDefs (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 produire columnDefs, qui à leur tour peuvent faire ré-émettre columnState par AG Grid. Pour ne pas boucler, le callback qui régénère les columnDefs prend le store en State (pas en Input) — 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 que apply_hidden_columns qui prend déjà columnState en State).

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'actuelle get_top_org_table, réutilise ag_grid() (même thème) mais avec ses propres columnDefs dérivés de setup_table_columns() (le sous-ensemble de colonnes du top 10 n'est pas le schéma DECP complet — pas de réutilisation de grid_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) et titulaire.py (top10_acheteurs). observatoire.py ré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_data sur filter_query/sort_by) au chemin DuckDB du Lot 1 — export_entity_grid() recompile filterModel/sortModel courants (lus via State pattern-matching ALL) → AST → SQL, scopé par base_where_sql/base_params de l'entité. Seuil de 65 000 lignes conservé (dérivé du total retourné par fetch_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 écrit total/total_unique dans deux stores (par page, ex. acheteur-total / acheteur-total-unique), et un callback update_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 via format_number() que tableau.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 Noneno_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).
  • columnState vide au premier chargement (nouvel utilisateur) : apply_persisted_layout(defs, None) retourne defs inchangés (déjà géré, cf. Lot 1).

Tests

  • Unitaires entity_grid.py : datasource scopée (base_where_sql/base_params appliqués correctement pour acheteur vs titulaire), export scopé, apply_persisted_layout avec 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) et test_003_tableau_download (signatures filter_query/sort_byfilterModel/sortModel pour les callbacks d'export acheteur/titulaire).
  • Suite complète uv run pytest uniquement 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.
  • filterModel persistant par (entité, année) ; columnState persistant 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_props configurable) ; export_dataframe() doit recevoir base_where_sql/base_params — deux extensions rétro-compatibles côté Lot 1.
  • Reset efface filtres et tris (via réécriture columnState sort=None, en préservant largeur/ordre/épinglage — approche #47-safe de tableau.py).
  • Compteur de lignes : on reproduit l'affichage « X marchés (Y lignes) » de /tableau (via total/total_unique déjà renvoyés par fetch_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.