From b11aa20743a96dc80bf84dbf14ee68aed58b7adb Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 30 Jan 2026 15:19:46 +0100 Subject: [PATCH 01/36] Correction loggers --- src/pages/tableau.py | 2 +- src/utils.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 1207ea0..707820f 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -294,7 +294,7 @@ def restore_view_from_url(search): return no_update, no_update, no_update, no_update, no_update params = urllib.parse.parse_qs(search.lstrip("?")) - logger.debug("params", params) + logger.debug("params " + json.dumps(params, indent=2)) filter_query = no_update sort_by = no_update diff --git a/src/utils.py b/src/utils.py index 0e7a96b..f9361f9 100644 --- a/src/utils.py +++ b/src/utils.py @@ -32,14 +32,14 @@ def split_filter_part(filter_part): ["icontains", "contains"], # [" ", "contains"] ] - logger.debug("filter part", filter_part) + logger.debug("filter part " + filter_part) for operator_group in operators: if operator_group[0] in filter_part: name_part, value_part = filter_part.split(operator_group[0], 1) name_part = name_part.strip() value = value_part.strip() name = name_part[name_part.find("{") + 1 : name_part.rfind("}")] - logger.debug("=>", name, operator_group[1], value) + logger.debug("=> " + " ".join([name, operator_group[1], value])) return name, operator_group[1], value From cee51d929c1b04173195cd5d2733df72dfdf3f19 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 30 Jan 2026 18:29:48 +0100 Subject: [PATCH 02/36] =?UTF-8?q?Petites=20am=C3=A9liorations=20au=20suivi?= =?UTF-8?q?=20des=20recherches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/utils.py b/src/utils.py index f9361f9..0cf908f 100644 --- a/src/utils.py +++ b/src/utils.py @@ -284,7 +284,7 @@ def filter_table_data( lff: pl.LazyFrame, filter_query: str, filter_source: str ) -> pl.LazyFrame: _schema = lff.collect_schema() - track_search(f"{filter_source}: {filter_query}") + track_search(filter_query, filter_source) filtering_expressions = filter_query.split(" && ") for filter_part in filtering_expressions: col_name, operator, filter_value = split_filter_part(filter_part) @@ -464,21 +464,15 @@ def get_data_schema() -> dict: return new_schema -def track_search(query): - if ( - len(query) >= 4 - and os.getenv("DEVELOPMENT").lower != "true" - and os.getenv("MATOMO_DOMAIN") - ): - if os.getenv("DEVELOPMENT").lower() == "true": - url = "https://test.decp.info" - else: - url = "https://decp.info" +def track_search(query, category): + if len(query) >= 4 and not development and os.getenv("MATOMO_DOMAIN"): + url = "https://decp.info" params = { "idsite": os.getenv("MATOMO_ID_SITE"), "url": url, "rec": "1", - "action_name": "front_page_search", + "action_name": "search" if category == "home_page_search" else "filter", + "search_cat": category, "rand": uuid.uuid4().hex, "apiv": "1", "h": localtime().tm_hour, @@ -506,7 +500,7 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame: return dff.select(pl.lit(False).alias("matches")) # Enregistrement des recherche dans Matomo - track_search(query) + track_search(query, "home_page_search") # Normalize query normalized_query = unidecode(query.strip()).upper() From 3ce37784dcafb8f03764925d4af17808aad10431 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 30 Jan 2026 19:22:54 +0100 Subject: [PATCH 03/36] Modal mode d'emploi et style boutons --- src/assets/css/style.css | 33 +++++++++++++++++++++--- src/pages/tableau.py | 55 +++++++++++++++++++++++++++++----------- 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 3ea0540..c6c5bd2 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -31,14 +31,38 @@ h3 { margin: 36px 0 20px 0; } -/* Base Button Styles */ +/* Base Button Styles button { font-weight: 400; background-color: #fff; border-radius: 3px; appearance: auto; border: solid var(--primary-color) 1px; - /* couleur thème foncée */ +} */ + +button.btn.btn-primary, +button.show-hide { + border-radius: 3px; + outline: 0; + color: #fff; + border: 0; + height: 30px; + padding-top: 2px; + background-color: rgb(179, 56, 33) !important; + background-image: linear-gradient( + rgb(209, 96, 73), + rgb(179, 56, 33) 26%, + rgb(159, 36, 22) + ); +} + +button.btn.btn-primary:hover, +button.show-hide:hover { + background-image: linear-gradient( + rgb(239, 126, 103), + rgb(209, 86, 63) 26%, + rgb(189, 66, 52) + ); } button[disabled] { @@ -160,12 +184,13 @@ p.version > a { .table-menu { font-size: 16px; margin: 12px 0 12px 0; - height: 36px; + height: 50px; + display: flex; + align-items: baseline; } .table-menu > * { margin: 8px 16px 8px 0; - float: left; } #source_table { diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 707820f..20edcf1 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -4,6 +4,7 @@ import urllib.parse import uuid from datetime import datetime +import dash_bootstrap_components as dbc import polars as pl from dash import ( ClientsideFunction, @@ -125,14 +126,15 @@ layout = [ style={"maxWidth": "1000px"}, ), html.Div( - html.Details( - children=[ - html.Summary( - html.H4("Mode d'emploi", style={"textDecoration": "underline"}), - ), - dcc.Markdown( - dangerously_allow_html=True, - children=f""" + [ + dbc.Button("Mode d'emploi", id="tableau_help_open"), + dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Header")), + dbc.ModalBody( + dcc.Markdown( + dangerously_allow_html=True, + children=f""" ##### Définition des colonnes Pour voir la définition d'une colonne, passez votre souris sur son en-tête. @@ -144,11 +146,9 @@ layout = [ - Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché et n'est pas sensible à la casse (majuscules/minuscules). - Exemple : `rennes` retourne "RENNES METROPOLE". - Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances` - - Lorsque vous ouvrez une URL de vue (voir "Partager une vue" plus bas), le format équivalent `icontains rennes` est utilisé. Mais dans vos filtres pas besoin de taper `icontains` ! - Champs numériques (Durée en mois, Montant, ...) : vous pouvez... - soit taper un nombre pour trouver les valeurs strictement égales. Exemple : `12` ne retourne que des 12 - soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres. - - lorsque vous ouvrez une URL de vue (voir "Partager une vue" plus bas), le format équivalent `i<` ou `i>` est utilisé, mais c'est un bug : vous n'avez pas besoin de taper le `i` pour appliquer ce filtre. - Champs date (Date de notification, ...) : vous pouvez également utiliser **>** ou **<**. Exemples : - `< 2024-01-31` pour "avant le 31 janvier 2024" - `2024` pour "en 2024", `> 2022` pour "à partir de 2022". @@ -188,10 +188,24 @@ layout = [ (informations, marchés attribués/remportés, etc.) """, - ), - ], - id="instructions", - ), + ), + ), + dbc.ModalFooter( + dbc.Button( + "Close", + id="tableau_help_close", + className="ms-auto", + n_clicks=0, + ) + ), + ], + id="tableau_help", + is_open=False, + fullscreen="md-down", + scrollable=True, + size="lg", + ), + ], id="header", ), # html.Div( @@ -210,7 +224,7 @@ layout = [ html.P("lignes", id="nb_rows"), html.Div(id="copy-container"), dcc.Input(id="share-url", readOnly=True, style={"display": "none"}), - html.Button( + dbc.Button( "Téléchargement désactivé au-delà de 65 000 lignes", id="btn-download-data", disabled=True, @@ -389,3 +403,14 @@ def show_confirmation(n_clicks): style={"color": "green", "fontWeight": "bold", "marginLeft": "10px"}, ) return no_update + + +@callback( + Output("tableau_help", "is_open"), + [Input("tableau_help_open", "n_clicks"), Input("tableau_help_close", "n_clicks")], + [State("tableau_help", "is_open")], +) +def toggle_tableau_help(click_open, click_close, is_open): + if click_open or click_close: + return not is_open + return is_open From 965aef06f93efb98c16dab1c0f8e68365ea3267a Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 30 Jan 2026 20:04:43 +0100 Subject: [PATCH 04/36] Bonne application des fonts dans les cellules de tableau --- src/figures.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/figures.py b/src/figures.py index 5ad50ca..8f13d8a 100644 --- a/src/figures.py +++ b/src/figures.py @@ -6,7 +6,7 @@ import plotly.graph_objects as go import polars as pl from dash import dash_table, dcc, html -from src.utils import format_number +from src.utils import data_schema, format_number def get_map_count_marches(df: pl.DataFrame): @@ -295,20 +295,18 @@ class DataTable(dash_table.DataTable): "lineHeight": "18px", "whiteSpace": "normal", }, - { - "if": {"column_id": "montant"}, - "textAlign": "right", - }, - { - "if": {"column_id": "dureeMois"}, - "textAlign": "right", - }, - { - "if": {"column_id": "titulaire_distance"}, - "textAlign": "right", - }, ] + for key in data_schema.keys(): + field = data_schema[key] + if field["type"] in ["number", "integer"]: + rule = { + "if": {"column_id": field["name"]}, + "textAlign": "right", + # "fontFamily": "Fira Code", + } + style_cell_conditional.append(rule) + # Initialisation de la classe parente avec les arguments super().__init__( id=dtid, @@ -330,6 +328,8 @@ class DataTable(dash_table.DataTable): style_cell_conditional=style_cell_conditional, data_timestamp=0, markdown_options={"html": True}, + style_header={"fontFamily": "Inter", "fontSize": "16px"}, + style_cell={"fontFamily": "Inter", "fontSize": "16px"}, tooltip_duration=8000, tooltip_delay=350, hidden_columns=hidden_columns, From 55bb92468ef36f75bc6ea24dbba589fbe922dc89 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 30 Jan 2026 20:05:58 +0100 Subject: [PATCH 05/36] =?UTF-8?q?Styles=20de=20boutons=20homog=C3=A8nes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/css/style.css | 2 +- src/pages/acheteur.py | 2 ++ src/pages/recherche.py | 10 +++++----- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index c6c5bd2..6a1d71e 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -42,13 +42,13 @@ button { button.btn.btn-primary, button.show-hide { + display: block; border-radius: 3px; outline: 0; color: #fff; border: 0; height: 30px; padding-top: 2px; - background-color: rgb(179, 56, 33) !important; background-image: linear-gradient( rgb(209, 96, 73), rgb(179, 56, 33) 26%, diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 5d0694f..161d972 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -109,6 +109,7 @@ layout = [ html.Button( "Téléchargement au format Excel", id="btn-download-data-acheteur", + className="btn btn-primary", ), dcc.Download(id="download-data-acheteur"), ], @@ -136,6 +137,7 @@ layout = [ html.Button( "Téléchargement désactivé au-delà de 65 000 lignes", id="btn-download-filtered-data-acheteur", + className="btn btn-primary", disabled=True, ), dcc.Download(id="acheteur-download-filtered-data"), diff --git a/src/pages/recherche.py b/src/pages/recherche.py index b402c63..44b1271 100644 --- a/src/pages/recherche.py +++ b/src/pages/recherche.py @@ -46,20 +46,20 @@ layout = html.Div( "width": "500px", "border": "1px solid #ccc", "borderRight": "none", - "borderRadius": "4px 0 0 4px", + "borderRadius": "3px 0 0 3px", "padding": "5px 10px", "outline": "none", + "height": "34px", }, ), html.Button( - "🔍", + "=>", id="search-button", + className="btn btn-primary", style={ "border": "1px solid #ccc", - "borderRadius": "0 4px 4px 0", + "borderRadius": "0 3px 3px 0", "marginLeft": "0", - "backgroundColor": "#f0f0f0", - "cursor": "pointer", "height": "auto", # Ensure it matches input height if necessary, often relying on padding/line-height }, ), From c3ebdb00570d59faf859e67c968adb7b75c6e68f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 30 Jan 2026 20:13:59 +0100 Subject: [PATCH 06/36] =?UTF-8?q?Am=C3=A9liorations=20styles=20tableaux,?= =?UTF-8?q?=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/css/style.css | 4 ++-- src/figures.py | 14 ++++---------- src/pages/acheteur.py | 6 +++--- src/pages/titulaire.py | 6 +++--- 4 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 6a1d71e..0084638 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -238,11 +238,11 @@ table.cell-table th { font-family: "Inter", sans-serif; text-align: left; font-weight: 600; - padding-right: 12px; + padding: 2px 12px 4px 2px; border: 1px solid rgb(179, 56, 33) !important; background-color: rgb(179, 56, 33); border-bottom: none !important; - height: 34px; + height: 32px; } .dash-table-container diff --git a/src/figures.py b/src/figures.py index 8f13d8a..ed85db4 100644 --- a/src/figures.py +++ b/src/figures.py @@ -91,11 +91,8 @@ def get_yearly_statistics(statistics, today_str) -> html.Div: page_size=10, sort_action="none", filter_action="none", - style_header={ - "border": "solid 1px rgb(179, 56, 33)", - "backgroundColor": "rgb(179, 56, 33)", - "color": "white", - }, + style_header={"fontFamily": "Inter", "fontSize": "16px"}, + style_cell={"fontFamily": "Inter", "fontSize": "16px"}, ) return html.Div(children=table, className="marches_table") @@ -210,11 +207,8 @@ def get_sources_tables(source_path) -> html.Div: ], sort_action="native", markdown_options={"html": True}, - style_header={ - "border": "solid 1px rgb(179, 56, 33)", - "backgroundColor": "rgb(179, 56, 33)", - "color": "white", - }, + style_header={"fontFamily": "Inter", "fontSize": "16px"}, + style_cell={"fontFamily": "Inter", "fontSize": "16px"}, ) return html.Div(children=datatable) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 161d972..6f5e5e2 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -71,7 +71,7 @@ layout = [ className="org_year", children=dcc.Dropdown( id="acheteur_year", - options=["Toutes"] + options=["Toutes les années"] + [ str(year) for year in range( @@ -231,7 +231,7 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> tuple: acheteur_siret = url.split("/")[-1] lff = df.lazy() lff = lff.filter(pl.col("acheteur_id") == acheteur_siret) - if acheteur_year and acheteur_year != "Toutes": + if acheteur_year and acheteur_year != "Toutes les années": acheteur_year = int(acheteur_year) lff = lff.filter(pl.col("dateNotification").dt.year() == acheteur_year) lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True) @@ -292,7 +292,7 @@ def download_acheteur_data( def to_bytes(buffer): df_to_download.write_excel( - buffer, worksheet="DECP" if annee in ["Toutes", None] else annee + buffer, worksheet="DECP" if annee in ["Toutes les années", None] else annee ) date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index 834ce68..84ec606 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -71,7 +71,7 @@ layout = [ className="org_year", children=dcc.Dropdown( id="titulaire_year", - options=["Toutes"] + options=["Toutes les années"] + [ str(year) for year in range( @@ -235,7 +235,7 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> tuple: (pl.col("titulaire_id") == titulaire_siret) & (pl.col("titulaire_typeIdentifiant") == "SIRET") ) - if titulaire_year and titulaire_year != "Toutes": + if titulaire_year and titulaire_year != "Toutes les années": lff = lff.filter( pl.col("dateNotification").cast(pl.String).str.starts_with(titulaire_year) ) @@ -305,7 +305,7 @@ def download_titulaire_data( def to_bytes(buffer): df_to_download.write_excel( - buffer, worksheet="DECP" if annee in ["Toutes", None] else annee + buffer, worksheet="DECP" if annee in ["Toutes les années", None] else annee ) date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") From e609bc3e320082ae5b832ef169fa8fb34c6a0644 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 30 Jan 2026 21:13:29 +0100 Subject: [PATCH 07/36] Weight des titres --- src/assets/css/style.css | 8 ++++++++ src/figures.py | 15 --------------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 0084638..6417467 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -27,6 +27,14 @@ b { font-weight: 600 !important; } +h1, +h2, +h3, +h4, +h5 { + font-weight: 600; +} + h3 { margin: 36px 0 20px 0; } diff --git a/src/figures.py b/src/figures.py index ed85db4..a505576 100644 --- a/src/figures.py +++ b/src/figures.py @@ -354,13 +354,6 @@ def get_duplicate_matrix() -> html.Div: Passez votre souris sur une case pour avoir les pourcentages exacts. À noter que ces statistiques sont produites avant le dédoublonnement qui a lieu avant la publication en Open Data et sur ce site.""") - # Assuming result_df is your DataFrame with structure: - # | sourceDataset | unique | dataset1 | dataset2 | dataset3 | - # |---------------|--------|----------|----------|----------| - # | dataset1 | 0.8 | | 0.15 | 0.2 | - # | dataset2 | 0.75 | 0.15 | | 0.12 | - # | dataset3 | 0.85 | 0.2 | 0.12 | | - # Extract data z_data = result_df.select(pl.all().exclude("sourceDataset")).fill_null(0).to_numpy() x_labels = result_df.columns[1:] # columns after "sourceDataset" @@ -372,12 +365,6 @@ def get_duplicate_matrix() -> html.Div: z=z_data, x=x_labels, y=y_labels, - # colorscale=[ - # [0, "white"], # 0% → white - # [0.10, "lightblue"], # 1% → light blue (soft start) - # [0.50, "steelblue"], # 50% → medium blue - # [1, "darkblue"], # 100% → dark blue - # ], colorscale=[ [0.0, "white"], # 0% → white [0.10, "lightsalmon"], # 10% → light warm tone @@ -385,8 +372,6 @@ def get_duplicate_matrix() -> html.Div: ], zmin=0, zmax=1, - # texttemplate="%{z:.0%}", # Format as percentage - # textfont={"size": 10, "color": "black"}, # Smaller font hoverongaps=False, showscale=True, hovertemplate=( From cd81aa9532f3d064149b557e0c3e968932caf007 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 31 Jan 2026 01:04:09 +0100 Subject: [PATCH 08/36] =?UTF-8?q?Syncro=20s=C3=A9lection=20de=20colonnes?= =?UTF-8?q?=20et=20tableau=20fonctionnelle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/css/style.css | 18 ++++- src/pages/tableau.py | 144 +++++++++++++++++++++++++++++++++++++-- src/utils.py | 4 +- 3 files changed, 158 insertions(+), 8 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 6417467..60d1c92 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -240,7 +240,11 @@ table.cell-table th { .dash-table-container .dash-spreadsheet-container .dash-spreadsheet-inner - th.dash-header { + th.dash-header, +.dash-table-container + .dash-spreadsheet-container + .dash-spreadsheet-inner + th.dash-select-header { margin: 0; color: white; font-family: "Inter", sans-serif; @@ -258,7 +262,13 @@ table.cell-table th { .dash-spreadsheet-inner table.cell-table tr:first-of-type - th.dash-header:first-of-type { + th.dash-header:first-of-type, +.dash-table-container + .dash-spreadsheet-container + .dash-spreadsheet-inner + table.cell-table + tr:first-of-type + th.dash-select-header:first-of-type { border-top-left-radius: 3px !important; } @@ -354,6 +364,10 @@ table.cell-table th { right: 5px; } +#column_list .show-hide { + display: none; +} + .show-hide-menu-item > input { margin-right: 10px; } diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 20edcf1..24bfc2b 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -21,6 +21,8 @@ from dash import ( from src.figures import DataTable from src.utils import ( + columns, + data_schema, df, filter_table_data, get_default_hidden_columns, @@ -61,9 +63,70 @@ datatable = html.Div( ), ) + +def make_tableau_columns_table(): + table_data = [] + table_columns = [ + { + "id": col, + "name": data_schema[col]["title"], + "description": data_schema[col]["description"], + } + for col in df.columns + ] + for column in table_columns: + new_column = { + "id": column["id"], + "name": column["name"], + "description": data_schema[column["id"]]["description"], + } + table_data.append(new_column) + + table = ( + DataTable( + row_selectable="multi", + data=table_data, + filter_action="none", + sort_action="none", + style_cell={ + "textAlign": "left", + }, + columns=[ + { + "name": "Nom", + "id": "name", + }, + { + "name": "Description", + "id": "description", + }, + { + "name": "column_id", + "id": "id", + }, + ], + hidden_columns=["id"], + style_cell_conditional=[ + { + "if": {"column_id": "description"}, + "minWidth": "450px", + "overflow": "hidden", + "lineHeight": "18px", + "whiteSpace": "normal", + } + ], + page_action="none", + dtid="column_list", + ), + ) + + return table + + layout = [ dcc.Location(id="tableau_url", refresh=False), dcc.Store(id="filter-cleanup-trigger"), + dcc.Store(id="tableau-hidden-columns"), html.Script( type="application/ld+json", id="dataset_jsonld", @@ -192,7 +255,7 @@ layout = [ ), dbc.ModalFooter( dbc.Button( - "Close", + "Fermer", id="tableau_help_close", className="ms-auto", n_clicks=0, @@ -235,6 +298,28 @@ layout = [ ], className="table-menu", ), + dbc.Button("Colonne affichées", id="tableau_columns_open"), + dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Choix des colonnes à afficher")), + dbc.ModalBody( + id="tableau_columns_body", children=make_tableau_columns_table() + ), + dbc.ModalFooter( + dbc.Button( + "Fermer", + id="tableau_columns_close", + className="ms-auto", + n_clicks=0, + ) + ), + ], + id="tableau_columns", + is_open=False, + fullscreen="md-down", + scrollable=True, + size="lg", + ), datatable, ], ), @@ -297,7 +382,7 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): @callback( Output("table", "filter_query"), Output("table", "sort_by"), - Output("table", "hidden_columns"), + Output("tableau-hidden-columns", "data"), Output("tableau_url", "search", allow_duplicate=True), Output("filter-cleanup-trigger", "data"), Input("tableau_url", "search"), @@ -368,9 +453,9 @@ def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href): params["tris"] = json.dumps(sort_by) if hidden_columns: - columns = invert_columns(hidden_columns) - columns = ",".join(columns) - params["colonnes"] = columns + table_columns = invert_columns(hidden_columns) + table_columns = ",".join(table_columns) + params["colonnes"] = table_columns query_string = urllib.parse.urlencode(params) full_url = f"{base_url}?{query_string}" if query_string else base_url @@ -414,3 +499,52 @@ def toggle_tableau_help(click_open, click_close, is_open): if click_open or click_close: return not is_open return is_open + + +@callback( + Output("tableau-hidden-columns", "data", allow_duplicate=True), + Input("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("table", "hidden_columns", allow_duplicate=True), + Input( + "tableau-hidden-columns", + "data", + ), + prevent_initial_call=True, +) +def store_hidden_columns(hidden_columns): + return hidden_columns + + +@callback( + Output("column_list", "selected_rows"), + Input("table", "hidden_columns"), + State("column_list", "selected_rows"), # pour éviter la boucle infinie +) +def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes): + # 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("tableau_columns", "is_open"), + Input("tableau_columns_open", "n_clicks"), + Input("tableau_columns_close", "n_clicks"), + State("tableau_columns", "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 diff --git a/src/utils.py b/src/utils.py index 0cf908f..57385fd 100644 --- a/src/utils.py +++ b/src/utils.py @@ -2,6 +2,7 @@ import json import logging import os import uuid +from collections import OrderedDict from time import localtime, sleep import polars as pl @@ -456,7 +457,7 @@ def get_data_schema() -> dict: else: raise Exception(f"Chemin vers le schéma invalide: {path}") - new_schema = {} + new_schema = OrderedDict() for col in original_schema["fields"]: new_schema[col["name"]] = col @@ -757,3 +758,4 @@ meta_content = { ), } data_schema = get_data_schema() +columns = df.columns From bd149ff11faa0f081b719d171639132f924c874c Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 31 Jan 2026 01:27:59 +0100 Subject: [PATCH 09/36] Meilleure gestion des styles dans les tableaux --- src/figures.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/figures.py b/src/figures.py index a505576..0fd9210 100644 --- a/src/figures.py +++ b/src/figures.py @@ -258,10 +258,12 @@ class DataTable(dash_table.DataTable): page_action: Literal["native", "custom", "none"] = "native", sort_action: Literal["native", "custom", "none"] = "native", filter_action: Literal["native", "custom", "none"] = "native", + style_cell_conditional: list | None = None, + style_cell: dict | None = None, **kwargs, ): # Styles de base - style_cell_conditional = [ + style_cell_conditional_common = [ { "if": {"column_id": "objet"}, "minWidth": "350px", @@ -291,6 +293,8 @@ class DataTable(dash_table.DataTable): }, ] + style_cell_common = {"fontFamily": "Inter", "fontSize": "16px"} + for key in data_schema.keys(): field = data_schema[key] if field["type"] in ["number", "integer"]: @@ -299,7 +303,15 @@ class DataTable(dash_table.DataTable): "textAlign": "right", # "fontFamily": "Fira Code", } - style_cell_conditional.append(rule) + style_cell_conditional_common.append(rule) + + style_cell_conditional = ( + style_cell_conditional or [] + ) + style_cell_conditional_common + style_cell.update(style_cell_common) if isinstance( + style_cell, dict + ) else style_cell_common + style_header = style_cell # Initialisation de la classe parente avec les arguments super().__init__( @@ -322,8 +334,8 @@ class DataTable(dash_table.DataTable): style_cell_conditional=style_cell_conditional, data_timestamp=0, markdown_options={"html": True}, - style_header={"fontFamily": "Inter", "fontSize": "16px"}, - style_cell={"fontFamily": "Inter", "fontSize": "16px"}, + style_header=style_header, + style_cell=style_cell, tooltip_duration=8000, tooltip_delay=350, hidden_columns=hidden_columns, From 8f5d38ff787dbba2510d44c40d9a7fddb1cdb6ca Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 31 Jan 2026 01:29:37 +0100 Subject: [PATCH 10/36] =?UTF-8?q?Petites=20am=C3=A9liorations=20sur=20les?= =?UTF-8?q?=20boutons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/css/style.css | 5 +++-- src/pages/tableau.py | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 60d1c92..adab228 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -194,7 +194,7 @@ p.version > a { margin: 12px 0 12px 0; height: 50px; display: flex; - align-items: baseline; + align-items: center; } .table-menu > * { @@ -364,7 +364,8 @@ table.cell-table th { right: 5px; } -#column_list .show-hide { +#column_list .show-hide, +#table .show-hide { display: none; } diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 24bfc2b..e6049bb 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -284,6 +284,11 @@ layout = [ children=[ html.Div( [ + dbc.Button( + "Colonnes affichées", + id="tableau_columns_open", + className="column_list", + ), html.P("lignes", id="nb_rows"), html.Div(id="copy-container"), dcc.Input(id="share-url", readOnly=True, style={"display": "none"}), @@ -298,7 +303,6 @@ layout = [ ], className="table-menu", ), - dbc.Button("Colonne affichées", id="tableau_columns_open"), dbc.Modal( [ dbc.ModalHeader(dbc.ModalTitle("Choix des colonnes à afficher")), @@ -318,7 +322,7 @@ layout = [ is_open=False, fullscreen="md-down", scrollable=True, - size="lg", + size="xl", ), datatable, ], From 75b42f4bafb243c21e4741d796aa92e5203e0961 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 31 Jan 2026 01:33:26 +0100 Subject: [PATCH 11/36] Correction bug style cell --- src/figures.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/figures.py b/src/figures.py index 0fd9210..2562093 100644 --- a/src/figures.py +++ b/src/figures.py @@ -308,9 +308,10 @@ class DataTable(dash_table.DataTable): style_cell_conditional = ( style_cell_conditional or [] ) + style_cell_conditional_common - style_cell.update(style_cell_common) if isinstance( - style_cell, dict - ) else style_cell_common + if style_cell: + style_cell.update(style_cell_common) + else: + style_cell = style_cell_common style_header = style_cell # Initialisation de la classe parente avec les arguments From f1725a95f8b7c100451540db3276a20017312771 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 31 Jan 2026 01:41:08 +0100 Subject: [PATCH 12/36] Une ligne de boutons --- src/pages/tableau.py | 171 +++++++++++++++++++++---------------------- 1 file changed, 83 insertions(+), 88 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index e6049bb..050ec07 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -189,94 +189,9 @@ layout = [ style={"maxWidth": "1000px"}, ), html.Div( - [ - dbc.Button("Mode d'emploi", id="tableau_help_open"), - dbc.Modal( - [ - dbc.ModalHeader(dbc.ModalTitle("Header")), - dbc.ModalBody( - dcc.Markdown( - dangerously_allow_html=True, - children=f""" - ##### Définition des colonnes - - Pour voir la définition d'une colonne, passez votre souris sur son en-tête. - - ##### Appliquer des filtres - - Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`. - - - Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché et n'est pas sensible à la casse (majuscules/minuscules). - - Exemple : `rennes` retourne "RENNES METROPOLE". - - Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances` - - Champs numériques (Durée en mois, Montant, ...) : vous pouvez... - - soit taper un nombre pour trouver les valeurs strictement égales. Exemple : `12` ne retourne que des 12 - - soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres. - - Champs date (Date de notification, ...) : vous pouvez également utiliser **>** ou **<**. Exemples : - - `< 2024-01-31` pour "avant le 31 janvier 2024" - - `2024` pour "en 2024", `> 2022` pour "à partir de 2022". - - Pour les champs textuels et les champs dates : - - pour chercher du texte qui **commence par** votre texte, entrez `texte*`. C'est par exemple utile pour filtrer des acheteurs ou titulaires par numéro SIREN (`123456789*`) ou les marchés sur une année en particulier (`2024*`) - - pour chercher du texte qui **finit par** votre texte, entrez `*texte` - - Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page. - - ##### Trier les données - - Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre : - - 1. tri croissant - 2. tri décroissant - 3. pas de tri - - ##### Afficher plus de colonnes - - Par défaut, un nombre réduit de colonnes est affiché pour ne pas surcharger la page. Mais vous avez le choix parmi {str(df.width)} colonnes, ce serait dommage de vous limiter ! - - Pour afficher plus de colonnes, cliquez sur le bouton **Colonnes affichées** et cochez les colonnes pour les afficher. - - ##### Partager une vue - - Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqué. Cliquez sur l'icône drawing pour copier une adresse Web qui reproduit la vue courante à l'identique : en la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres. - - Pratique pour partager une vue avec un·e collègue, sur les réseaux sociaux, ou la sauvegarder pour plus tard. - - ##### Télécharger le résultat - - Vous pouvez télécharger le résultat de vos filtres et tris, pour les colonnes affichées, en cliquant sur **Télécharger au format Excel**. - - ##### Liens - - Les liens dans les colonnes Identifiant unique, Acheteur et Titulaire vous permettent de consulter une vue qui leur est dédiée - (informations, marchés attribués/remportés, etc.) - - """, - ), - ), - dbc.ModalFooter( - dbc.Button( - "Fermer", - id="tableau_help_close", - className="ms-auto", - n_clicks=0, - ) - ), - ], - id="tableau_help", - is_open=False, - fullscreen="md-down", - scrollable=True, - size="lg", - ), - ], + [], id="header", ), - # html.Div( - # [ - # "Recherche dans objet : ", - # dcc.Input(id="search", value="", type="text"), - # ] - # )]), dcc.Loading( overlay_style={"visibility": "visible", "filter": "blur(2px)"}, id="loading-home", @@ -284,6 +199,84 @@ layout = [ children=[ html.Div( [ + dbc.Button("Mode d'emploi", id="tableau_help_open"), + dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Header")), + dbc.ModalBody( + dcc.Markdown( + dangerously_allow_html=True, + children=f""" + ##### Définition des colonnes + + Pour voir la définition d'une colonne, passez votre souris sur son en-tête. + + ##### Appliquer des filtres + + Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`. + + - Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché et n'est pas sensible à la casse (majuscules/minuscules). + - Exemple : `rennes` retourne "RENNES METROPOLE". + - Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances` + - Champs numériques (Durée en mois, Montant, ...) : vous pouvez... + - soit taper un nombre pour trouver les valeurs strictement égales. Exemple : `12` ne retourne que des 12 + - soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres. + - Champs date (Date de notification, ...) : vous pouvez également utiliser **>** ou **<**. Exemples : + - `< 2024-01-31` pour "avant le 31 janvier 2024" + - `2024` pour "en 2024", `> 2022` pour "à partir de 2022". + - Pour les champs textuels et les champs dates : + - pour chercher du texte qui **commence par** votre texte, entrez `texte*`. C'est par exemple utile pour filtrer des acheteurs ou titulaires par numéro SIREN (`123456789*`) ou les marchés sur une année en particulier (`2024*`) + - pour chercher du texte qui **finit par** votre texte, entrez `*texte` + + Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page. + + ##### Trier les données + + Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre : + + 1. tri croissant + 2. tri décroissant + 3. pas de tri + + ##### Afficher plus de colonnes + + Par défaut, un nombre réduit de colonnes est affiché pour ne pas surcharger la page. Mais vous avez le choix parmi {str(df.width)} colonnes, ce serait dommage de vous limiter ! + + Pour afficher plus de colonnes, cliquez sur le bouton **Colonnes affichées** et cochez les colonnes pour les afficher. + + ##### Partager une vue + + Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqué. Cliquez sur l'icône drawing pour copier une adresse Web qui reproduit la vue courante à l'identique : en la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres. + + Pratique pour partager une vue avec un·e collègue, sur les réseaux sociaux, ou la sauvegarder pour plus tard. + + ##### Télécharger le résultat + + Vous pouvez télécharger le résultat de vos filtres et tris, pour les colonnes affichées, en cliquant sur **Télécharger au format Excel**. + + ##### Liens + + Les liens dans les colonnes Identifiant unique, Acheteur et Titulaire vous permettent de consulter une vue qui leur est dédiée + (informations, marchés attribués/remportés, etc.) + + """, + ), + ), + dbc.ModalFooter( + dbc.Button( + "Fermer", + id="tableau_help_close", + className="ms-auto", + n_clicks=0, + ) + ), + ], + id="tableau_help", + is_open=False, + fullscreen="md-down", + scrollable=True, + size="lg", + ), dbc.Button( "Colonnes affichées", id="tableau_columns_open", @@ -415,8 +408,10 @@ def restore_view_from_url(search): pass if "colonnes" in params: - columns = params["colonnes"][0].split(",") - verified_columns = [column for column in columns if column in schema.names()] + table_columns = params["colonnes"][0].split(",") + verified_columns = [ + column for column in table_columns if column in schema.names() + ] hidden_columns = invert_columns(verified_columns) return filter_query, sort_by, hidden_columns, "", trigger_cleanup From 1561cb3c9a83eb613779fca30615064e87570507 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 31 Jan 2026 01:44:11 +0100 Subject: [PATCH 13/36] Tableau de choix des colonnes => filtrable --- src/pages/tableau.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 050ec07..0eb16e1 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -86,7 +86,7 @@ def make_tableau_columns_table(): DataTable( row_selectable="multi", data=table_data, - filter_action="none", + filter_action="native", sort_action="none", style_cell={ "textAlign": "left", From 41a832d7beaa65c1b272dac23808f290b9737d33 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 31 Jan 2026 17:38:54 +0100 Subject: [PATCH 14/36] Tableau de choix des colonnes aussi sur les pages acheteur et titulaire --- src/assets/css/style.css | 6 ++- src/figures.py | 64 +++++++++++++++++++++++++++--- src/pages/acheteur.py | 84 +++++++++++++++++++++++++++++++++++++++- src/pages/tableau.py | 75 +++++------------------------------ src/pages/titulaire.py | 84 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 238 insertions(+), 75 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index adab228..b42c0f3 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -354,15 +354,17 @@ table.cell-table th { position: relative; width: 180px; margin: 0 0 10px 0; + display: none; } +/* .show-hide::before { background: inherit; content: "Colonnes affichées"; position: absolute; left: 5px; right: 5px; -} + #column_list .show-hide, #table .show-hide { @@ -371,7 +373,7 @@ table.cell-table th { .show-hide-menu-item > input { margin-right: 10px; -} +} */ /* Tooltips */ .dash-tooltip, diff --git a/src/figures.py b/src/figures.py index 2562093..e7b97cb 100644 --- a/src/figures.py +++ b/src/figures.py @@ -6,10 +6,10 @@ import plotly.graph_objects as go import polars as pl from dash import dash_table, dcc, html -from src.utils import data_schema, format_number +from src.utils import data_schema, df, format_number -def get_map_count_marches(df: pl.DataFrame): +def get_map_count_marches(): lf = df.lazy() lf = lf.with_columns( pl.col("lieuExecution_code").str.head(2).str.zfill(2).alias("Département") @@ -31,16 +31,16 @@ def get_map_count_marches(df: pl.DataFrame): for f in departements["features"]: f["id"] = f["properties"]["code"] - df = lf.collect(engine="streaming") + df_map = lf.collect(engine="streaming") fig = px.choropleth( - df, + df_map, geojson=departements, locations="Département", color="uid", color_continuous_scale="Reds", title="Nombres de marchés attribués par département (lieu d'exécution)", - range_color=(df["uid"].min(), df["uid"].max()), + range_color=(df_map["uid"].min(), df_map["uid"].max()), labels={"uid": "Marchés attribués"}, scope="europe", width=900, @@ -414,3 +414,57 @@ def get_duplicate_matrix() -> html.Div: dcc.Graph(figure=fig), ] ) + + +def make_column_picker(page: str): + table_data = [] + table_columns = [ + { + "id": col, + "name": data_schema[col]["title"], + "description": data_schema[col]["description"], + } + for col in df.columns + ] + for column in table_columns: + new_column = { + "id": column["id"], + "name": column["name"], + "description": data_schema[column["id"]]["description"], + } + table_data.append(new_column) + + table = ( + DataTable( + row_selectable="multi", + data=table_data, + filter_action="native", + sort_action="none", + style_cell={ + "textAlign": "left", + }, + columns=[ + { + "name": "Nom", + "id": "name", + }, + { + "name": "Description", + "id": "description", + }, + ], + style_cell_conditional=[ + { + "if": {"column_id": "description"}, + "minWidth": "450px", + "overflow": "hidden", + "lineHeight": "18px", + "whiteSpace": "normal", + } + ], + page_action="none", + dtid=f"{page}_column_list", + ), + ) + + return table diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 6f5e5e2..1ca9044 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -1,11 +1,13 @@ import datetime +import dash_bootstrap_components as dbc import polars as pl from dash import Input, Output, State, callback, dcc, html, register_page from src.callbacks import get_top_org_table -from src.figures import DataTable, point_on_map +from src.figures import DataTable, make_column_picker, point_on_map from src.utils import ( + columns, df, df_acheteurs, filter_table_data, @@ -53,6 +55,7 @@ datatable = html.Div( layout = [ dcc.Store(id="acheteur_data", storage_type="memory"), + dcc.Store(id="acheteur-hidden-columns", storage_type="memory"), dcc.Location(id="acheteur_url", refresh="callback-nav"), html.Div( children=[ @@ -133,6 +136,12 @@ layout = [ children=[ html.Div( [ + # Bouton modal des colonnes affichées + dbc.Button( + "Colonnes affichées", + id="acheteur_columns_open", + className="column_list", + ), html.P("lignes", id="acheteur_nb_rows"), html.Button( "Téléchargement désactivé au-delà de 65 000 lignes", @@ -144,6 +153,30 @@ layout = [ ], className="table-menu", ), + dbc.Modal( + [ + dbc.ModalHeader( + dbc.ModalTitle("Choix des colonnes à afficher") + ), + dbc.ModalBody( + id="acheteur_columns_body", + children=make_column_picker("acheteur"), + ), + dbc.ModalFooter( + dbc.Button( + "Fermer", + id="acheteur_columns_close", + className="ms-auto", + n_clicks=0, + ) + ), + ], + id="acheteur_columns", + is_open=False, + fullscreen="md-down", + scrollable=True, + size="xl", + ), datatable, ], ), @@ -333,3 +366,52 @@ def download_filtered_acheteur_data( return dcc.send_bytes( to_bytes, filename=f"decp_filtrées_{acheteur_nom}_{date}.xlsx" ) + + +@callback( + Output("acheteur-hidden-columns", "data", allow_duplicate=True), + Input("acheteur_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("acheteur_datatable", "hidden_columns", allow_duplicate=True), + Input( + "acheteur-hidden-columns", + "data", + ), + prevent_initial_call=True, +) +def store_hidden_columns(hidden_columns): + return hidden_columns + + +@callback( + Output("acheteur_column_list", "selected_rows"), + Input("acheteur_datatable", "hidden_columns"), + State("acheteur_column_list", "selected_rows"), # pour éviter la boucle infinie +) +def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes): + # 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("acheteur_columns", "is_open"), + Input("acheteur_columns_open", "n_clicks"), + Input("acheteur_columns_close", "n_clicks"), + State("acheteur_columns", "is_open"), +) +def toggle_acheteur_columns(click_open, click_close, is_open): + if click_open or click_close: + return not is_open + return is_open diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 0eb16e1..967d9ff 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -19,10 +19,10 @@ from dash import ( register_page, ) +from figures import make_column_picker from src.figures import DataTable from src.utils import ( columns, - data_schema, df, filter_table_data, get_default_hidden_columns, @@ -63,66 +63,6 @@ datatable = html.Div( ), ) - -def make_tableau_columns_table(): - table_data = [] - table_columns = [ - { - "id": col, - "name": data_schema[col]["title"], - "description": data_schema[col]["description"], - } - for col in df.columns - ] - for column in table_columns: - new_column = { - "id": column["id"], - "name": column["name"], - "description": data_schema[column["id"]]["description"], - } - table_data.append(new_column) - - table = ( - DataTable( - row_selectable="multi", - data=table_data, - filter_action="native", - sort_action="none", - style_cell={ - "textAlign": "left", - }, - columns=[ - { - "name": "Nom", - "id": "name", - }, - { - "name": "Description", - "id": "description", - }, - { - "name": "column_id", - "id": "id", - }, - ], - hidden_columns=["id"], - style_cell_conditional=[ - { - "if": {"column_id": "description"}, - "minWidth": "450px", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - } - ], - page_action="none", - dtid="column_list", - ), - ) - - return table - - layout = [ dcc.Location(id="tableau_url", refresh=False), dcc.Store(id="filter-cleanup-trigger"), @@ -199,6 +139,7 @@ layout = [ children=[ html.Div( [ + # Modal du mode d'emploi dbc.Button("Mode d'emploi", id="tableau_help_open"), dbc.Modal( [ @@ -246,7 +187,7 @@ layout = [ ##### Partager une vue - Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqué. Cliquez sur l'icône drawing pour copier une adresse Web qui reproduit la vue courante à l'identique : en la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres. + Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqués. Cliquez sur l'icône drawing pour copier une adresse Web qui reproduit la vue courante à l'identique : en la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres. Pratique pour partager une vue avec un·e collègue, sur les réseaux sociaux, ou la sauvegarder pour plus tard. @@ -277,6 +218,7 @@ layout = [ scrollable=True, size="lg", ), + # Bouton modal des colonnes affichées dbc.Button( "Colonnes affichées", id="tableau_columns_open", @@ -300,7 +242,8 @@ layout = [ [ dbc.ModalHeader(dbc.ModalTitle("Choix des colonnes à afficher")), dbc.ModalBody( - id="tableau_columns_body", children=make_tableau_columns_table() + id="tableau_columns_body", + children=make_column_picker("tableau"), ), dbc.ModalFooter( dbc.Button( @@ -502,7 +445,7 @@ def toggle_tableau_help(click_open, click_close, is_open): @callback( Output("tableau-hidden-columns", "data", allow_duplicate=True), - Input("column_list", "selected_rows"), + Input("tableau_column_list", "selected_rows"), prevent_initial_call=True, ) def update_hidden_columns_from_checkboxes(selected_columns): @@ -527,9 +470,9 @@ def store_hidden_columns(hidden_columns): @callback( - Output("column_list", "selected_rows"), + Output("tableau_column_list", "selected_rows"), Input("table", "hidden_columns"), - State("column_list", "selected_rows"), # pour éviter la boucle infinie + State("tableau_column_list", "selected_rows"), # pour éviter la boucle infinie ) def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes): # Show all columns that are NOT hidden diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index 84ec606..939dd96 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -1,11 +1,13 @@ import datetime +import dash_bootstrap_components as dbc import polars as pl from dash import Input, Output, State, callback, dcc, html, register_page from src.callbacks import get_top_org_table -from src.figures import DataTable, point_on_map +from src.figures import DataTable, make_column_picker, point_on_map from src.utils import ( + columns, df, df_titulaires, filter_table_data, @@ -53,6 +55,7 @@ datatable = html.Div( layout = [ dcc.Store(id="titulaire_data", storage_type="memory"), + dcc.Store(id="titulaire-hidden-columns", storage_type="memory"), dcc.Location(id="titulaire_url", refresh="callback-nav"), html.Div( children=[ @@ -132,6 +135,12 @@ layout = [ children=[ html.Div( [ + # Bouton modal des colonnes affichées + dbc.Button( + "Colonnes affichées", + id="titulaire_columns_open", + className="column_list", + ), html.P("lignes", id="titulaire_nb_rows"), html.Button( "Téléchargement désactivé au-delà de 65 000 lignes", @@ -142,6 +151,30 @@ layout = [ ], className="table-menu", ), + dbc.Modal( + [ + dbc.ModalHeader( + dbc.ModalTitle("Choix des colonnes à afficher") + ), + dbc.ModalBody( + id="titulaire_columns_body", + children=make_column_picker("titulaire"), + ), + dbc.ModalFooter( + dbc.Button( + "Fermer", + id="titulaire_columns_close", + className="ms-auto", + n_clicks=0, + ) + ), + ], + id="titulaire_columns", + is_open=False, + fullscreen="md-down", + scrollable=True, + size="xl", + ), datatable, ], ), @@ -346,3 +379,52 @@ def download_filtered_titulaire_data( return dcc.send_bytes( to_bytes, filename=f"decp_filtrées_{titulaire_nom}_{date}.xlsx" ) + + +@callback( + Output("titulaire-hidden-columns", "data", allow_duplicate=True), + Input("titulaire_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("titulaire_datatable", "hidden_columns", allow_duplicate=True), + Input( + "titulaire-hidden-columns", + "data", + ), + prevent_initial_call=True, +) +def store_hidden_columns(hidden_columns): + return hidden_columns + + +@callback( + Output("titulaire_column_list", "selected_rows"), + Input("titulaire_datatable", "hidden_columns"), + State("titulaire_column_list", "selected_rows"), # pour éviter la boucle infinie +) +def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes): + # 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("titulaire_columns", "is_open"), + Input("titulaire_columns_open", "n_clicks"), + Input("titulaire_columns_close", "n_clicks"), + State("titulaire_columns", "is_open"), +) +def toggle_titulaire_columns(click_open, click_close, is_open): + if click_open or click_close: + return not is_open + return is_open From bfc4065cbb19289157600f14dc963e0059a7e72a Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 31 Jan 2026 18:28:19 +0100 Subject: [PATCH 15/36] =?UTF-8?q?Les=20filtres=20sont=20stock=C3=A9s=20en?= =?UTF-8?q?=20LocalStorage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/statistiques.py | 2 +- src/pages/tableau.py | 25 +++++++++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/pages/statistiques.py b/src/pages/statistiques.py index 36d4d1d..058f660 100644 --- a/src/pages/statistiques.py +++ b/src/pages/statistiques.py @@ -65,7 +65,7 @@ layout = [ """), html.H4("Statistiques par année"), get_yearly_statistics(statistics, today_str), - dcc.Graph(figure=get_map_count_marches(df)), + dcc.Graph(figure=get_map_count_marches()), get_duplicate_matrix(), html.H3("Nombre de marchés par source dans le temps"), dcc.Graph( diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 967d9ff..9043408 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -67,6 +67,8 @@ layout = [ dcc.Location(id="tableau_url", refresh=False), dcc.Store(id="filter-cleanup-trigger"), dcc.Store(id="tableau-hidden-columns"), + dcc.Store(id="tableau-filters", storage_type="local"), + dcc.Store(id="tableau-table"), html.Script( type="application/ld+json", id="dataset_jsonld", @@ -277,7 +279,7 @@ layout = [ Output("btn-download-data", "title"), Input("table", "page_current"), Input("table", "page_size"), - Input("table", "filter_query"), + Input("tableau-filters", "data"), Input("table", "sort_by"), State("table", "data_timestamp"), ) @@ -286,6 +288,7 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp) # search_params = None # else: # search_params = urllib.parse.parse_qs(search_params.lstrip("?")) + print(filter_query) return prepare_table_data( None, data_timestamp, filter_query, page_current, page_size, sort_by, "tableau" ) @@ -323,16 +326,16 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): Output("table", "filter_query"), Output("table", "sort_by"), Output("tableau-hidden-columns", "data"), - Output("tableau_url", "search", allow_duplicate=True), + Output("tableau_url", "search"), Output("filter-cleanup-trigger", "data"), Input("tableau_url", "search"), - prevent_initial_call=True, + State("tableau-filters", "data"), ) -def restore_view_from_url(search): - if not search: +def restore_view_from_url(search, stored_filters): + if not search and not stored_filters: return no_update, no_update, no_update, no_update, no_update - params = urllib.parse.parse_qs(search.lstrip("?")) + params = urllib.parse.parse_qs(search.lstrip("?")) if search else {} logger.debug("params " + json.dumps(params, indent=2)) filter_query = no_update @@ -343,6 +346,9 @@ def restore_view_from_url(search): if "filtres" in params: filter_query = params["filtres"][0] trigger_cleanup = str(uuid.uuid4()) + elif stored_filters: + filter_query = stored_filters + trigger_cleanup = str(uuid.uuid4()) if "tris" in params: try: @@ -490,3 +496,10 @@ def toggle_tableau_columns(click_open, click_close, is_open): if click_open or click_close: return not is_open return is_open + + +@callback(Output("tableau-filters", "data"), Input("table", "filter_query")) +def sync_filters_to_local_storage(filter_query): + if filter_query: + return filter_query + return no_update From 7b3ea9c580e5171f974d4a3e9fd96ebe58c97abb Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 31 Jan 2026 19:12:34 +0100 Subject: [PATCH 16/36] =?UTF-8?q?Les=20tris=20sont=20stock=C3=A9s=20en=20L?= =?UTF-8?q?ocalStorage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/tableau.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 9043408..d731721 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -68,6 +68,7 @@ layout = [ dcc.Store(id="filter-cleanup-trigger"), dcc.Store(id="tableau-hidden-columns"), dcc.Store(id="tableau-filters", storage_type="local"), + dcc.Store(id="tableau-sort", storage_type="local"), dcc.Store(id="tableau-table"), html.Script( type="application/ld+json", @@ -237,6 +238,11 @@ layout = [ dcc.Download(id="download-data"), dcc.Store(id="filtered_data", storage_type="memory"), html.P("Données mises à jour le " + str(update_date)), + dbc.Button( + "Remise à zéro", + title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.", + id="btn-tableau-reset", + ), ], className="table-menu", ), @@ -280,7 +286,7 @@ layout = [ Input("table", "page_current"), Input("table", "page_size"), Input("tableau-filters", "data"), - Input("table", "sort_by"), + Input("tableau-sort", "data"), State("table", "data_timestamp"), ) def update_table(page_current, page_size, filter_query, sort_by, data_timestamp): @@ -330,8 +336,9 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): Output("filter-cleanup-trigger", "data"), Input("tableau_url", "search"), State("tableau-filters", "data"), + State("tableau-sort", "data"), ) -def restore_view_from_url(search, stored_filters): +def restore_view_from_url(search, stored_filters, stored_sort): if not search and not stored_filters: return no_update, no_update, no_update, no_update, no_update @@ -355,6 +362,8 @@ def restore_view_from_url(search, stored_filters): sort_by = json.loads(params["tris"][0]) except json.JSONDecodeError: pass + elif stored_sort: + sort_by = stored_sort if "colonnes" in params: table_columns = params["colonnes"][0].split(",") @@ -500,6 +509,19 @@ def toggle_tableau_columns(click_open, click_close, is_open): @callback(Output("tableau-filters", "data"), Input("table", "filter_query")) def sync_filters_to_local_storage(filter_query): - if filter_query: - return filter_query - return no_update + return filter_query + + +@callback(Output("tableau-sort", "data"), Input("table", "sort_by")) +def sync_sort_to_local_storage(sort_by): + return sort_by + + +@callback( + Output("table", "filter_query", allow_duplicate=True), + Output("table", "sort_by", allow_duplicate=True), + Input("btn-tableau-reset", "n_clicks"), + prevent_initial_call=True, +) +def reset_view(n_clicks): + return "", [] From edd52d471e82586a4a0810f11ed7bbe67caeef33 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 31 Jan 2026 19:20:41 +0100 Subject: [PATCH 17/36] Suppression (pour l'instant de la carte acheteur/titulaire) --- src/assets/css/style.css | 2 +- src/figures.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index b42c0f3..1dee649 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -350,7 +350,7 @@ table.cell-table th { display: none; } -.show-hide { +button.show-hide { position: relative; width: 180px; margin: 0 0 10px 0; diff --git a/src/figures.py b/src/figures.py index e7b97cb..c5eed34 100644 --- a/src/figures.py +++ b/src/figures.py @@ -244,6 +244,7 @@ def point_on_map(lat, lon): fig.update_layout(map_center={"lat": 46.6, "lon": 1.89}, map_zoom=4) graph = dcc.Graph(id="map", figure=fig) + graph = html.Div(style={"width": "400px"}) return graph From ef111842868b5bbcaaa9f768e21cf30103ad5697 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 2 Feb 2026 17:11:32 +0100 Subject: [PATCH 18/36] =?UTF-8?q?Les=20colonnes=20masqu=C3=A9es=20sont=20s?= =?UTF-8?q?tock=C3=A9es=20en=20LocalStorage=20et=20correctement=20synchron?= =?UTF-8?q?is=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/tableau.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index d731721..724f0b8 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -58,7 +58,7 @@ datatable = html.Div( page_action="custom", filter_action="custom", sort_action="custom", - hidden_columns=get_default_hidden_columns(None), + hidden_columns=[], columns=[{"id": col, "name": col} for col in df.columns], ), ) @@ -66,7 +66,7 @@ datatable = html.Div( layout = [ dcc.Location(id="tableau_url", refresh=False), dcc.Store(id="filter-cleanup-trigger"), - dcc.Store(id="tableau-hidden-columns"), + dcc.Store(id="tableau-hidden-columns", storage_type="local"), dcc.Store(id="tableau-filters", storage_type="local"), dcc.Store(id="tableau-sort", storage_type="local"), dcc.Store(id="tableau-table"), @@ -294,7 +294,6 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp) # search_params = None # else: # search_params = urllib.parse.parse_qs(search_params.lstrip("?")) - print(filter_query) return prepare_table_data( None, data_timestamp, filter_query, page_current, page_size, sort_by, "tableau" ) @@ -473,12 +472,11 @@ def update_hidden_columns_from_checkboxes(selected_columns): @callback( - Output("table", "hidden_columns", allow_duplicate=True), + Output("table", "hidden_columns"), Input( "tableau-hidden-columns", "data", ), - prevent_initial_call=True, ) def store_hidden_columns(hidden_columns): return hidden_columns @@ -490,6 +488,8 @@ def store_hidden_columns(hidden_columns): State("tableau_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(None) + # 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 From deba0a244eedeb13b012ee80f1fbad45cc54ccb0 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 2 Feb 2026 18:28:05 +0100 Subject: [PATCH 19/36] Ajout d'un lien canonique vers chaque page dans le head #70 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dynamique, donc pas sûr que les moteurs de recherche le voit. --- src/pages/tableau.py | 6 ++++++ src/utils.py | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 724f0b8..ab95c80 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -22,6 +22,7 @@ from dash import ( from figures import make_column_picker from src.figures import DataTable from src.utils import ( + add_canonical_link, columns, df, filter_table_data, @@ -525,3 +526,8 @@ def sync_sort_to_local_storage(sort_by): ) def reset_view(n_clicks): return "", [] + + +@callback(Input("tableau_url", "pathname")) +def cb_add_canonical_link(pathname): + add_canonical_link(pathname) diff --git a/src/utils.py b/src/utils.py index 57385fd..26411ce 100644 --- a/src/utils.py +++ b/src/utils.py @@ -5,6 +5,7 @@ import uuid from collections import OrderedDict from time import localtime, sleep +import dash import polars as pl import polars.selectors as cs from httpx import get, post @@ -721,6 +722,14 @@ def make_org_jsonld(org_id, org_type, org_name=None, type_org_id="SIRET") -> dic return jsonld +def add_canonical_link(pathname): + @dash.hooks.index() + def update_index(html_string): + url = f"https://{domain_name}{pathname}" + canonical_tag = f'' + return html_string.replace("", f"{canonical_tag}\n ") + + df: pl.DataFrame = get_decp_data() schema = df.collect_schema() From f16cca036dd79134eadba437cfa12cba33543358 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 2 Feb 2026 18:56:19 +0100 Subject: [PATCH 20/36] =?UTF-8?q?Toutes=20les=20pages=20s'ouvrent=20dans?= =?UTF-8?q?=20le=20m=C3=AAme=20onglet=20puisque=20plus=20de=20perte=20de?= =?UTF-8?q?=20filtre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/css/style.css | 5 +++++ src/pages/acheteur.py | 1 - src/pages/titulaire.py | 1 - src/utils.py | 10 +++++----- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 1dee649..ca598da 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -375,6 +375,11 @@ button.show-hide { margin-right: 10px; } */ +/* Dropdowns */ +.Select-placeholder { + color: #333; +} + /* Tooltips */ .dash-tooltip, .dash-table-tooltip { diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 1ca9044..7f1790c 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -99,7 +99,6 @@ layout = [ html.A( id="acheteur_lien_annuaire", children="Plus de détails sur l'Annuaire des entreprises", - target="_blank", ), ], ), diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index 939dd96..d7f2267 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -99,7 +99,6 @@ layout = [ html.A( id="titulaire_lien_annuaire", children="Plus de détails sur l'Annuaire des entreprises", - target="_blank", ), ], ), diff --git a/src/utils.py b/src/utils.py index 26411ce..f870f56 100644 --- a/src/utils.py +++ b/src/utils.py @@ -58,7 +58,7 @@ def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame: return dff -def add_links(dff: pl.DataFrame, target: str = "_blank"): +def add_links(dff: pl.DataFrame): for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]: if col in dff.columns: if col.startswith("titulaire_"): @@ -72,7 +72,7 @@ def add_links(dff: pl.DataFrame, target: str = "_blank"): .then( '' + + '">' + pl.col(col) + "" ) @@ -84,7 +84,7 @@ def add_links(dff: pl.DataFrame, target: str = "_blank"): ( '' + + '">' + pl.col(col) + "" ).alias(col) @@ -94,7 +94,7 @@ def add_links(dff: pl.DataFrame, target: str = "_blank"): ( '' + + '">' + pl.col("uid") + "" ).alias("uid") @@ -551,7 +551,7 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame: ) # Format result - dff = add_links(dff, target="") + dff = add_links(dff) dff = dff.with_columns( pl.concat_str( pl.col(f"{org_type}_departement_nom"), From 55e29925598e0b6e790d6f59d8c0962b4886fe71 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 2 Feb 2026 19:51:09 +0100 Subject: [PATCH 21/36] id table => tableau_datatable --- src/pages/tableau.py | 46 ++++++++++++++++++++++---------------------- src/utils.py | 7 +++---- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index ab95c80..a64b0e3 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -54,7 +54,7 @@ register_page( datatable = html.Div( className="marches_table", children=DataTable( - dtid="table", + dtid="tableau_datatable", page_size=20, page_action="custom", filter_action="custom", @@ -276,19 +276,19 @@ layout = [ @callback( - Output("table", "data"), - Output("table", "columns"), - Output("table", "tooltip_header"), - Output("table", "data_timestamp"), + Output("tableau_datatable", "data"), + Output("tableau_datatable", "columns"), + Output("tableau_datatable", "tooltip_header"), + Output("tableau_datatable", "data_timestamp"), Output("nb_rows", "children"), Output("btn-download-data", "disabled"), Output("btn-download-data", "children"), Output("btn-download-data", "title"), - Input("table", "page_current"), - Input("table", "page_size"), + Input("tableau_datatable", "page_current"), + Input("tableau_datatable", "page_size"), Input("tableau-filters", "data"), Input("tableau-sort", "data"), - State("table", "data_timestamp"), + State("tableau_datatable", "data_timestamp"), ) def update_table(page_current, page_size, filter_query, sort_by, data_timestamp): # if ctx.triggered_id != "url": @@ -303,9 +303,9 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp) @callback( Output("download-data", "data"), Input("btn-download-data", "n_clicks"), - State("table", "filter_query"), - State("table", "sort_by"), - State("table", "hidden_columns"), + State("tableau_datatable", "filter_query"), + State("tableau_datatable", "sort_by"), + State("tableau_datatable", "hidden_columns"), prevent_initial_call=True, ) def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): @@ -329,8 +329,8 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): @callback( - Output("table", "filter_query"), - Output("table", "sort_by"), + Output("tableau_datatable", "filter_query"), + Output("tableau_datatable", "sort_by"), Output("tableau-hidden-columns", "data"), Output("tableau_url", "search"), Output("filter-cleanup-trigger", "data"), @@ -389,9 +389,9 @@ clientside_callback( @callback( Output("share-url", "value"), Output("copy-container", "children"), - Input("table", "filter_query"), - Input("table", "sort_by"), - Input("table", "hidden_columns"), + Input("tableau_datatable", "filter_query"), + Input("tableau_datatable", "sort_by"), + Input("tableau_datatable", "hidden_columns"), State("tableau_url", "href"), prevent_initial_call=True, ) @@ -473,7 +473,7 @@ def update_hidden_columns_from_checkboxes(selected_columns): @callback( - Output("table", "hidden_columns"), + Output("tableau_datatable", "hidden_columns"), Input( "tableau-hidden-columns", "data", @@ -485,11 +485,11 @@ def store_hidden_columns(hidden_columns): @callback( Output("tableau_column_list", "selected_rows"), - Input("table", "hidden_columns"), + Input("tableau_datatable", "hidden_columns"), State("tableau_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(None) + 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] @@ -508,19 +508,19 @@ def toggle_tableau_columns(click_open, click_close, is_open): return is_open -@callback(Output("tableau-filters", "data"), Input("table", "filter_query")) +@callback(Output("tableau-filters", "data"), Input("tableau_datatable", "filter_query")) def sync_filters_to_local_storage(filter_query): return filter_query -@callback(Output("tableau-sort", "data"), Input("table", "sort_by")) +@callback(Output("tableau-sort", "data"), Input("tableau_datatable", "sort_by")) def sync_sort_to_local_storage(sort_by): return sort_by @callback( - Output("table", "filter_query", allow_duplicate=True), - Output("table", "sort_by", allow_duplicate=True), + Output("tableau_datatable", "filter_query", allow_duplicate=True), + Output("tableau_datatable", "sort_by", allow_duplicate=True), Input("btn-tableau-reset", "n_clicks"), prevent_initial_call=True, ) diff --git a/src/utils.py b/src/utils.py index f870f56..bcb62a6 100644 --- a/src/utils.py +++ b/src/utils.py @@ -428,12 +428,11 @@ def get_default_hidden_columns(page): "codeCPV", "dureeRestanteMois", ] + elif page == "titulaire": + displayed_columns = os.getenv("DISPLAYED_COLUMNS") else: displayed_columns = os.getenv("DISPLAYED_COLUMNS") - if displayed_columns is None: - raise ValueError("DISPLAYED_COLUMNS n'est pas configuré") - else: - displayed_columns = displayed_columns.replace(" ", "").split(",") + logger.warning(f"Invalid page: {page}") hidden_columns = [] From 15de2aaf1745515755a419dc3557ea7b6bd7b9c2 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 2 Feb 2026 19:51:40 +0100 Subject: [PATCH 22/36] Placeholder de dropdown plus visible --- src/assets/css/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index ca598da..ccb63d0 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -377,7 +377,7 @@ button.show-hide { /* Dropdowns */ .Select-placeholder { - color: #333; + color: #333 !important; } /* Tooltips */ From 6fdf01e5e162b27aeb36d3f7247ed7bf734c7226 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 2 Feb 2026 19:56:27 +0100 Subject: [PATCH 23/36] Colonnes, filtres et tri de la page acheteur en LocalStorage --- src/pages/acheteur.py | 62 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 7f1790c..f3a907f 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -7,6 +7,7 @@ from dash import Input, Output, State, callback, dcc, html, register_page from src.callbacks import get_top_org_table from src.figures import DataTable, make_column_picker, point_on_map from src.utils import ( + add_canonical_link, columns, df, df_acheteurs, @@ -49,13 +50,16 @@ datatable = html.Div( filter_action="custom", sort_action="custom", page_size=10, - hidden_columns=get_default_hidden_columns(page="acheteur"), + hidden_columns=[], + columns=[{"id": col, "name": col} for col in df.columns], ), ) layout = [ dcc.Store(id="acheteur_data", storage_type="memory"), - dcc.Store(id="acheteur-hidden-columns", storage_type="memory"), + dcc.Store(id="acheteur-hidden-columns", storage_type="local"), + dcc.Store(id="acheteur-filters", storage_type="local"), + dcc.Store(id="acheteur-sort", storage_type="local"), dcc.Location(id="acheteur_url", refresh="callback-nav"), html.Div( children=[ @@ -149,6 +153,11 @@ layout = [ disabled=True, ), dcc.Download(id="acheteur-download-filtered-data"), + dbc.Button( + "Remise à zéro", + title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.", + id="btn-acheteur-reset", + ), ], className="table-menu", ), @@ -286,8 +295,8 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> tuple: Input("acheteur_data", "data"), Input("acheteur_datatable", "page_current"), Input("acheteur_datatable", "page_size"), - Input("acheteur_datatable", "filter_query"), - Input("acheteur_datatable", "sort_by"), + Input("acheteur-filters", "data"), + Input("acheteur-sort", "data"), State("acheteur_datatable", "data_timestamp"), ) def get_last_marches_data( @@ -336,8 +345,8 @@ def download_acheteur_data( State("acheteur_data", "data"), Input("btn-download-filtered-data-acheteur", "n_clicks"), State("acheteur_nom", "children"), - State("acheteur_datatable", "filter_query"), - State("acheteur_datatable", "sort_by"), + State("acheteur-filters", "data"), + State("acheteur-sort", "data"), State("acheteur_datatable", "hidden_columns"), prevent_initial_call=True, ) @@ -399,6 +408,8 @@ def store_hidden_columns(hidden_columns): State("acheteur_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("acheteur") + # 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 @@ -414,3 +425,42 @@ def toggle_acheteur_columns(click_open, click_close, is_open): if click_open or click_close: return not is_open return is_open + + +@callback( + Output("acheteur-filters", "data"), Input("acheteur_datatable", "filter_query") +) +def sync_filters_to_local_storage(filter_query): + return filter_query + + +@callback(Output("acheteur-sort", "data"), Input("acheteur_datatable", "sort_by")) +def sync_sort_to_local_storage(sort_by): + return sort_by + + +@callback( + Output("acheteur_datatable", "filter_query", allow_duplicate=True), + Output("acheteur_datatable", "sort_by", allow_duplicate=True), + Input("acheteur_url", "href"), + State("acheteur-filters", "data"), + State("acheteur-sort", "data"), + prevent_initial_call=True, +) +def sync_local_storage_to_datatable(href, filter_query, sort_by): + return filter_query, sort_by + + +@callback( + Output("acheteur_datatable", "filter_query", allow_duplicate=True), + Output("acheteur_datatable", "sort_by"), + Input("btn-acheteur-reset", "n_clicks"), + prevent_initial_call=True, +) +def reset_view(n_clicks): + return "", [] + + +@callback(Input("acheteur_url", "pathname")) +def cb_add_canonical_link(pathname): + add_canonical_link(pathname) From 0532f6b0a73b86a72d53415c324d5fda03ec344a Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 4 Feb 2026 09:46:22 +0100 Subject: [PATCH 24/36] Colonnes, filtres et tri de la page titulaire en LocalStorage (buggy) --- src/pages/titulaire.py | 65 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index d7f2267..81e164f 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -7,6 +7,7 @@ from dash import Input, Output, State, callback, dcc, html, register_page from src.callbacks import get_top_org_table from src.figures import DataTable, make_column_picker, point_on_map from src.utils import ( + add_canonical_link, columns, df, df_titulaires, @@ -49,13 +50,16 @@ datatable = html.Div( filter_action="custom", sort_action="custom", page_size=10, - hidden_columns=get_default_hidden_columns(page="titulaire"), + hidden_columns=[], + columns=[{"id": col, "name": col} for col in df.columns], ), ) layout = [ dcc.Store(id="titulaire_data", storage_type="memory"), - dcc.Store(id="titulaire-hidden-columns", storage_type="memory"), + dcc.Store(id="titulaire-hidden-columns", storage_type="local"), + dcc.Store(id="titulaire-filters", storage_type="local"), + dcc.Store(id="titulaire-sort", storage_type="local"), dcc.Location(id="titulaire_url", refresh="callback-nav"), html.Div( children=[ @@ -111,6 +115,7 @@ layout = [ html.Button( "Téléchargement au format Excel", id="btn-download-data-titulaire", + className="btn btn-primary", ), dcc.Download(id="download-data-titulaire"), ], @@ -145,8 +150,15 @@ layout = [ "Téléchargement désactivé au-delà de 65 000 lignes", id="btn-download-filtered-data-titulaire", disabled=True, + className="btn btn-primary", ), dcc.Download(id="titulaire-download-filtered-data"), + dbc.Button( + "Remise à zéro", + title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.", + id="btn-titulaire-reset", + className="btn btn-primary", + ), ], className="table-menu", ), @@ -293,8 +305,8 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> tuple: Input("titulaire_data", "data"), Input("titulaire_datatable", "page_current"), Input("titulaire_datatable", "page_size"), - Input("titulaire_datatable", "filter_query"), - Input("titulaire_datatable", "sort_by"), + Input("titulaire-filters", "data"), + Input("titulaire-sort", "data"), State("titulaire_datatable", "data_timestamp"), ) def get_last_marches_data( @@ -349,8 +361,8 @@ def download_titulaire_data( State("titulaire_data", "data"), Input("btn-download-filtered-data-titulaire", "n_clicks"), State("titulaire_nom", "children"), - State("titulaire_datatable", "filter_query"), - State("titulaire_datatable", "sort_by"), + State("titulaire-filters", "data"), + State("titulaire-sort", "data"), State("titulaire_datatable", "hidden_columns"), prevent_initial_call=True, ) @@ -412,6 +424,8 @@ def store_hidden_columns(hidden_columns): State("titulaire_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("titulaire") + # 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 @@ -427,3 +441,42 @@ def toggle_titulaire_columns(click_open, click_close, is_open): if click_open or click_close: return not is_open return is_open + + +@callback( + Output("titulaire-filters", "data"), Input("titulaire_datatable", "filter_query") +) +def sync_filters_to_local_storage(filter_query): + return filter_query + + +@callback(Output("titulaire-sort", "data"), Input("titulaire_datatable", "sort_by")) +def sync_sort_to_local_storage(sort_by): + return sort_by + + +@callback( + Output("titulaire_datatable", "filter_query", allow_duplicate=True), + Output("titulaire_datatable", "sort_by", allow_duplicate=True), + Input("titulaire_url", "href"), + State("titulaire-filters", "data"), + State("titulaire-sort", "data"), + prevent_initial_call=True, +) +def sync_local_storage_to_datatable(href, filter_query, sort_by): + return filter_query, sort_by + + +@callback( + Output("titulaire_datatable", "filter_query", allow_duplicate=True), + Output("titulaire_datatable", "sort_by"), + Input("btn-titulaire-reset", "n_clicks"), + prevent_initial_call=True, +) +def reset_view(n_clicks): + return "", [] + + +@callback(Input("titulaire_url", "pathname")) +def cb_add_canonical_link(pathname): + add_canonical_link(pathname) From ad9b4f0fdf955630961f399bdba5e2463cd18885 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 4 Feb 2026 12:09:41 +0100 Subject: [PATCH 25/36] Nettoyage des filtres aussi sur acheteur (clientside callback) --- src/pages/acheteur.py | 28 +++++++++++++++++++++++++++- src/pages/tableau.py | 12 ++++++++---- src/utils.py | 9 +++++++-- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index f3a907f..f225d97 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -2,7 +2,17 @@ import datetime import dash_bootstrap_components as dbc import polars as pl -from dash import Input, Output, State, callback, dcc, html, register_page +from dash import ( + ClientsideFunction, + Input, + Output, + State, + callback, + clientside_callback, + dcc, + html, + register_page, +) from src.callbacks import get_top_org_table from src.figures import DataTable, make_column_picker, point_on_map @@ -60,6 +70,7 @@ layout = [ dcc.Store(id="acheteur-hidden-columns", storage_type="local"), dcc.Store(id="acheteur-filters", storage_type="local"), dcc.Store(id="acheteur-sort", storage_type="local"), + dcc.Store(id="filter-cleanup-trigger-acheteur"), dcc.Location(id="acheteur_url", refresh="callback-nav"), html.Div( children=[ @@ -292,12 +303,14 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> tuple: Output("btn-download-filtered-data-acheteur", "disabled"), Output("btn-download-filtered-data-acheteur", "children"), Output("btn-download-filtered-data-acheteur", "title"), + Output("filter-cleanup-trigger-acheteur", "data", allow_duplicate=True), Input("acheteur_data", "data"), Input("acheteur_datatable", "page_current"), Input("acheteur_datatable", "page_size"), Input("acheteur-filters", "data"), Input("acheteur-sort", "data"), State("acheteur_datatable", "data_timestamp"), + prevent_initial_call=True, ) def get_last_marches_data( data, page_current, page_size, filter_query, sort_by, data_timestamp @@ -376,6 +389,19 @@ def download_filtered_acheteur_data( ) +# Pour nettoyer les icontains et i< des filtres +# voir aussi src/assets/dash_clientside.js +clientside_callback( + ClientsideFunction( + namespace="clientside", + function_name="clean_filters", + ), + Output("filter-cleanup-trigger-acheteur", "data", allow_duplicate=True), + Input("filter-cleanup-trigger-acheteur", "data"), + prevent_initial_call=True, +) + + @callback( Output("acheteur-hidden-columns", "data", allow_duplicate=True), Input("acheteur_column_list", "selected_rows"), diff --git a/src/pages/tableau.py b/src/pages/tableau.py index a64b0e3..e44ae78 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -66,7 +66,7 @@ datatable = html.Div( layout = [ dcc.Location(id="tableau_url", refresh=False), - dcc.Store(id="filter-cleanup-trigger"), + dcc.Store(id="filter-cleanup-trigger-tableau"), dcc.Store(id="tableau-hidden-columns", storage_type="local"), dcc.Store(id="tableau-filters", storage_type="local"), dcc.Store(id="tableau-sort", storage_type="local"), @@ -284,11 +284,13 @@ layout = [ Output("btn-download-data", "disabled"), Output("btn-download-data", "children"), Output("btn-download-data", "title"), + Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True), Input("tableau_datatable", "page_current"), Input("tableau_datatable", "page_size"), Input("tableau-filters", "data"), Input("tableau-sort", "data"), State("tableau_datatable", "data_timestamp"), + prevent_initial_call=True, ) def update_table(page_current, page_size, filter_query, sort_by, data_timestamp): # if ctx.triggered_id != "url": @@ -333,7 +335,7 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): Output("tableau_datatable", "sort_by"), Output("tableau-hidden-columns", "data"), Output("tableau_url", "search"), - Output("filter-cleanup-trigger", "data"), + Output("filter-cleanup-trigger-tableau", "data"), Input("tableau_url", "search"), State("tableau-filters", "data"), State("tableau-sort", "data"), @@ -375,13 +377,15 @@ def restore_view_from_url(search, stored_filters, stored_sort): return filter_query, sort_by, hidden_columns, "", trigger_cleanup +# Pour nettoyer les icontains et i< des filtres +# voir aussi src/assets/dash_clientside.js clientside_callback( ClientsideFunction( namespace="clientside", function_name="clean_filters", ), - Output("filter-cleanup-trigger", "data", allow_duplicate=True), - Input("filter-cleanup-trigger", "data"), + Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True), + Input("filter-cleanup-trigger-tableau", "data"), prevent_initial_call=True, ) diff --git a/src/utils.py b/src/utils.py index bcb62a6..f7c80bf 100644 --- a/src/utils.py +++ b/src/utils.py @@ -8,6 +8,7 @@ from time import localtime, sleep import dash import polars as pl import polars.selectors as cs +from dash import no_update from httpx import get, post from polars.exceptions import ComputeError from unidecode import unidecode @@ -586,6 +587,8 @@ def prepare_table_data( if os.getenv("DEVELOPMENT").lower() == "true": logger.debug(" + + + + + + + + + + + + + + + + + + ") + trigger_cleanup = no_update + # Récupération des données if isinstance(data, list): lff: pl.LazyFrame = pl.LazyFrame(data, strict=False, infer_schema_length=5000) @@ -595,6 +598,7 @@ def prepare_table_data( # Application des filtres if filter_query: lff = filter_table_data(lff, filter_query, source_table) + trigger_cleanup = no_update if source_table == "tableau" else str(uuid.uuid4()) # Application des tris if len(sort_by) > 0: @@ -632,7 +636,7 @@ def prepare_table_data( dff = format_values(dff) # Récupération des colonnes et tooltip - columns, tooltip = setup_table_columns(dff) + table_columns, tooltip = setup_table_columns(dff) dicts = dff.to_dicts() @@ -641,13 +645,14 @@ def prepare_table_data( return ( dicts, - columns, + table_columns, tooltip, data_timestamp + 1, nb_rows, download_disabled, download_text, download_title, + trigger_cleanup, ) From f480439985409d32a79fbe0ef4b49402752eee5d Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 4 Feb 2026 13:04:11 +0100 Subject: [PATCH 26/36] =?UTF-8?q?acheteur=20:=20le=20clientside=20callback?= =?UTF-8?q?=20est=20aussi=20trigger=20quand=20les=20colonnes=20affich?= =?UTF-8?q?=C3=A9es=20changent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/acheteur.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index f225d97..0ca2253 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -1,4 +1,5 @@ import datetime +import uuid import dash_bootstrap_components as dbc import polars as pl @@ -11,6 +12,7 @@ from dash import ( clientside_callback, dcc, html, + no_update, register_page, ) @@ -404,16 +406,19 @@ clientside_callback( @callback( Output("acheteur-hidden-columns", "data", allow_duplicate=True), + Output("filter-cleanup-trigger-acheteur", "data", allow_duplicate=True), Input("acheteur_column_list", "selected_rows"), + State("acheteur-filters", "data"), prevent_initial_call=True, ) -def update_hidden_columns_from_checkboxes(selected_columns): +def update_hidden_columns_from_checkboxes(selected_columns, filter_query): + trigger_cleanup = str(uuid.uuid4()) if filter_query else no_update 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 + return hidden_columns, trigger_cleanup else: - return [] + return [], trigger_cleanup @callback( From 39af4bafbe92f67f8931b9dc2ce937713baefa4c Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 4 Feb 2026 13:05:59 +0100 Subject: [PATCH 27/36] =?UTF-8?q?titulaire=20:=20clientside=20callback=20a?= =?UTF-8?q?jout=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/titulaire.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index 81e164f..bd54a91 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -2,7 +2,17 @@ import datetime import dash_bootstrap_components as dbc import polars as pl -from dash import Input, Output, State, callback, dcc, html, register_page +from dash import ( + ClientsideFunction, + Input, + Output, + State, + callback, + clientside_callback, + dcc, + html, + register_page, +) from src.callbacks import get_top_org_table from src.figures import DataTable, make_column_picker, point_on_map @@ -60,6 +70,7 @@ layout = [ dcc.Store(id="titulaire-hidden-columns", storage_type="local"), dcc.Store(id="titulaire-filters", storage_type="local"), dcc.Store(id="titulaire-sort", storage_type="local"), + dcc.Store(id="filter-cleanup-trigger-titulaire"), dcc.Location(id="titulaire_url", refresh="callback-nav"), html.Div( children=[ @@ -302,12 +313,14 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> tuple: Output("btn-download-filtered-data-titulaire", "disabled"), Output("btn-download-filtered-data-titulaire", "children"), Output("btn-download-filtered-data-titulaire", "title"), + Output("filter-cleanup-trigger-titulaire", "data", allow_duplicate=True), Input("titulaire_data", "data"), Input("titulaire_datatable", "page_current"), Input("titulaire_datatable", "page_size"), Input("titulaire-filters", "data"), Input("titulaire-sort", "data"), State("titulaire_datatable", "data_timestamp"), + config_prevent_initial_callbacks=True, ) def get_last_marches_data( data, page_current, page_size, filter_query, sort_by, data_timestamp @@ -392,6 +405,19 @@ def download_filtered_titulaire_data( ) +# Pour nettoyer les icontains et i< des filtres +# voir aussi src/assets/dash_clientside.js +clientside_callback( + ClientsideFunction( + namespace="clientside", + function_name="clean_filters", + ), + Output("filter-cleanup-trigger-titulaire", "data", allow_duplicate=True), + Input("filter-cleanup-trigger-titulaire", "data"), + prevent_initial_call=True, +) + + @callback( Output("titulaire-hidden-columns", "data", allow_duplicate=True), Input("titulaire_column_list", "selected_rows"), From 61dbf237cfb518c8b184e414da83fa8631b0c09e Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 4 Feb 2026 23:33:59 +0100 Subject: [PATCH 28/36] Premier test UI --- pyproject.toml | 17 +++++++++++++++++ src/app.py | 6 +++++- tests/__init__.py | 0 tests/conftest.py | 41 +++++++++++++++++++++++++++++++++++++++++ tests/test_main.py | 31 +++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_main.py diff --git a/pyproject.toml b/pyproject.toml index a75f5fd..261b9bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,5 +22,22 @@ dependencies = [ [project.optional-dependencies] dev = [ + "pytest", + "pytest-env", "pre-commit", + "selenium", + "webdriver-manager", + "dash[testing]", ] + +[tool.pytest.ini_options] +pythonpath = [ + "src" +] +testpaths = [ + "tests" +] +env = [ + "DATA_FILE_PARQUET_PATH=tests/test.parquet" +] +addopts = "-p no:warnings" diff --git a/src/app.py b/src/app.py index e444c95..3f45aed 100644 --- a/src/app.py +++ b/src/app.py @@ -9,6 +9,10 @@ from flask import Response load_dotenv() +# if os.getenv("PYTEST_CURRENT_TEST"): +# os.environ["DATA_FILE_PARQUET_PATH"] + + development = os.getenv("DEVELOPMENT").lower() == "true" meta_tags = [ @@ -22,7 +26,7 @@ meta_tags = [ if development: meta_tags.append({"name": "robots", "content": "noindex"}) -app = Dash( +app: Dash = Dash( title="decp.info", use_pages=True, compress=True, diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..cd58181 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,41 @@ +import datetime +import os + +import polars as pl +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def test_data(): + data = [ + { + "uid": "1", + "acheteur_nom": "Acheteur 1", + "acheteur_id": "a1", + "titulaire_nom": "Titulaire 1", + "titulaire_id": "t1", + "montant": 10, + "dateNotification": datetime.date(2025, 1, 1), + "codeCPV": "71600000", + "donneesActuelles": True, + "acheteur_departement_code": "75", + "acheteur_departement_nom": "Paris", + "acheteur_commune_nom": "Paris", + "titulaire_departement_code": "35", + "titulaire_departement_nom": "Ille-et-Vilaine", + "titulaire_commune_nom": "Rennes", + "titulaire_typeIdentifiant": "SIRET", + "objet": "Objet test", + "dureeRestanteMois": 12, + "lieuExecution_code": "75001", + "sourceFile": "test.xml", + "sourceDataset": "test_dataset", + "datePublicationDonnees": datetime.date(2025, 1, 1), + } + ] + path = "tests/test.parquet" + path = os.path.abspath(path) + print(f"Writing test data to: {path}") # <-- This will show you the real path + + pl.DataFrame(data).write_parquet("tests/test.parquet") + yield path diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..8bac293 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,31 @@ +from dash.testing.composite import DashComposite +from selenium.webdriver import Keys +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webelement import WebElement + + +def test_001_logo_and_search(dash_duo: DashComposite): + from src.app import app + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4) + assert dash_duo.find_element(".logo > h1").text == "decp.info" + search_bar: WebElement = dash_duo.find_element("#search") + search_bar.click() + search_bar.send_keys("A") + search_bar.send_keys(Keys.ENTER) + + dash_duo.wait_for_element("#results_acheteur_datatable") + result_table_acheteurs: WebElement = dash_duo.find_element( + "#results_acheteur_datatable tbody" + ) + + assert ( + len(result_table_acheteurs.find_elements(by=By.TAG_NAME, value="tr")) == 2 + ) # header row + 1 result + assert ( + result_table_acheteurs.find_element( + by=By.CSS_SELECTOR, value='td[data-dash-column="acheteur_nom"]' + ).text + == "Acheteur 1" + ) From cb885eeb90f339411cd3348d23329b0398baddb6 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 4 Feb 2026 23:58:47 +0100 Subject: [PATCH 29/36] Utilisation des options de persistence de Dash --- src/figures.py | 1 - src/pages/tableau.py | 26 +++++++++----------------- src/utils.py | 2 +- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/figures.py b/src/figures.py index c5eed34..fb07e7d 100644 --- a/src/figures.py +++ b/src/figures.py @@ -330,7 +330,6 @@ class DataTable(dash_table.DataTable): }, sort_action=sort_action, sort_mode="multi", - sort_by=[], row_deletable=False, page_current=0, style_cell_conditional=style_cell_conditional, diff --git a/src/pages/tableau.py b/src/pages/tableau.py index e44ae78..7b9f913 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -55,6 +55,9 @@ datatable = html.Div( className="marches_table", children=DataTable( dtid="tableau_datatable", + persisted_props=["filter_query", "sort_by"], + persistence_type="local", + persistence=True, page_size=20, page_action="custom", filter_action="custom", @@ -68,8 +71,6 @@ layout = [ dcc.Location(id="tableau_url", refresh=False), dcc.Store(id="filter-cleanup-trigger-tableau"), dcc.Store(id="tableau-hidden-columns", storage_type="local"), - dcc.Store(id="tableau-filters", storage_type="local"), - dcc.Store(id="tableau-sort", storage_type="local"), dcc.Store(id="tableau-table"), html.Script( type="application/ld+json", @@ -285,14 +286,15 @@ layout = [ Output("btn-download-data", "children"), Output("btn-download-data", "title"), Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True), + Input("tableau_url", "href"), Input("tableau_datatable", "page_current"), Input("tableau_datatable", "page_size"), - Input("tableau-filters", "data"), - Input("tableau-sort", "data"), + Input("tableau_datatable", "filter_query"), + Input("tableau_datatable", "sort_by"), State("tableau_datatable", "data_timestamp"), prevent_initial_call=True, ) -def update_table(page_current, page_size, filter_query, sort_by, data_timestamp): +def update_table(href, page_current, page_size, filter_query, sort_by, data_timestamp): # if ctx.triggered_id != "url": # search_params = None # else: @@ -337,8 +339,8 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): Output("tableau_url", "search"), Output("filter-cleanup-trigger-tableau", "data"), Input("tableau_url", "search"), - State("tableau-filters", "data"), - State("tableau-sort", "data"), + State("tableau_datatable", "filter_query"), + State("tableau_datatable", "sort_by"), ) def restore_view_from_url(search, stored_filters, stored_sort): if not search and not stored_filters: @@ -512,16 +514,6 @@ def toggle_tableau_columns(click_open, click_close, is_open): return is_open -@callback(Output("tableau-filters", "data"), Input("tableau_datatable", "filter_query")) -def sync_filters_to_local_storage(filter_query): - return filter_query - - -@callback(Output("tableau-sort", "data"), Input("tableau_datatable", "sort_by")) -def sync_sort_to_local_storage(sort_by): - return sort_by - - @callback( Output("tableau_datatable", "filter_query", allow_duplicate=True), Output("tableau_datatable", "sort_by", allow_duplicate=True), diff --git a/src/utils.py b/src/utils.py index f7c80bf..15a94de 100644 --- a/src/utils.py +++ b/src/utils.py @@ -601,7 +601,7 @@ def prepare_table_data( trigger_cleanup = no_update if source_table == "tableau" else str(uuid.uuid4()) # Application des tris - if len(sort_by) > 0: + if sort_by and len(sort_by) > 0: lff = sort_table_data(lff, sort_by) # Matérialisation des filtres From 64fc40e5aa888af01ee98aec670e38929453997c Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 5 Feb 2026 14:18:38 +0100 Subject: [PATCH 30/36] Meilleure gestion des SIRET absents du SIRENE dans acheteur et titulaire --- src/pages/acheteur.py | 48 ++++++++++++++--------- src/pages/titulaire.py | 86 +++++++++++++++++++----------------------- src/utils.py | 11 ++++-- 3 files changed, 77 insertions(+), 68 deletions(-) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 0ca2253..8efb6bd 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -218,26 +218,40 @@ layout = [ ) def update_acheteur_infos(url): acheteur_siret = url.split("/")[-1] - if len(acheteur_siret) != 14: - acheteur_siret = ( - f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})" - ) + # if len(acheteur_siret) != 14: + # acheteur_siret = ( + # f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})" + # ) data = get_annuaire_data(acheteur_siret) - data_etablissement = data["matching_etablissements"][0] - acheteur_map = point_on_map( - data_etablissement["latitude"], data_etablissement["longitude"] - ) - code_departement, nom_departement, nom_region = get_departement_region( - data_etablissement["code_postal"] - ) - departement = f"{nom_departement} ({code_departement})" - lien_annuaire = ( - f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}" - ) + data_etablissement = data.get("matching_etablissements") if data else None + if data_etablissement: + data_etablissement = data_etablissement[0] + + acheteur_map = point_on_map( + data_etablissement["latitude"], data_etablissement["longitude"] + ) + code_departement, nom_departement, nom_region = get_departement_region( + data_etablissement["code_postal"] + ) + departement = f"{nom_departement} ({code_departement})" + lien_annuaire = ( + f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}" + ) + raison_sociale = data["nom_raison_sociale"] + libelle_commune = data_etablissement["libelle_commune"] + + else: + acheteur_map = html.Div() + code_departement, nom_departement, nom_region = "", "", "" + departement = "" + lien_annuaire = "" + raison_sociale = "" + libelle_commune = "" + return ( acheteur_siret, - data["nom_raison_sociale"], - data_etablissement["libelle_commune"], + raison_sociale, + libelle_commune, acheteur_map, departement, nom_region, diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index bd54a91..51937c4 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -56,6 +56,9 @@ datatable = html.Div( className="marches_table", children=DataTable( dtid="titulaire_datatable", + persistence=True, + persistence_type="local", + persisted_props=["filter_query", "sort_by"], page_action="custom", filter_action="custom", sort_action="custom", @@ -68,8 +71,6 @@ datatable = html.Div( layout = [ dcc.Store(id="titulaire_data", storage_type="memory"), dcc.Store(id="titulaire-hidden-columns", storage_type="local"), - dcc.Store(id="titulaire-filters", storage_type="local"), - dcc.Store(id="titulaire-sort", storage_type="local"), dcc.Store(id="filter-cleanup-trigger-titulaire"), dcc.Location(id="titulaire_url", refresh="callback-nav"), html.Div( @@ -217,26 +218,38 @@ layout = [ ) def update_titulaire_infos(url): titulaire_siret = url.split("/")[-1] - if len(titulaire_siret) != 14: - titulaire_siret = ( - f"Le SIRET renseigné doit faire 14 caractères ({titulaire_siret})" - ) data = get_annuaire_data(titulaire_siret) - data_etablissement = data["matching_etablissements"][0] - titulaire_map = point_on_map( - data_etablissement["latitude"], data_etablissement["longitude"] - ) - code_departement, nom_departement, nom_region = get_departement_region( - data_etablissement["code_postal"] - ) - departement = f"{nom_departement} ({code_departement})" - lien_annuaire = ( - f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}" - ) + data_etablissement = data.get("matching_etablissements") if data else None + if data_etablissement: + data_etablissement = data_etablissement[0] + + titulaire_map = point_on_map( + data_etablissement["latitude"], data_etablissement["longitude"] + ) + code_departement, nom_departement, nom_region = get_departement_region( + data_etablissement["code_postal"] + ) + departement = f"{nom_departement} ({code_departement})" + lien_annuaire = ( + f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}" + ) + raison_sociale = data["nom_raison_sociale"] + libelle_commune = data_etablissement["libelle_commune"] + + else: + titulaire_map = html.Div() + code_departement, nom_departement, nom_region = "", "", "" + departement = "" + lien_annuaire = "" + raison_sociale = html.Span( + f"N° SIREN inconnu de l'INSEE ({titulaire_siret[:9]})" + ) + libelle_commune = "" + return ( titulaire_siret, - data["nom_raison_sociale"], - data_etablissement["libelle_commune"], + raison_sociale, + libelle_commune, titulaire_map, departement, nom_region, @@ -314,16 +327,17 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> tuple: Output("btn-download-filtered-data-titulaire", "children"), Output("btn-download-filtered-data-titulaire", "title"), Output("filter-cleanup-trigger-titulaire", "data", allow_duplicate=True), + Input(component_id="titulaire_url", component_property="href"), Input("titulaire_data", "data"), Input("titulaire_datatable", "page_current"), Input("titulaire_datatable", "page_size"), - Input("titulaire-filters", "data"), - Input("titulaire-sort", "data"), + Input("titulaire_datatable", "filter_query"), + Input("titulaire_datatable", "sort_by"), State("titulaire_datatable", "data_timestamp"), config_prevent_initial_callbacks=True, ) def get_last_marches_data( - data, page_current, page_size, filter_query, sort_by, data_timestamp + href, data, page_current, page_size, filter_query, sort_by, data_timestamp ) -> list[dict]: return prepare_table_data( data, @@ -374,8 +388,8 @@ def download_titulaire_data( State("titulaire_data", "data"), Input("btn-download-filtered-data-titulaire", "n_clicks"), State("titulaire_nom", "children"), - State("titulaire-filters", "data"), - State("titulaire-sort", "data"), + State("titulaire_datatable", "filter_query"), + State("titulaire_datatable", "sort_by"), State("titulaire_datatable", "hidden_columns"), prevent_initial_call=True, ) @@ -469,30 +483,6 @@ def toggle_titulaire_columns(click_open, click_close, is_open): return is_open -@callback( - Output("titulaire-filters", "data"), Input("titulaire_datatable", "filter_query") -) -def sync_filters_to_local_storage(filter_query): - return filter_query - - -@callback(Output("titulaire-sort", "data"), Input("titulaire_datatable", "sort_by")) -def sync_sort_to_local_storage(sort_by): - return sort_by - - -@callback( - Output("titulaire_datatable", "filter_query", allow_duplicate=True), - Output("titulaire_datatable", "sort_by", allow_duplicate=True), - Input("titulaire_url", "href"), - State("titulaire-filters", "data"), - State("titulaire-sort", "data"), - prevent_initial_call=True, -) -def sync_local_storage_to_datatable(href, filter_query, sort_by): - return filter_query, sort_by - - @callback( Output("titulaire_datatable", "filter_query", allow_duplicate=True), Output("titulaire_datatable", "sort_by"), diff --git a/src/utils.py b/src/utils.py index 15a94de..c4c2c98 100644 --- a/src/utils.py +++ b/src/utils.py @@ -9,7 +9,7 @@ import dash import polars as pl import polars.selectors as cs from dash import no_update -from httpx import get, post +from httpx import HTTPError, get, post from polars.exceptions import ComputeError from unidecode import unidecode @@ -208,8 +208,13 @@ def format_values(dff: pl.DataFrame) -> pl.DataFrame: def get_annuaire_data(siret: str) -> dict: url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}" - response = get(url) - return response.json()["results"][0] + try: + response = get(url).raise_for_status() + response = response.json()["results"][0] + except (HTTPError, IndexError): + response = None + logger.warning("Could not fetch data from recherche-entreprises.api.") + return response def get_decp_data() -> pl.DataFrame: From 37e893d6425c2346f0e07e851146289e81140b2a Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 5 Feb 2026 14:54:30 +0100 Subject: [PATCH 31/36] Utilisation de la persistence sur /acheteurs --- src/pages/acheteur.py | 46 +++++++++++-------------------------------- 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 8efb6bd..5ba9f83 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -12,7 +12,6 @@ from dash import ( clientside_callback, dcc, html, - no_update, register_page, ) @@ -58,6 +57,9 @@ datatable = html.Div( className="marches_table", children=DataTable( dtid="acheteur_datatable", + persistence=True, + persistence_type="local", + persisted_props=["filter_query", "sort_by"], page_action="custom", filter_action="custom", sort_action="custom", @@ -70,8 +72,6 @@ datatable = html.Div( layout = [ dcc.Store(id="acheteur_data", storage_type="memory"), dcc.Store(id="acheteur-hidden-columns", storage_type="local"), - dcc.Store(id="acheteur-filters", storage_type="local"), - dcc.Store(id="acheteur-sort", storage_type="local"), dcc.Store(id="filter-cleanup-trigger-acheteur"), dcc.Location(id="acheteur_url", refresh="callback-nav"), html.Div( @@ -320,16 +320,17 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> tuple: Output("btn-download-filtered-data-acheteur", "children"), Output("btn-download-filtered-data-acheteur", "title"), Output("filter-cleanup-trigger-acheteur", "data", allow_duplicate=True), + Input("acheteur_url", "href"), Input("acheteur_data", "data"), Input("acheteur_datatable", "page_current"), Input("acheteur_datatable", "page_size"), - Input("acheteur-filters", "data"), - Input("acheteur-sort", "data"), + Input("acheteur_datatable", "filter_query"), + Input("acheteur_datatable", "sort_by"), State("acheteur_datatable", "data_timestamp"), prevent_initial_call=True, ) def get_last_marches_data( - data, page_current, page_size, filter_query, sort_by, data_timestamp + href, data, page_current, page_size, filter_query, sort_by, data_timestamp ) -> tuple: return prepare_table_data( data, data_timestamp, filter_query, page_current, page_size, sort_by, "acheteur" @@ -374,8 +375,8 @@ def download_acheteur_data( State("acheteur_data", "data"), Input("btn-download-filtered-data-acheteur", "n_clicks"), State("acheteur_nom", "children"), - State("acheteur-filters", "data"), - State("acheteur-sort", "data"), + State("acheteur_datatable", "filter_query"), + State("acheteur_datatable", "sort_by"), State("acheteur_datatable", "hidden_columns"), prevent_initial_call=True, ) @@ -422,11 +423,10 @@ clientside_callback( Output("acheteur-hidden-columns", "data", allow_duplicate=True), Output("filter-cleanup-trigger-acheteur", "data", allow_duplicate=True), Input("acheteur_column_list", "selected_rows"), - State("acheteur-filters", "data"), prevent_initial_call=True, ) -def update_hidden_columns_from_checkboxes(selected_columns, filter_query): - trigger_cleanup = str(uuid.uuid4()) if filter_query else no_update +def update_hidden_columns_from_checkboxes(selected_columns): + trigger_cleanup = str(uuid.uuid4()) 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] @@ -472,30 +472,6 @@ def toggle_acheteur_columns(click_open, click_close, is_open): return is_open -@callback( - Output("acheteur-filters", "data"), Input("acheteur_datatable", "filter_query") -) -def sync_filters_to_local_storage(filter_query): - return filter_query - - -@callback(Output("acheteur-sort", "data"), Input("acheteur_datatable", "sort_by")) -def sync_sort_to_local_storage(sort_by): - return sort_by - - -@callback( - Output("acheteur_datatable", "filter_query", allow_duplicate=True), - Output("acheteur_datatable", "sort_by", allow_duplicate=True), - Input("acheteur_url", "href"), - State("acheteur-filters", "data"), - State("acheteur-sort", "data"), - prevent_initial_call=True, -) -def sync_local_storage_to_datatable(href, filter_query, sort_by): - return filter_query, sort_by - - @callback( Output("acheteur_datatable", "filter_query", allow_duplicate=True), Output("acheteur_datatable", "sort_by"), From 3beedbcfe4915da9d0a62869da5786f67aaed4c7 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 5 Feb 2026 15:05:37 +0100 Subject: [PATCH 32/36] Test de la persistence des fitres (toutes pages) --- src/pages/acheteur.py | 7 ++--- tests/conftest.py | 13 +++++++-- tests/test_main.py | 66 ++++++++++++++++++++++++++++++++----------- 3 files changed, 62 insertions(+), 24 deletions(-) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 5ba9f83..1a3e915 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -1,5 +1,4 @@ import datetime -import uuid import dash_bootstrap_components as dbc import polars as pl @@ -421,18 +420,16 @@ clientside_callback( @callback( Output("acheteur-hidden-columns", "data", allow_duplicate=True), - Output("filter-cleanup-trigger-acheteur", "data", allow_duplicate=True), Input("acheteur_column_list", "selected_rows"), prevent_initial_call=True, ) def update_hidden_columns_from_checkboxes(selected_columns): - trigger_cleanup = str(uuid.uuid4()) 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, trigger_cleanup + return hidden_columns else: - return [], trigger_cleanup + return [] @callback( diff --git a/tests/conftest.py b/tests/conftest.py index cd58181..7531524 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import os import polars as pl import pytest +from selenium.webdriver.chrome.options import Options @pytest.fixture(scope="session", autouse=True) @@ -10,9 +11,10 @@ def test_data(): data = [ { "uid": "1", - "acheteur_nom": "Acheteur 1", + "id": "1", + "acheteur_nom": "ACHETEUR 1", "acheteur_id": "a1", - "titulaire_nom": "Titulaire 1", + "titulaire_nom": "TITULAIRE 1", "titulaire_id": "t1", "montant": 10, "dateNotification": datetime.date(2025, 1, 1), @@ -24,6 +26,7 @@ def test_data(): "titulaire_departement_code": "35", "titulaire_departement_nom": "Ille-et-Vilaine", "titulaire_commune_nom": "Rennes", + "titulaire_distance": 10, "titulaire_typeIdentifiant": "SIRET", "objet": "Objet test", "dureeRestanteMois": 12, @@ -39,3 +42,9 @@ def test_data(): pl.DataFrame(data).write_parquet("tests/test.parquet") yield path + + +def pytest_setup_options(): + options = Options() + options.add_argument("--window-size=1200,800") + return options diff --git a/tests/test_main.py b/tests/test_main.py index 8bac293..3d1920a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,3 +1,5 @@ +from time import sleep + from dash.testing.composite import DashComposite from selenium.webdriver import Keys from selenium.webdriver.common.by import By @@ -10,22 +12,52 @@ def test_001_logo_and_search(dash_duo: DashComposite): dash_duo.start_server(app) dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4) assert dash_duo.find_element(".logo > h1").text == "decp.info" - search_bar: WebElement = dash_duo.find_element("#search") - search_bar.click() - search_bar.send_keys("A") - search_bar.send_keys(Keys.ENTER) - dash_duo.wait_for_element("#results_acheteur_datatable") - result_table_acheteurs: WebElement = dash_duo.find_element( - "#results_acheteur_datatable tbody" - ) + for org_type in ["acheteur", "titulaire"]: + name = f"{org_type.upper()} 1" + search_bar: WebElement = dash_duo.find_element("#search") - assert ( - len(result_table_acheteurs.find_elements(by=By.TAG_NAME, value="tr")) == 2 - ) # header row + 1 result - assert ( - result_table_acheteurs.find_element( - by=By.CSS_SELECTOR, value='td[data-dash-column="acheteur_nom"]' - ).text - == "Acheteur 1" - ) + dash_duo.clear_input(search_bar) + + search_bar.send_keys(name) + search_bar.send_keys(Keys.ENTER) + + dash_duo.wait_for_element(f"#results_{org_type}_datatable", timeout=2) + result_table: WebElement = dash_duo.find_element( + f"#results_{org_type}_datatable tbody" + ) + + assert len(result_table.find_elements(by=By.TAG_NAME, value="tr")) == 2, ( + "The search should return only one result" + ) # header row + 1 result + assert ( + result_table.find_element( + by=By.CSS_SELECTOR, value=f'td[data-dash-column="{org_type}_nom"]' + ).text + == name + ), f"The search result should have the right {org_type} name" + + +def test_002_filter_persistence(dash_duo: DashComposite): + from src.app import app + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4) + + def open_page_and_check_filter_input(): + dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}") + filter_input_selector = ( + '.marches_table th[data-dash-column="uid"] input[type="text"]' + ) + dash_duo.wait_for_element(filter_input_selector, timeout=2) + _filter_input: WebElement = dash_duo.find_element(filter_input_selector) + return _filter_input + + for page in ["tableau", "acheteurs/a1", "titulaires/t1"]: + print("page:", page) + filter_input = open_page_and_check_filter_input() + filter_input.send_keys("11") # a UID that doesn't exist + filter_input.send_keys(Keys.ENTER) + sleep(1) + filter_input = open_page_and_check_filter_input() + assert filter_input.get_attribute("value") == "11" From 472fbb7cbbd261ebe2fd9b9997cb2ac1c1a48b38 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 5 Feb 2026 17:16:31 +0100 Subject: [PATCH 33/36] =?UTF-8?q?Am=C3=A9liorations=20des=20textes=20et=20?= =?UTF-8?q?petits=20ajustements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/css/style.css | 12 ++++++++++++ src/figures.py | 2 +- src/pages/tableau.py | 32 +++++++++++++++++++++++++------- src/utils.py | 11 ++++++++++- tests/test_main.py | 3 --- 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index ccb63d0..2fd4da3 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -301,6 +301,15 @@ table.cell-table th { vertical-align: center; } +.dash-table-container + .dash-spreadsheet-container + .dash-spreadsheet-inner + .cell-table + .dash-filter + input[type="text"]::placeholder { + color: #999; +} + .dash-filter--case { display: none; } @@ -375,6 +384,9 @@ button.show-hide { margin-right: 10px; } */ +#btn-copy-url:before { +} + /* Dropdowns */ .Select-placeholder { color: #333 !important; diff --git a/src/figures.py b/src/figures.py index fb07e7d..e95b779 100644 --- a/src/figures.py +++ b/src/figures.py @@ -326,7 +326,7 @@ class DataTable(dash_table.DataTable): page_action=page_action, filter_options={ "case": "insensitive", - "placeholder_text": "", + "placeholder_text": "Filtre de colonne...", }, sort_action=sort_action, sort_mode="multi", diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 7b9f913..920fc20 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -130,7 +130,7 @@ layout = [ ], ), dcc.Markdown( - f"Ce tableau vous permet d'appliquer un filtre sur une ou plusieurs colonnes, et ainsi produire la liste de marchés dont vous avez besoin ([exemple de filtre](/tableau?filtres=%7Bacheteur_id%7D+icontains+24350013900189+%26%26+%7BdateNotification%7D+icontains+2025%2A+%26%26+%7Bmontant%7D+i%3C+40000+%26%26+%7Bobjet%7D+icontains+voirie&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2Cacheteur_departement_code%2CsourceDataset)). Par défaut seules quelques colonnes sont affichées, mais vous pouvez en afficher jusqu'à {str(df.width)} en cliquant sur le bouton **Colonnes affichées**. Cet outil est assez puissant, je vous recommande de lire le mode d'emploi pour en tirer pleinement partie.", + f"Ce tableau contient tous les marchés attribués en France. Il vous permet d'appliquer un filtre sur une ou plusieurs colonnes, et ainsi produire la liste de marchés dont vous avez besoin (exemples : [marchés de voirie < 40 k€ en 2025](/tableau?filtres=%7Bacheteur_id%7D+icontains+24350013900189+%26%26+%7BdateNotification%7D+icontains+2025%2A+%26%26+%7Bmontant%7D+i%3C+40000+%26%26+%7Bobjet%7D+icontains+voirie&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2Cacheteur_departement_code%2CsourceDataset), [marchés > 500 k€ avec clause sociale attribués à des PME à plus de 100 km dans le Var](/tableau?filtres=%7Btitulaire_categorie%7D+icontains+PME+%26%26+%7Btitulaire_distance%7D+i%3E+100+%26%26+%7Bmontant%7D+i%3E+500000+%26%26+%7Bacheteur_departement_code%7D+icontains+83+%26%26+%7BconsiderationsSociales%7D+icontains+clause&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2CconsiderationsSociales%2Ctitulaire_distance%2Cacheteur_departement_code%2Ctitulaire_categorie%2CsourceDataset)). Par défaut seules quelques colonnes sont affichées, mais vous pouvez en afficher jusqu'à {str(df.width)} en cliquant sur le bouton **Choisir les colonnes**. Cet outil est assez puissant, je vous recommande de lire le mode d'emploi pour en tirer pleinement partie.", style={"maxWidth": "1000px"}, ), html.Div( @@ -148,7 +148,7 @@ layout = [ dbc.Button("Mode d'emploi", id="tableau_help_open"), dbc.Modal( [ - dbc.ModalHeader(dbc.ModalTitle("Header")), + dbc.ModalHeader(dbc.ModalTitle("Mode d'emploi")), dbc.ModalBody( dcc.Markdown( dangerously_allow_html=True, @@ -157,6 +157,10 @@ layout = [ Pour voir la définition d'une colonne, passez votre souris sur son en-tête. + ##### Vos réglages sont persistents + + Les filtres, les tris et le choix de colonnes sont automatiquement enregistrés dans votre navigateur et persistent même si vous changez de page ou si vous fermez votre navigateur. À votre retour, vous retrouverez cette page comme vous l'avez laissée. + ##### Appliquer des filtres Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`. @@ -174,7 +178,7 @@ layout = [ - pour chercher du texte qui **commence par** votre texte, entrez `texte*`. C'est par exemple utile pour filtrer des acheteurs ou titulaires par numéro SIREN (`123456789*`) ou les marchés sur une année en particulier (`2024*`) - pour chercher du texte qui **finit par** votre texte, entrez `*texte` - Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page. + Vous pouvez filtrer plusieurs colonnes à la fois. ##### Trier les données @@ -188,11 +192,11 @@ layout = [ Par défaut, un nombre réduit de colonnes est affiché pour ne pas surcharger la page. Mais vous avez le choix parmi {str(df.width)} colonnes, ce serait dommage de vous limiter ! - Pour afficher plus de colonnes, cliquez sur le bouton **Colonnes affichées** et cochez les colonnes pour les afficher. + Pour afficher plus de colonnes, cliquez sur le bouton **Choisir les colonnes** et cochez les colonnes pour les afficher. ##### Partager une vue - Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqués. Cliquez sur l'icône drawing pour copier une adresse Web qui reproduit la vue courante à l'identique : en la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres. + Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqués. Cliquez sur **Partager** pour copier une adresse Web qui reproduit la vue courante à l'identique : en la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres. Pratique pour partager une vue avec un·e collègue, sur les réseaux sociaux, ou la sauvegarder pour plus tard. @@ -225,9 +229,10 @@ layout = [ ), # Bouton modal des colonnes affichées dbc.Button( - "Colonnes affichées", + "Choisir les colonnes", id="tableau_columns_open", className="column_list", + title="Choisir les colonnes à afficher et masquer", ), html.P("lignes", id="nb_rows"), html.Div(id="copy-container"), @@ -241,7 +246,7 @@ layout = [ dcc.Store(id="filtered_data", storage_type="memory"), html.P("Données mises à jour le " + str(update_date)), dbc.Button( - "Remise à zéro", + "Remettre à zéro", title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.", id="btn-tableau-reset", ), @@ -434,6 +439,13 @@ def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href): "cursor": "pointer", }, className="fa fa-link", + children=[ + dbc.Button( + "Partager", + className="btn btn-primary", + title="Copier l'adresse de cette vue (filtres, tris, choix de colonnes) pour la partager.", + ) + ], ) return full_url, copy_button @@ -527,3 +539,9 @@ def reset_view(n_clicks): @callback(Input("tableau_url", "pathname")) def cb_add_canonical_link(pathname): add_canonical_link(pathname) + + +# @callback(Input("tableau_url", "pathname"), Output("btn-copy-url", "children")) +# def cb_add_canonical_link(pathname): +# add_canonical_link(pathname) +# diff --git a/src/utils.py b/src/utils.py index c4c2c98..ccd50ef 100644 --- a/src/utils.py +++ b/src/utils.py @@ -242,6 +242,15 @@ def get_decp_data() -> pl.DataFrame: # Convertir les colonnes booléennes en chaînes de caractères lff = booleans_to_strings(lff) + # Mention pour les org dont on a pas le nom + for col in ["acheteur_nom", "titulaire_nom"]: + lff = lff.with_columns( + pl.when(pl.col(col).is_null()) + .then(pl.lit("[Identifiant non reconnu dans la base INSEE]")) + .otherwise(pl.col(col)) + .name.keep() + ) + # Bizarrement je ne peux pas faire lff = lff.fill_null("") ici # ça génère une erreur dans la page acheteur (acheteur_data.table) : # AttributeError: partially initialized module 'pandas' has no attribute 'NaT' (most likely due to a circular import) @@ -673,7 +682,7 @@ def get_button_properties(height): else: download_disabled = False download_text = "Télécharger au format Excel" - download_title = "" + download_title = "Télécharger les données telles qu'affichées au format Excel" return download_disabled, download_text, download_title diff --git a/tests/test_main.py b/tests/test_main.py index 3d1920a..453a806 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,3 @@ -from time import sleep - from dash.testing.composite import DashComposite from selenium.webdriver import Keys from selenium.webdriver.common.by import By @@ -58,6 +56,5 @@ def test_002_filter_persistence(dash_duo: DashComposite): filter_input = open_page_and_check_filter_input() filter_input.send_keys("11") # a UID that doesn't exist filter_input.send_keys(Keys.ENTER) - sleep(1) filter_input = open_page_and_check_filter_input() assert filter_input.get_attribute("value") == "11" From b303a8bea67fe9de8adab325e896cd1a8a89eda5 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 5 Feb 2026 17:29:54 +0100 Subject: [PATCH 34/36] Bump version 2.6.0 et changelog --- CHANGELOG.md | 8 ++++++++ README.md | 2 +- pyproject.toml | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52515f9..b82194d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +#### 2.6.0 (5 février 2026) + +- Suite de la refonte graphique +- Persistence des filtres, des tris et des choix de colonnes sur toutes les pages +- Joli tableau pour choisir les colonnes à afficher +- Meilleure gestion des acheteurs et titulaires absents de la base SIRENE +- Amélioration du SEO (liens canoniques) + ##### 2.5.1 (29 janvier 2026) - Mise en production un peu hâtive ([#67](https://github.com/ColinMaudry/decp.info/issues/67), [#68](https://github.com/ColinMaudry/decp.info/issues/68)) diff --git a/README.md b/README.md index f710191..ecfa81b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # decp.info -> v2.5.1 +> v2.6.0 > Outil d'exploration et de téléchargement des données essentielles de la commande publique. => [decp.info](https://decp.info) diff --git a/pyproject.toml b/pyproject.toml index 261b9bb..35749a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "decp.info" description = "Interface d'exploration et d'analyse des marchés publics français." -version = "2.5.1" +version = "2.6.0" requires-python = ">= 3.10" authors = [ { name = "Colin Maudry", email = "colin@colmo.tech" } From eb5be1972e7a02a7368ebbf2834ad45ea4ac8f7f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 5 Feb 2026 18:10:20 +0100 Subject: [PATCH 35/36] Correction de soucis de sauvegarde des colonnes dans acheteur.py --- src/pages/acheteur.py | 3 +-- src/pages/titulaire.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 1a3e915..90e0fe6 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -318,7 +318,7 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> tuple: Output("btn-download-filtered-data-acheteur", "disabled"), Output("btn-download-filtered-data-acheteur", "children"), Output("btn-download-filtered-data-acheteur", "title"), - Output("filter-cleanup-trigger-acheteur", "data", allow_duplicate=True), + Output("filter-cleanup-trigger-acheteur", "data"), Input("acheteur_url", "href"), Input("acheteur_data", "data"), Input("acheteur_datatable", "page_current"), @@ -326,7 +326,6 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> tuple: Input("acheteur_datatable", "filter_query"), Input("acheteur_datatable", "sort_by"), State("acheteur_datatable", "data_timestamp"), - prevent_initial_call=True, ) def get_last_marches_data( href, data, page_current, page_size, filter_query, sort_by, data_timestamp diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index 51937c4..23c3cf6 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -326,7 +326,7 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> tuple: Output("btn-download-filtered-data-titulaire", "disabled"), Output("btn-download-filtered-data-titulaire", "children"), Output("btn-download-filtered-data-titulaire", "title"), - Output("filter-cleanup-trigger-titulaire", "data", allow_duplicate=True), + Output("filter-cleanup-trigger-titulaire", "data"), Input(component_id="titulaire_url", component_property="href"), Input("titulaire_data", "data"), Input("titulaire_datatable", "page_current"), @@ -334,7 +334,6 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> tuple: Input("titulaire_datatable", "filter_query"), Input("titulaire_datatable", "sort_by"), State("titulaire_datatable", "data_timestamp"), - config_prevent_initial_callbacks=True, ) def get_last_marches_data( href, data, page_current, page_size, filter_query, sort_by, data_timestamp From af89bb0630cf5842ec4a13f1a7bb8020d7f9bf6f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 5 Feb 2026 18:22:17 +0100 Subject: [PATCH 36/36] =?UTF-8?q?Plus=20grosses=20cases=20=C3=A0=20cocher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/css/style.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 2fd4da3..e76e1f2 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -392,6 +392,13 @@ button.show-hide { color: #333 !important; } +/* Checkboxes */ + +input[type="checkbox"] { + height: 17px; + width: 17px; +} + /* Tooltips */ .dash-tooltip, .dash-table-tooltip {