From 3b2cb15935734cd7858bc6598481ff5d6bd6eeaa Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 20 Mar 2026 17:38:10 +0100 Subject: [PATCH 1/5] =?UTF-8?q?Pr=C3=A9visualisation=20basique=20des=20don?= =?UTF-8?q?n=C3=A9es=20#65?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/observatoire.py | 210 +++++++++++++++++++++++++++++++++++++- src/utils.py | 4 +- 2 files changed, 209 insertions(+), 5 deletions(-) diff --git a/src/pages/observatoire.py b/src/pages/observatoire.py index 3b66447..525d5b3 100644 --- a/src/pages/observatoire.py +++ b/src/pages/observatoire.py @@ -18,6 +18,7 @@ from dash import ( ) from src.figures import ( + DataTable, get_barchart_sources, get_dashboard_summary_table, get_distance_histogram, @@ -25,14 +26,17 @@ from src.figures import ( get_geographic_maps, get_top_org_table, make_card, + make_column_picker, make_donut, ) from src.utils import ( + data_schema, departements, df, df_acheteurs, df_titulaires, get_enum_values_as_dict, + logger, meta_content, prepare_dashboard_data, ) @@ -61,6 +65,27 @@ for code in departements.keys(): } options_departements.append(departement) +OBSERVATOIRE_COLUMNS = [ + col + for col in df.columns + if col.startswith("acheteur") + or col.startswith("titulaire") + or col + in [ + "uid", + "dateNotification", + "montant", + "considerationsSociales", + "considerationsEnvironnementales", + "marcheInnovant", + "sousTraitanceDeclaree", + "techniques", + "sourceDataset", + "type", + "codeCPV", + ] +] + layout = [ dcc.Location(id="dashboard_url", refresh="callback-nav"), dcc.Store(id="observatoire-filters", storage_type="local"), @@ -372,10 +397,16 @@ Alors, on fait comment ? ), dcc.Download(id="download-observatoire"), dbc.Button( - "Télécharger au format Excel", - id="btn-download-observatoire", - disabled=True, + "Prévisualiser les données", + id="btn-observatoire-preview", className="mt-2", + color="primary", + outline=True, + ), + dcc.Input( + id="observatoire-share-url", + readOnly=True, + style={"display": "none"}, ), dcc.Input( id="observatoire-share-url", @@ -398,6 +429,83 @@ Alors, on fait comment ? ), ], ), + dbc.Offcanvas( + id="observatoire-preview", + title="Prévisualisation des données", + placement="bottom", + is_open=False, + scrollable=True, + style={"height": "75vh"}, + children=[ + # Header row: title + "Colonnes affichées" button + dbc.Row( + [ + dbc.Col( + html.Div( + className="table-menu", + children=[ + dbc.Button( + "Colonnes affichées", + id="observatoire-preview-columns-open", + size="sm", + color="secondary", + outline=True, + ), + dbc.Button( + "Télécharger au format Excel", + id="btn-download-observatoire", + disabled=True, + className="mt-2", + color="primary", + outline=True, + ), + ], + ), + width="auto", + ), + ], + className="mb-2 align-items-center", + ), + # Column picker modal + dbc.Modal( + [ + dbc.ModalHeader( + dbc.ModalTitle("Colonnes affichées dans la prévisualisation") + ), + dbc.ModalBody( + id="observatoire-preview-columns-body", + children=make_column_picker("observatoire_preview"), + ), + dbc.ModalFooter( + dbc.Button( + "Fermer", + id="observatoire-preview-columns-close", + className="ms-auto", + n_clicks=0, + ) + ), + ], + id="observatoire-preview-columns", + is_open=False, + fullscreen="md-down", + scrollable=True, + size="xl", + ), + # DataTable + html.Div( + className="marches_table", + children=DataTable( + dtid="observatoire-preview-table", + page_size=10, + page_action="native", + sort_action="native", + filter_action="native", + hidden_columns=[], + columns=[{"id": col, "name": col} for col in OBSERVATOIRE_COLUMNS], + ), + ), + ], + ), ] @@ -586,6 +694,9 @@ def udpate_dashboard_cards( # Génération des métriques dff = lff.collect(engine="streaming") + + logger.debug("Filter data: " + str(dff.height)) + df_per_uid = ( dff.select("uid", "montant").group_by("uid").agg(pl.col("montant").first()) ) @@ -795,3 +906,96 @@ def add_organization_name_in_title(acheteur_id, titulaire_id): html.Small(nom, className="text-muted d-block fw-normal fs-5"), ] return name + + +@callback( + Output("observatoire-preview", "is_open"), + Input("btn-observatoire-preview", "n_clicks"), + State("observatoire-preview", "is_open"), + prevent_initial_call=True, +) +def toggle_observatoire_preview(n_clicks, is_open): + return not is_open + + +@callback( + Output("observatoire-preview-table", "data"), + Output("observatoire-preview-table", "columns"), + Input("observatoire-preview", "is_open"), + State("dashboard_year", "value"), + State("dashboard_acheteur_id", "value"), + State("dashboard_acheteur_categorie", "value"), + State("dashboard_acheteur_departement_code", "value"), + State("dashboard_titulaire_id", "value"), + State("dashboard_titulaire_categorie", "value"), + State("dashboard_titulaire_departement_code", "value"), + State("dashboard_marche_type", "value"), + State("dashboard_marche_objet", "value"), + State("dashboard_marche_code_cpv", "value"), + State("dashboard_montant_min", "value"), + State("dashboard_montant_max", "value"), + State("dashboard_marche_techniques", "value"), + State("dashboard_marche_innovant", "value"), + State("dashboard_marche_sousTraitanceDeclaree", "value"), + State("dashboard_marche_considerationsSociales", "value"), + State("dashboard_marche_considerationsEnvironnementales", "value"), + prevent_initial_call=True, +) +def populate_preview_table( + is_open, + dashboard_year, + dashboard_acheteur_id, + dashboard_acheteur_categorie, + dashboard_acheteur_departement_code, + dashboard_titulaire_id, + dashboard_titulaire_categorie, + dashboard_titulaire_departement_code, + dashboard_marche_type, + dashboard_marche_objet, + dashboard_marche_code_cpv, + dashboard_montant_min, + dashboard_montant_max, + dashboard_marche_techniques, + dashboard_marche_innovant, + dashboard_marche_sous_traitance_declaree, + dashboard_marche_considerations_sociales, + dashboard_marche_considerations_environnementales, +): + if not is_open: + return no_update, no_update + + available_in_df = [col for col in OBSERVATOIRE_COLUMNS if col in df.columns] + lff = prepare_dashboard_data( + lff=df.lazy().select(available_in_df), + year=dashboard_year, + acheteur_id=dashboard_acheteur_id, + acheteur_categorie=dashboard_acheteur_categorie, + acheteur_departement_code=dashboard_acheteur_departement_code, + titulaire_id=dashboard_titulaire_id, + titulaire_categorie=dashboard_titulaire_categorie, + titulaire_departement_code=dashboard_titulaire_departement_code, + type=dashboard_marche_type, + objet=dashboard_marche_objet, + code_cpv=dashboard_marche_code_cpv, + considerations_sociales=dashboard_marche_considerations_sociales, + considerations_environnementales=dashboard_marche_considerations_environnementales, + montant_min=dashboard_montant_min, + montant_max=dashboard_montant_max, + techniques=dashboard_marche_techniques, + marche_innovant=dashboard_marche_innovant, + sous_traitance_declaree=dashboard_marche_sous_traitance_declaree, + ) + + dff = lff.collect(engine="streaming") + + table_data = dff.to_dicts() + table_columns = [ + { + "name": data_schema.get(col, {}).get("title", col), + "id": col, + "type": "text", + "format": {"nully": "N/A"}, + } + for col in available_in_df + ] + return table_data, table_columns diff --git a/src/utils.py b/src/utils.py index 15ef713..6c1c996 100644 --- a/src/utils.py +++ b/src/utils.py @@ -751,10 +751,10 @@ def prepare_dashboard_data( if code_cpv: lff = lff.filter(pl.col("codeCPV").str.starts_with(code_cpv)) - if marche_innovant != "all": + if marche_innovant and marche_innovant != "all": lff = lff.filter(pl.col("marcheInnovant") == marche_innovant) - if sous_traitance_declaree != "all": + if sous_traitance_declaree and sous_traitance_declaree != "all": lff = lff.filter(pl.col("sousTraitanceDeclaree") == sous_traitance_declaree) if techniques: From dd63feeeac50f650b1ec1fa48011a2a8876b1d90 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 20 Mar 2026 17:49:27 +0100 Subject: [PATCH 2/5] Style des boutons --- src/pages/observatoire.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/pages/observatoire.py b/src/pages/observatoire.py index 525d5b3..b02d66f 100644 --- a/src/pages/observatoire.py +++ b/src/pages/observatoire.py @@ -399,7 +399,7 @@ Alors, on fait comment ? dbc.Button( "Prévisualiser les données", id="btn-observatoire-preview", - className="mt-2", + className="btn btn-primary", color="primary", outline=True, ), @@ -447,16 +447,13 @@ Alors, on fait comment ? dbc.Button( "Colonnes affichées", id="observatoire-preview-columns-open", - size="sm", - color="secondary", - outline=True, + className="btn btn-primary", ), dbc.Button( "Télécharger au format Excel", id="btn-download-observatoire", disabled=True, - className="mt-2", - color="primary", + className="btn btn-primary", outline=True, ), ], From d6e14e25641f778231b156d0bfdcd333e6ea296c Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 21 Mar 2026 10:02:11 +0100 Subject: [PATCH 3/5] =?UTF-8?q?Pr=C3=A9visualisation=20des=20donn=C3=A9es?= =?UTF-8?q?=20fonctionnelle=20#65?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/observatoire.py | 149 +++++++++++--------------------------- src/utils.py | 2 + 2 files changed, 43 insertions(+), 108 deletions(-) diff --git a/src/pages/observatoire.py b/src/pages/observatoire.py index b02d66f..e60b273 100644 --- a/src/pages/observatoire.py +++ b/src/pages/observatoire.py @@ -3,7 +3,6 @@ from datetime import datetime import dash_bootstrap_components as dbc import polars as pl -import polars.selectors as cs from dash import ( ALL, Input, @@ -30,7 +29,6 @@ from src.figures import ( make_donut, ) from src.utils import ( - data_schema, departements, df, df_acheteurs, @@ -39,6 +37,7 @@ from src.utils import ( logger, meta_content, prepare_dashboard_data, + prepare_table_data, ) name = "Observatoire" @@ -86,9 +85,14 @@ OBSERVATOIRE_COLUMNS = [ ] ] +DF_FILTERED: pl.DataFrame = pl.DataFrame() + layout = [ dcc.Location(id="dashboard_url", refresh="callback-nav"), dcc.Store(id="observatoire-filters", storage_type="local"), + dcc.Store( + id="filter-cleanup-trigger-observatoire-preview" + ), # utilisé juste pour ne pas avoir à adapter les données retournées de prepare_table data dbc.Modal( [ dbc.ModalHeader(dbc.ModalTitle("Montants")), @@ -445,10 +449,11 @@ Alors, on fait comment ? className="table-menu", children=[ dbc.Button( - "Colonnes affichées", + "Choisir les colonnes", id="observatoire-preview-columns-open", className="btn btn-primary", ), + html.P(id="nb_rows_observatoire"), dbc.Button( "Télécharger au format Excel", id="btn-download-observatoire", @@ -493,10 +498,10 @@ Alors, on fait comment ? className="marches_table", children=DataTable( dtid="observatoire-preview-table", - page_size=10, - page_action="native", - sort_action="native", - filter_action="native", + page_size=5, + page_action="custom", + sort_action="custom", + filter_action="custom", hidden_columns=[], columns=[{"id": col, "name": col} for col in OBSERVATOIRE_COLUMNS], ), @@ -605,8 +610,6 @@ def sync_observatoire_share_url(acheteur_id, titulaire_id, href): @callback( Output("cards", "children"), - Output("btn-download-observatoire", "disabled"), - Output("btn-download-observatoire", "children"), Input("dashboard_year", "value"), Input("dashboard_acheteur_id", "value"), Input("dashboard_acheteur_categorie", "value"), @@ -646,27 +649,6 @@ def udpate_dashboard_cards( ): lff: pl.LazyFrame = df.lazy() - columns = [ - "uid", - cs.starts_with("acheteur"), - cs.starts_with("titulaire"), - "dateNotification", - "montant", - "considerationsSociales", - "considerationsEnvironnementales", - "marcheInnovant", - "sousTraitanceDeclaree", - "techniques", - "sourceDataset", - "type", - "codeCPV", - ] - - if dashboard_marche_objet: - columns.append("objet") - - lff = lff.select(columns) - # Filtrage des données lff = prepare_dashboard_data( lff=lff, @@ -692,6 +674,9 @@ def udpate_dashboard_cards( # Génération des métriques dff = lff.collect(engine="streaming") + global DF_FILTERED + DF_FILTERED = dff + logger.debug("Filter data: " + str(dff.height)) df_per_uid = ( @@ -699,13 +684,6 @@ def udpate_dashboard_cards( ) nb_marches = df_per_uid.height - if nb_marches == 0: - dl_disabled, dl_text = True, "Pas de données à télécharger" - elif nb_marches > 65000: - dl_disabled, dl_text = True, "Téléchargement désactivé au-delà de 65 000 lignes" - else: - dl_disabled, dl_text = False, "Télécharger au format Excel" - cards = [] card_summary_table = get_dashboard_summary_table(dff, df_per_uid, nb_marches) @@ -794,7 +772,7 @@ def udpate_dashboard_cards( ) ) - return dbc.Row(children=cards + geographic_maps + other_cards), dl_disabled, dl_text + return dbc.Row(children=cards + geographic_maps + other_cards) @callback( @@ -918,81 +896,36 @@ def toggle_observatoire_preview(n_clicks, is_open): @callback( Output("observatoire-preview-table", "data"), Output("observatoire-preview-table", "columns"), + Output("observatoire-preview-table", "tooltip_header"), + Output("observatoire-preview-table", "data_timestamp"), + Output("nb_rows_observatoire", "children"), + Output("btn-download-observatoire", "disabled"), + Output("btn-download-observatoire", "children"), + Output("btn-download-observatoire", "title"), + Output("filter-cleanup-trigger-observatoire-preview", "data", allow_duplicate=True), Input("observatoire-preview", "is_open"), - State("dashboard_year", "value"), - State("dashboard_acheteur_id", "value"), - State("dashboard_acheteur_categorie", "value"), - State("dashboard_acheteur_departement_code", "value"), - State("dashboard_titulaire_id", "value"), - State("dashboard_titulaire_categorie", "value"), - State("dashboard_titulaire_departement_code", "value"), - State("dashboard_marche_type", "value"), - State("dashboard_marche_objet", "value"), - State("dashboard_marche_code_cpv", "value"), - State("dashboard_montant_min", "value"), - State("dashboard_montant_max", "value"), - State("dashboard_marche_techniques", "value"), - State("dashboard_marche_innovant", "value"), - State("dashboard_marche_sousTraitanceDeclaree", "value"), - State("dashboard_marche_considerationsSociales", "value"), - State("dashboard_marche_considerationsEnvironnementales", "value"), + Input("observano_updatetoire-preview-table", "filter_query"), + Input("observatoire-preview-table", "page_current"), + Input("observatoire-preview-table", "page_size"), + Input("observatoire-preview-table", "sort_by"), + State("observatoire-preview-table", "data_timestamp"), prevent_initial_call=True, ) def populate_preview_table( - is_open, - dashboard_year, - dashboard_acheteur_id, - dashboard_acheteur_categorie, - dashboard_acheteur_departement_code, - dashboard_titulaire_id, - dashboard_titulaire_categorie, - dashboard_titulaire_departement_code, - dashboard_marche_type, - dashboard_marche_objet, - dashboard_marche_code_cpv, - dashboard_montant_min, - dashboard_montant_max, - dashboard_marche_techniques, - dashboard_marche_innovant, - dashboard_marche_sous_traitance_declaree, - dashboard_marche_considerations_sociales, - dashboard_marche_considerations_environnementales, + is_open, filter_query, page_current, page_size, sort_by, data_timestamp ): if not is_open: - return no_update, no_update + return (no_update,) * 9 - available_in_df = [col for col in OBSERVATOIRE_COLUMNS if col in df.columns] - lff = prepare_dashboard_data( - lff=df.lazy().select(available_in_df), - year=dashboard_year, - acheteur_id=dashboard_acheteur_id, - acheteur_categorie=dashboard_acheteur_categorie, - acheteur_departement_code=dashboard_acheteur_departement_code, - titulaire_id=dashboard_titulaire_id, - titulaire_categorie=dashboard_titulaire_categorie, - titulaire_departement_code=dashboard_titulaire_departement_code, - type=dashboard_marche_type, - objet=dashboard_marche_objet, - code_cpv=dashboard_marche_code_cpv, - considerations_sociales=dashboard_marche_considerations_sociales, - considerations_environnementales=dashboard_marche_considerations_environnementales, - montant_min=dashboard_montant_min, - montant_max=dashboard_montant_max, - techniques=dashboard_marche_techniques, - marche_innovant=dashboard_marche_innovant, - sous_traitance_declaree=dashboard_marche_sous_traitance_declaree, + global DF_FILTERED + lff = DF_FILTERED.lazy() + + return prepare_table_data( + lff, + data_timestamp, + filter_query, + page_current, + page_size, + sort_by, + "observatoire-preview", ) - - dff = lff.collect(engine="streaming") - - table_data = dff.to_dicts() - table_columns = [ - { - "name": data_schema.get(col, {}).get("title", col), - "id": col, - "type": "text", - "format": {"nully": "N/A"}, - } - for col in available_in_df - ] - return table_data, table_columns diff --git a/src/utils.py b/src/utils.py index 6c1c996..63e5750 100644 --- a/src/utils.py +++ b/src/utils.py @@ -631,6 +631,8 @@ def prepare_table_data( # Récupération des données if isinstance(data, list): lff: pl.LazyFrame = pl.LazyFrame(data, strict=False, infer_schema_length=5000) + elif isinstance(data, pl.LazyFrame): + lff = data else: lff: pl.LazyFrame = df.lazy() # start from the original data From b34f63f711ee04b9e931e991f7e49543690a4160 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 21 Mar 2026 10:17:58 +0100 Subject: [PATCH 4/5] =?UTF-8?q?Pr=C3=A9visualisation=20des=20donn=C3=A9es:?= =?UTF-8?q?=20choix=20des=20colonnes=20#65?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/observatoire.py | 59 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/src/pages/observatoire.py b/src/pages/observatoire.py index e60b273..4e3dcb9 100644 --- a/src/pages/observatoire.py +++ b/src/pages/observatoire.py @@ -29,10 +29,12 @@ from src.figures import ( make_donut, ) from src.utils import ( + columns, departements, df, df_acheteurs, df_titulaires, + get_default_hidden_columns, get_enum_values_as_dict, logger, meta_content, @@ -90,6 +92,7 @@ DF_FILTERED: pl.DataFrame = pl.DataFrame() layout = [ dcc.Location(id="dashboard_url", refresh="callback-nav"), dcc.Store(id="observatoire-filters", storage_type="local"), + dcc.Store(id="observatoire-hidden-columns", storage_type="local"), dcc.Store( id="filter-cleanup-trigger-observatoire-preview" ), # utilisé juste pour ne pas avoir à adapter les données retournées de prepare_table data @@ -487,7 +490,7 @@ Alors, on fait comment ? ) ), ], - id="observatoire-preview-columns", + id="observatoire-preview-columns-modal", is_open=False, fullscreen="md-down", scrollable=True, @@ -904,7 +907,7 @@ def toggle_observatoire_preview(n_clicks, is_open): Output("btn-download-observatoire", "title"), Output("filter-cleanup-trigger-observatoire-preview", "data", allow_duplicate=True), Input("observatoire-preview", "is_open"), - Input("observano_updatetoire-preview-table", "filter_query"), + Input("observatoire-preview-table", "filter_query"), Input("observatoire-preview-table", "page_current"), Input("observatoire-preview-table", "page_size"), Input("observatoire-preview-table", "sort_by"), @@ -929,3 +932,55 @@ def populate_preview_table( sort_by, "observatoire-preview", ) + + +@callback( + Output("observatoire-hidden-columns", "data", allow_duplicate=True), + Input("observatoire_preview_column_list", "selected_rows"), + prevent_initial_call=True, +) +def update_hidden_columns_from_checkboxes(selected_columns): + if selected_columns: + selected_columns = [columns[i] for i in selected_columns] + hidden_columns = [col for col in columns if col not in selected_columns] + return hidden_columns + else: + return [] + + +@callback( + Output("observatoire-preview-table", "hidden_columns"), + Input( + "observatoire-hidden-columns", + "data", + ), +) +def store_hidden_columns(hidden_columns): + return hidden_columns + + +@callback( + Output("observatoire_preview_column_list", "selected_rows"), + Input("observatoire-preview-table", "hidden_columns"), + State( + "observatoire_preview_column_list", "selected_rows" + ), # pour éviter la boucle infinie +) +def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes): + hidden_cols = hidden_cols or get_default_hidden_columns("tableau") + + # Show all columns that are NOT hidden + visible_cols = [columns.index(col) for col in columns if col not in hidden_cols] + return visible_cols + + +@callback( + Output("observatoire-preview-columns-modal", "is_open"), + Input("observatoire-preview-columns-open", "n_clicks"), + Input("observatoire-preview-columns-close", "n_clicks"), + State("observatoire-preview-columns-modal", "is_open"), +) +def toggle_tableau_columns(click_open, click_close, is_open): + if click_open or click_close: + return not is_open + return is_open From e6bf671f1657292590078ce68d098c6cbe82655f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 21 Mar 2026 10:28:07 +0100 Subject: [PATCH 5/5] =?UTF-8?q?Pr=C3=A9visualisation=20des=20donn=C3=A9es:?= =?UTF-8?q?=20bonnes=20donn=C3=A9es=20t=C3=A9l=C3=A9charg=C3=A9es=20#65?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/observatoire.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pages/observatoire.py b/src/pages/observatoire.py index 4e3dcb9..ff5a959 100644 --- a/src/pages/observatoire.py +++ b/src/pages/observatoire.py @@ -798,6 +798,7 @@ def udpate_dashboard_cards( State("dashboard_marche_sousTraitanceDeclaree", "value"), State("dashboard_marche_considerationsSociales", "value"), State("dashboard_marche_considerationsEnvironnementales", "value"), + State("observatoire-hidden-columns", "data"), prevent_initial_call=True, ) def download_observatoire( @@ -819,6 +820,7 @@ def download_observatoire( dashboard_marche_sous_traitance_declaree, dashboard_considerations_sociales, dashboard_considerations_environnementales, + hidden_columns, ): lff = prepare_dashboard_data( lff=df.lazy(), @@ -841,6 +843,9 @@ def download_observatoire( sous_traitance_declaree=dashboard_marche_sous_traitance_declaree, ) + if hidden_columns: + lff = lff.drop(hidden_columns) + def to_bytes(buffer): lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")