From 503ec33f433811b4cc265f3c29f3a6302ad5bd64 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 15 Nov 2025 21:00:28 +0100 Subject: [PATCH 01/16] =?UTF-8?q?Classe=20partag=C3=A9e=20pour=20la=20g?= =?UTF-8?q?=C3=A9n=C3=A9ration=20de=20datatables=20#56?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/callbacks.py | 39 ++++-------------------- src/figures.py | 67 ++++++++++++++++++++++++++++++++++++++++++ src/pages/acheteur.py | 31 +++---------------- src/pages/tableau.py | 45 ++++------------------------ src/pages/titulaire.py | 31 +++---------------- 5 files changed, 85 insertions(+), 128 deletions(-) diff --git a/src/callbacks.py b/src/callbacks.py index 6bf197e..6b12013 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -1,6 +1,7 @@ import polars as pl -from dash import dash_table, html +from dash import html +from src.figures import DataTable from utils import add_links_in_dict, format_values, setup_table_columns @@ -15,7 +16,7 @@ def get_top_org_table(data, org_type: str): dff_nb = dff.group_by(f"{org_type}_id", f"{org_type}_nom", "distance").agg( pl.len().alias("Attributions"), pl.sum("montant").alias("montant") ) - dff_nb = dff_nb.sort(by="montant", descending=True) + dff_nb = dff_nb.sort(by="montant", descending=True, nulls_last=True) dff_nb = dff_nb.cast(pl.String) dff_nb = dff_nb.fill_null("") dff_nb = format_values(dff_nb) @@ -25,41 +26,11 @@ def get_top_org_table(data, org_type: str): data = dff_nb.to_dicts() data = add_links_in_dict(data, f"{org_type}") - return dash_table.DataTable( + return DataTable( + dtid=f"top10_{org_type}", data=data, - markdown_options={"html": True}, page_action="native", page_size=10, columns=columns, - cell_selectable=False, tooltip_header=tooltip, - style_cell_conditional=[ - { - "if": {"column_id": "objet"}, - "minWidth": "350px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "14px", - "whiteSpace": "normal", - "fontSize": "85%", - }, - { - "if": {"column_id": "acheteur_nom"}, - "minWidth": "200px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "16px", - # "fontSize": "85%", - "whiteSpace": "normal", - }, - { - "if": {"column_id": "titulaire_nom"}, - "minWidth": "200px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "16px", - "whiteSpace": "normal", - # "fontSize": "85%", - }, - ], ) diff --git a/src/figures.py b/src/figures.py index ec5eda1..8796e96 100644 --- a/src/figures.py +++ b/src/figures.py @@ -1,4 +1,5 @@ import json +from typing import Literal import plotly.express as px import polars as pl @@ -192,3 +193,69 @@ def point_on_map(lat, lon): graph = dcc.Graph(id="map", figure=fig) return graph + + +class DataTable(dash_table.DataTable): + def __init__( + self, + dtid: str, + hidden_columns: list = None, + data=None, + columns: list = None, + page_size: int = 20, + page_action: Literal["native", "custom", "none"] = "native", + sort_action: Literal["native", "custom", "none"] = "native", + filter_action: Literal["native", "custom", "none"] = "native", + **kwargs, + ): + # Styles de base + style_cell_conditional = [ + { + "if": {"column_id": "objet"}, + "minWidth": "350px", + "textAlign": "left", + "overflow": "hidden", + "lineHeight": "18px", + "whiteSpace": "normal", + }, + { + "if": {"column_id": "acheteur_nom"}, + "minWidth": "250px", + "textAlign": "left", + "overflow": "hidden", + "lineHeight": "18px", + "whiteSpace": "normal", + }, + { + "if": {"column_id": "titulaire_nom"}, + "minWidth": "250px", + "textAlign": "left", + "overflow": "hidden", + "lineHeight": "18px", + "whiteSpace": "normal", + }, + ] + + # Initialisation de la classe parente avec les arguments + super().__init__( + id=dtid, + data=data, + columns=columns, + cell_selectable=False, + page_size=page_size, + filter_action=filter_action, + page_action=page_action, + filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."}, + sort_action=sort_action, + sort_mode="multi", + sort_by=[], + row_deletable=False, + page_current=0, + style_cell_conditional=style_cell_conditional, + data_timestamp=0, + markdown_options={"html": True}, + tooltip_duration=8000, + tooltip_delay=350, + hidden_columns=hidden_columns, + **kwargs, # Possibilité de remplacer des arguments + ) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 87ddd05..0213f67 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -1,10 +1,10 @@ import datetime import polars as pl -from dash import Input, Output, State, callback, dash_table, dcc, html, register_page +from dash import Input, Output, State, callback, dcc, html, register_page from src.callbacks import get_top_org_table -from src.figures import point_on_map +from src.figures import DataTable, point_on_map from src.utils import ( add_links_in_dict, df, @@ -227,37 +227,14 @@ def get_last_marches_table(data) -> html.Div: table = html.Div( className="marches_table", - id="acheteur_datatable", - children=dash_table.DataTable( + children=DataTable( + dtid="acheteur_datatable", data=data, - markdown_options={"html": True}, page_action="native", filter_action="native", - filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."}, columns=columns, tooltip_header=tooltip, - tooltip_duration=8000, - tooltip_delay=350, - cell_selectable=False, page_size=10, - style_cell_conditional=[ - { - "if": {"column_id": "objet"}, - "minWidth": "300px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - }, - { - "if": {"column_id": "titulaire_nom"}, - "minWidth": "200px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - }, - ], ), ) return table diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 972a240..ab3f712 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -2,8 +2,9 @@ import os from datetime import datetime import polars as pl -from dash import Input, Output, State, callback, dash_table, dcc, html, register_page +from dash import Input, Output, State, callback, dcc, html, register_page +from src.figures import DataTable from src.utils import ( add_links, add_resource_link, @@ -35,48 +36,12 @@ register_page( datatable = html.Div( className="marches_table", - children=dash_table.DataTable( - cell_selectable=False, - id="table", + children=DataTable( + dtid="table", page_size=20, - page_current=0, page_action="custom", filter_action="custom", - filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."}, sort_action="custom", - sort_mode="multi", - sort_by=[], - row_deletable=False, - style_cell_conditional=[ - { - "if": {"column_id": "objet"}, - "minWidth": "350px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - }, - { - "if": {"column_id": "acheteur_nom"}, - "minWidth": "250px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - }, - { - "if": {"column_id": "titulaire_nom"}, - "minWidth": "250px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - }, - ], - data_timestamp=0, - markdown_options={"html": True}, - tooltip_duration=8000, - tooltip_delay=350, hidden_columns=get_default_hidden_columns(schema), ), ) @@ -244,7 +209,7 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp) prevent_initial_call=True, ) def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): - lff: pl.LazyFrame = df # start from the original data + lff: pl.LazyFrame = df.lazy() # start from the original data # Les colonnes masquées sont supprimées if hidden_columns: diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index 3ebeaef..72b74de 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -1,10 +1,10 @@ import datetime import polars as pl -from dash import Input, Output, State, callback, dash_table, dcc, html, register_page +from dash import Input, Output, State, callback, dcc, html, register_page from src.callbacks import get_top_org_table -from src.figures import point_on_map +from src.figures import DataTable, point_on_map from src.utils import ( add_links_in_dict, df, @@ -239,37 +239,14 @@ def get_last_marches_table(data) -> html.Div: table = html.Div( className="marches_table", - id="titulaire_datatable", - children=dash_table.DataTable( + children=DataTable( + dtid="titulaire_data_table", data=data, - markdown_options={"html": True}, page_action="native", filter_action="native", - filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."}, columns=columns, tooltip_header=tooltip, - tooltip_duration=8000, - tooltip_delay=350, - cell_selectable=False, page_size=10, - style_cell_conditional=[ - { - "if": {"column_id": "objet"}, - "minWidth": "300px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - }, - { - "if": {"column_id": "acheteur_nom"}, - "maxWidth": "400px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - }, - ], ), ) return table From 6b133c21ab701356685a7fd91ce7aa67ad8cf632 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 15 Nov 2025 21:08:28 +0100 Subject: [PATCH 02/16] =?UTF-8?q?Classe=20partag=C3=A9e=20pour=20la=20g?= =?UTF-8?q?=C3=A9n=C3=A9ration=20de=20datatables=20#56?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/recherche.py | 29 ++++++----------------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/src/pages/recherche.py b/src/pages/recherche.py index 4ce7877..aceebb2 100644 --- a/src/pages/recherche.py +++ b/src/pages/recherche.py @@ -1,5 +1,6 @@ -from dash import Input, Output, callback, dash_table, dcc, html, register_page +from dash import Input, Output, callback, dcc, html, register_page +from src.figures import DataTable from src.utils import ( df_acheteurs, df_titulaires, @@ -73,31 +74,13 @@ def update_search_results(query): className=f"results_{org_type}", children=[ html.H3(f"{org_type.title()}s : {count}"), - dash_table.DataTable( + DataTable( + dtid=f"results_{org_type}_datatable", columns=columns, data=results.to_dicts(), page_size=10, - # style_table={"overflowX": "auto"}, - markdown_options={"html": True}, - cell_selectable=False, - style_cell_conditional=[ - { - "if": {"column_id": "acheteur_nom"}, - "maxWidth": "250px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - }, - { - "if": {"column_id": "titulaire_nom"}, - "maxWidth": "250px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - }, - ], + sort_action="none", + filter_action="none", ), ], ) From a51d2a83b4a1dd97d92f7de01bf0ab24e0f1da7b Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 18 Nov 2025 15:37:47 +0100 Subject: [PATCH 03/16] Nettoyage --- src/pages/recherche.py | 3 +-- src/pages/titulaire.py | 10 ---------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/pages/recherche.py b/src/pages/recherche.py index aceebb2..bf78231 100644 --- a/src/pages/recherche.py +++ b/src/pages/recherche.py @@ -90,5 +90,4 @@ def update_search_results(query): content.extend(org_content) return content - else: - return html.P("") + return html.P("") diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index 72b74de..95f8c8f 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -215,16 +215,6 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> list[dict]: Input(component_id="titulaire_data", component_property="data"), ) def get_last_marches_table(data) -> html.Div: - columns = [ - "uid", - "objet", - "dateNotification", - "acheteur_nom", - "montant", - "codeCPV", - "dureeMois", - ] - dff = pl.DataFrame(data) dff = dff.cast(pl.String) dff = dff.fill_null("") From 53919bff3ec062758c621186cd2592cc05c35843 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 24 Nov 2025 10:41:47 +0100 Subject: [PATCH 04/16] Cast acheteur_year to int et non dateNotification to string --- src/pages/acheteur.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 0213f67..ba58bdc 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -197,9 +197,8 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> list[dict]: "dureeMois", ) if acheteur_year and acheteur_year != "Toutes": - lff = lff.filter( - pl.col("dateNotification").cast(pl.String).str.starts_with(acheteur_year) - ) + acheteur_year = int(acheteur_year) + lff = lff.filter(pl.col("dateNotification").dt.year() == acheteur_year) lff = lff.sort(["dateNotification", "id"], descending=True, nulls_last=True) data = lff.collect(engine="streaming").to_dicts() From 565a0cfd08a4402c69d50ead4ae5ef36245e496f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 24 Nov 2025 11:17:05 +0100 Subject: [PATCH 05/16] Custom filter logic --- src/pages/acheteur.py | 51 ++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index ba58bdc..520230d 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -8,6 +8,7 @@ from src.figures import DataTable, point_on_map from src.utils import ( add_links_in_dict, df, + filter_table_data, format_number, format_values, get_annuaire_data, @@ -26,6 +27,16 @@ register_page( order=5, ) +datatable = html.Div( + className="marches_table", + children=DataTable( + dtid="acheteur_datatable", + page_action="native", + filter_action="custom", + page_size=10, + ), +) + layout = [ dcc.Store(id="acheteur_data", storage_type="memory"), dcc.Location(id="url", refresh="callback-nav"), @@ -101,7 +112,7 @@ layout = [ ), # récupérer les données de l'acheteur sur l'api annuaire html.H3("Derniers marchés publics attribués"), - html.Div(id="acheteur_last_marches", children=""), + html.Div(id="acheteur_last_marches", children=datatable), ], ), ] @@ -200,43 +211,37 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> list[dict]: acheteur_year = int(acheteur_year) lff = lff.filter(pl.col("dateNotification").dt.year() == acheteur_year) lff = lff.sort(["dateNotification", "id"], descending=True, nulls_last=True) + lff = lff.fill_null("") data = lff.collect(engine="streaming").to_dicts() return data @callback( - Output(component_id="acheteur_last_marches", component_property="children"), + Output(component_id="acheteur_datatable", component_property="data"), + Output(component_id="acheteur_datatable", component_property="columns"), + Output(component_id="acheteur_datatable", component_property="tooltip_header"), Input(component_id="acheteur_data", component_property="data"), + Input(component_id="acheteur_datatable", component_property="filter_query"), ) -def get_last_marches_table(data) -> html.Div: - dff = pl.DataFrame(data) - if dff.height == 0: - return html.Div(html.P("Aucun marché trouvé.")) - dff = dff.cast(pl.String) - dff = dff.fill_null("") - dff = format_values(dff) - columns, tooltip = setup_table_columns( +def get_last_marches_data(data, filter_query) -> tuple[list[dict], list, list]: + lff: pl.LazyFrame = pl.LazyFrame(data) + if filter_query: + lff = filter_table_data(lff, filter_query) + + lff = lff.cast(pl.String) + dff: pl.DataFrame = format_values(lff.collect()) + columns, tooltip_header = setup_table_columns( dff, hideable=False, exclude=["titulaire_id", "titulaire_typeIdentifiant", "uid"], ) + data = dff.to_dicts() + data = add_links_in_dict(data, "titulaire") - table = html.Div( - className="marches_table", - children=DataTable( - dtid="acheteur_datatable", - data=data, - page_action="native", - filter_action="native", - columns=columns, - tooltip_header=tooltip, - page_size=10, - ), - ) - return table + return data, columns, tooltip_header @callback( From 8c98a60c75463d6e37f0728a0b7fec38474f67f2 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 24 Nov 2025 13:43:42 +0100 Subject: [PATCH 06/16] filtres, tris fonctionnels (acheteur) --- src/assets/style.css | 8 ++- src/pages/acheteur.py | 89 +++++++++++++++------------- src/pages/tableau.py | 70 ++-------------------- src/utils.py | 134 ++++++++++++++++++++++++++++++++++++++---- 4 files changed, 180 insertions(+), 121 deletions(-) diff --git a/src/assets/style.css b/src/assets/style.css index c009a16..4f91b10 100644 --- a/src/assets/style.css +++ b/src/assets/style.css @@ -133,6 +133,12 @@ td[data-dash-column="objet"],td[data-dash-column="titulaire_nom"],td[data-dash-c .marches_table { font-family: "Open Sans", sans-serif; } + +.marches_table.stuck { + position: relative; + right: 200px; +} + .marches_table .cell-table tr:nth-child(even) td { background-color: #feeeee; font-family: "Open Sans", sans-serif; @@ -212,7 +218,7 @@ summary > h3 { } #_pages_content { - padding-top: 28px; + padding: 28px 24px 0 24px; } /* Vue acheteur/titulaire/recherche */ diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 520230d..36c3295 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -6,15 +6,13 @@ 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.utils import ( - add_links_in_dict, df, - filter_table_data, format_number, - format_values, get_annuaire_data, + get_default_hidden_columns, get_departement_region, meta_content, - setup_table_columns, + prepare_table_data, ) register_page( @@ -31,9 +29,11 @@ datatable = html.Div( className="marches_table", children=DataTable( dtid="acheteur_datatable", - page_action="native", + page_action="custom", filter_action="custom", + sort_action="custom", page_size=10, + hidden_columns=get_default_hidden_columns(page="acheteur"), ), ) @@ -41,7 +41,6 @@ layout = [ dcc.Store(id="acheteur_data", storage_type="memory"), dcc.Location(id="url", refresh="callback-nav"), html.Div( - className="container", children=[ html.Div( className="wrapper", @@ -112,7 +111,29 @@ layout = [ ), # récupérer les données de l'acheteur sur l'api annuaire html.H3("Derniers marchés publics attribués"), - html.Div(id="acheteur_last_marches", children=datatable), + dcc.Loading( + overlay_style={"visibility": "visible", "filter": "blur(2px)"}, + id="loading-home", + type="default", + children=[ + html.Div( + [ + html.P("lignes", id="acheteur_nb_rows"), + html.Button( + "Téléchargement désactivé au-delà de 65 000 lignes", + id="btn-download-data-acheteur", + disabled=True, + ), + dcc.Download(id="acheteur-download-data"), + dcc.Store( + id="acheteur_filtered_data", storage_type="memory" + ), + ], + className="table-menu", + ), + datatable, + ], + ), ], ), ] @@ -194,19 +215,6 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> list[dict]: acheteur_siret = url.split("/")[-1] lff = df.lazy() lff = lff.filter(pl.col("acheteur_id") == acheteur_siret) - lff = lff.select( - "id", - "uid", - "objet", - "dateNotification", - "titulaire_id", - "titulaire_typeIdentifiant", - "titulaire_nom", - "distance", - "montant", - "codeCPV", - "dureeMois", - ) if acheteur_year and acheteur_year != "Toutes": acheteur_year = int(acheteur_year) lff = lff.filter(pl.col("dateNotification").dt.year() == acheteur_year) @@ -218,31 +226,28 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> list[dict]: @callback( - Output(component_id="acheteur_datatable", component_property="data"), - Output(component_id="acheteur_datatable", component_property="columns"), - Output(component_id="acheteur_datatable", component_property="tooltip_header"), - Input(component_id="acheteur_data", component_property="data"), - Input(component_id="acheteur_datatable", component_property="filter_query"), + Output("acheteur_datatable", "data"), + Output("acheteur_datatable", "columns"), + Output("acheteur_datatable", "tooltip_header"), + Output("acheteur_datatable", "data_timestamp"), + Output("acheteur_nb_rows", "children"), + Output("btn-download-data-acheteur", "disabled"), + Output("btn-download-data-acheteur", "children"), + Output("btn-download-data-acheteur", "title"), + Input("acheteur_data", "data"), + Input("acheteur_datatable", "page_current"), + Input("acheteur_datatable", "page_size"), + Input("acheteur_datatable", "filter_query"), + Input("acheteur_datatable", "sort_by"), + State("acheteur_datatable", "data_timestamp"), ) -def get_last_marches_data(data, filter_query) -> tuple[list[dict], list, list]: - lff: pl.LazyFrame = pl.LazyFrame(data) - if filter_query: - lff = filter_table_data(lff, filter_query) - - lff = lff.cast(pl.String) - dff: pl.DataFrame = format_values(lff.collect()) - columns, tooltip_header = setup_table_columns( - dff, - hideable=False, - exclude=["titulaire_id", "titulaire_typeIdentifiant", "uid"], +def get_last_marches_data( + data, page_current, page_size, filter_query, sort_by, data_timestamp +) -> list[dict]: + return prepare_table_data( + data, data_timestamp, filter_query, page_current, page_size, sort_by ) - data = dff.to_dicts() - - data = add_links_in_dict(data, "titulaire") - - return data, columns, tooltip_header - @callback( Output(component_id="top10_titulaires", component_property="children"), diff --git a/src/pages/tableau.py b/src/pages/tableau.py index ab3f712..ba938b5 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -6,22 +6,17 @@ from dash import Input, Output, State, callback, dcc, html, register_page from src.figures import DataTable from src.utils import ( - add_links, - add_resource_link, df, filter_table_data, - format_number, - format_values, get_default_hidden_columns, meta_content, - setup_table_columns, sort_table_data, ) +from utils import prepare_table_data update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH")) update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y") -schema = df.collect_schema() name = "Tableau" register_page( @@ -42,7 +37,7 @@ datatable = html.Div( page_action="custom", filter_action="custom", sort_action="custom", - hidden_columns=get_default_hidden_columns(schema), + hidden_columns=get_default_hidden_columns(None), ), ) @@ -138,65 +133,8 @@ layout = [ State("table", "data_timestamp"), ) def update_table(page_current, page_size, filter_query, sort_by, data_timestamp): - if os.getenv("DEVELOPMENT").lower() == "true": - print(" + + + + + + + + + + + + + + + + + + ") - - # Application des filtres - lff: pl.LazyFrame = df.lazy() # start from the original data - if filter_query: - lff = filter_table_data(lff, filter_query) - - if len(sort_by) > 0: - lff = sort_table_data(lff, sort_by) - - # Matérialisation des filtres - dff: pl.DataFrame = lff.collect() - - height = dff.height - nb_rows = f"{format_number(height)} lignes ({format_number(dff.select('uid').unique().height)} marchés)" - - # Pagination des données - start_row = page_current * page_size - # end_row = (page_current + 1) * page_size - dff = dff.slice(start_row, page_size) - - # Tout devient string - dff = dff.cast(pl.String) - - # Remplace les strings null par "", mais pas les numeric null - dff = dff.fill_null("") - - # Ajout des liens vers l'annuaire des entreprises - dff = add_links(dff) - - # Ajout des liens vers les fichiers Open Data - dff = add_resource_link(dff) - - # Formatage des montants - dff = format_values(dff) - - columns, tooltip = setup_table_columns(dff) - - dicts = dff.to_dicts() - - if height > 65000: - download_disabled = True - download_text = "Téléchargement désactivé au-delà de 65 000 lignes" - download_title = "Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul. Contactez-moi pour me présenter votre besoin en téléchargement afin que je puisse adapter la solution." - else: - download_disabled = False - download_text = "Télécharger au format Excel" - download_title = "" - - return ( - dicts, - columns, - tooltip, - data_timestamp + 1, - nb_rows, - download_disabled, - download_text, - download_title, + return prepare_table_data( + None, data_timestamp, filter_query, page_current, page_size, sort_by ) diff --git a/src/utils.py b/src/utils.py index 084938c..d520f74 100644 --- a/src/utils.py +++ b/src/utils.py @@ -7,7 +7,6 @@ from time import localtime, sleep import polars as pl import polars.selectors as cs from httpx import get, post -from polars import Schema from polars.exceptions import ComputeError from unidecode import unidecode @@ -356,18 +355,47 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup return columns, tooltip -def get_default_hidden_columns(schema: Schema): - displayed_columns = os.getenv("DISPLAYED_COLUMNS") +def get_default_hidden_columns(page): + if page == "acheteur": + displayed_columns = [ + "uid", + "objet", + "dateNotification", + "titulaire_id", + "titulaire_typeIdentifiant", + "titulaire_nom", + "distance", + "montant", + "codeCPV", + "dureeRestanteMois", + ] + elif page == "titulaire": + displayed_columns = [ + "uid", + "objet", + "dateNotification", + "acheteur_id", + "acheteur_nom", + "distance", + "montant", + "codeCPV", + "dureeRestanteMois", + ] + 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(",") + hidden_columns = [] - if displayed_columns: - displayed_columns = displayed_columns.replace(" ", "").split(",") - for col in schema.names(): - if col in displayed_columns: - continue - else: - hidden_columns.append(col) - return hidden_columns - raise ValueError("DISPLAYED_COLUMNS n'est pas configuré") + + for col in schema.names(): + if col in displayed_columns: + continue + else: + hidden_columns.append(col) + return hidden_columns def get_data_schema() -> dict: @@ -503,9 +531,91 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame: return dff +def prepare_table_data( + data, data_timestamp, filter_query, page_current, page_size, sort_by +): + """ + Fonction de préparation des données pour les datatables, afin de permettre une gestion fine des logiques, + notamment pour les filtres et les tris. + :param data + :param data_timestamp: + :param filter_query: + :param page_current: + :param page_size: + :param sort_by: + :return: + """ + + if os.getenv("DEVELOPMENT").lower() == "true": + print(" + + + + + + + + + + + + + + + + + + ") + + # Récupération des données + if data: + lff: pl.LazyFrame = pl.LazyFrame(data) + else: + lff: pl.LazyFrame = df.lazy() # start from the original data + + # Application des filtres + if filter_query: + lff = filter_table_data(lff, filter_query) + + # Application des tris + if len(sort_by) > 0: + lff = sort_table_data(lff, sort_by) + + # Matérialisation des filtres + dff: pl.DataFrame = lff.collect() + height = dff.height + nb_rows = f"{format_number(height)} lignes ({format_number(dff.select('uid').unique().height)} marchés)" + + # Pagination des données + start_row = page_current * page_size + # end_row = (page_current + 1) * page_size + dff = dff.slice(start_row, page_size) + + # Tout devient string + dff = dff.cast(pl.String) + + # Remplace les strings null par "", mais pas les numeric null + dff = dff.fill_null("") + + # Ajout des liens vers l'annuaire des entreprises + dff = add_links(dff) + + # Ajout des liens vers les fichiers Open Data + if "sourceFile" in dff.columns: + dff = add_resource_link(dff) + + # Formatage des montants + dff = format_values(dff) + columns, tooltip = setup_table_columns(dff) + dicts = dff.to_dicts() + if height > 65000: + download_disabled = True + download_text = "Téléchargement désactivé au-delà de 65 000 lignes" + download_title = "Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul. Contactez-moi pour me présenter votre besoin en téléchargement afin que je puisse adapter la solution." + else: + download_disabled = False + download_text = "Télécharger au format Excel" + download_title = "" + return ( + dicts, + columns, + tooltip, + data_timestamp + 1, + nb_rows, + download_disabled, + download_text, + download_title, + ) + + df: pl.DataFrame = get_decp_data() +schema = df.collect_schema() + df_acheteurs = get_org_data(df, "acheteur") df_titulaires = get_org_data(df, "titulaire") + departements = get_departements() domain_name = ( "test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info" From ec1585240601f34a1b0e5e7697536abed87c27a8 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 24 Nov 2025 14:00:28 +0100 Subject: [PATCH 07/16] =?UTF-8?q?T=C3=A9l=C3=A9chargemetn=20filtr=C3=A9=20?= =?UTF-8?q?OK=20dans=20acheteur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/acheteur.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 36c3295..db9245e 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -7,12 +7,14 @@ from src.callbacks import get_top_org_table from src.figures import DataTable, point_on_map from src.utils import ( df, + filter_table_data, format_number, get_annuaire_data, get_default_hidden_columns, get_departement_region, meta_content, prepare_table_data, + sort_table_data, ) register_page( @@ -97,6 +99,7 @@ layout = [ id="btn-download-acheteur-data", ), dcc.Download(id="download-acheteur-data"), + dcc.Download(id="download-acheteur-data-filtered"), ], ), html.Div(className="org_map", id="acheteur_map"), @@ -280,3 +283,39 @@ def download_acheteur_data( date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") return dcc.send_bytes(to_bytes, filename=f"decp_{acheteur_nom}_{date}.xlsx") + + +@callback( + Output("download-acheteur-data-filtered", "data"), + State("acheteur_data", "data"), + Input("btn-download-data-acheteur", "n_clicks"), + State("acheteur_nom", "children"), + State("acheteur_datatable", "filter_query"), + State("acheteur_datatable", "sort_by"), + State("acheteur_datatable", "hidden_columns"), + prevent_initial_call=True, +) +def download_filtered_acheteur_data( + data, n_clicks, acheteur_nom, filter_query, sort_by, hidden_columns: list = None +): + lff: pl.LazyFrame = pl.LazyFrame( + data + ) # start from the full acheteur data, not from paginated table data + + # Les colonnes masquées sont supprimées + if hidden_columns: + lff = lff.drop(hidden_columns) + + if filter_query: + lff = filter_table_data(lff, filter_query) + + if len(sort_by) > 0: + lff = sort_table_data(lff, sort_by) + + def to_bytes(buffer): + lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP") + + date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") + return dcc.send_bytes( + to_bytes, filename=f"decp_filtrées_{acheteur_nom}_{date}.xlsx" + ) From 7f42ca67a22de8e0399cf302ceff8cc9d183e686 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 24 Nov 2025 15:33:27 +0100 Subject: [PATCH 08/16] =?UTF-8?q?Bonne=20configuration=20des=20boutons=20d?= =?UTF-8?q?e=20t=C3=A9l=C3=A9chargemetn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- pyproject.toml | 4 +- src/pages/acheteur.py | 41 +++++------ src/pages/titulaire.py | 157 +++++++++++++++++++++++++++-------------- src/utils.py | 31 +++++--- 5 files changed, 152 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 1d03d66..f31edfa 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ # decp.info -> v2.2.1 - -Outil d'exploration et de téléchargement des données essentielles de la commande publique. +> v2.3.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 d0c093f..5cd1e4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "decp.info" description = "Interface d'exploration et d'analyse des marchés publics français." -version = "2.2.1" +version = "2.3.0" requires-python = ">= 3.10" authors = [ { name = "Colin Maudry", email = "colin+decp@maudry.com" } ] dependencies = [ - "dash==3.2.0", + "dash==3.3.0", "dash[compress]", "polars", "gunicorn", diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index db9245e..8d2eca0 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -10,6 +10,7 @@ from src.utils import ( filter_table_data, format_number, get_annuaire_data, + get_button_properties, get_default_hidden_columns, get_departement_region, meta_content, @@ -96,10 +97,9 @@ layout = [ html.P(id="acheteur_titulaires_differents"), html.Button( "Téléchargement au format Excel", - id="btn-download-acheteur-data", + id="btn-download-data-acheteur", ), - dcc.Download(id="download-acheteur-data"), - dcc.Download(id="download-acheteur-data-filtered"), + dcc.Download(id="download-data-acheteur"), ], ), html.Div(className="org_map", id="acheteur_map"), @@ -124,13 +124,10 @@ layout = [ html.P("lignes", id="acheteur_nb_rows"), html.Button( "Téléchargement désactivé au-delà de 65 000 lignes", - id="btn-download-data-acheteur", + id="btn-download-filtered-data-acheteur", disabled=True, ), - dcc.Download(id="acheteur-download-data"), - dcc.Store( - id="acheteur_filtered_data", storage_type="memory" - ), + dcc.Download(id="acheteur-download-filtered-data"), ], className="table-menu", ), @@ -211,21 +208,25 @@ def update_acheteur_stats(data): @callback( Output(component_id="acheteur_data", component_property="data"), + Output("btn-download-data-acheteur", "disabled"), + Output("btn-download-data-acheteur", "children"), + Output("btn-download-data-acheteur", "title"), Input(component_id="url", component_property="pathname"), Input(component_id="acheteur_year", component_property="value"), ) -def get_acheteur_marches_data(url, acheteur_year: str) -> list[dict]: +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": acheteur_year = int(acheteur_year) lff = lff.filter(pl.col("dateNotification").dt.year() == acheteur_year) - lff = lff.sort(["dateNotification", "id"], descending=True, nulls_last=True) - lff = lff.fill_null("") + lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True) + dff: pl.DataFrame = lff.collect(engine="streaming") + download_disabled, download_text, download_title = get_button_properties(dff.height) - data = lff.collect(engine="streaming").to_dicts() - return data + data = dff.to_dicts() + return data, download_disabled, download_text, download_title @callback( @@ -234,9 +235,9 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> list[dict]: Output("acheteur_datatable", "tooltip_header"), Output("acheteur_datatable", "data_timestamp"), Output("acheteur_nb_rows", "children"), - Output("btn-download-data-acheteur", "disabled"), - Output("btn-download-data-acheteur", "children"), - Output("btn-download-data-acheteur", "title"), + Output("btn-download-filtered-data-acheteur", "disabled"), + Output("btn-download-filtered-data-acheteur", "children"), + Output("btn-download-filtered-data-acheteur", "title"), Input("acheteur_data", "data"), Input("acheteur_datatable", "page_current"), Input("acheteur_datatable", "page_size"), @@ -261,8 +262,8 @@ def get_top_titulaires(data): @callback( - Output("download-acheteur-data", "data"), - Input("btn-download-acheteur-data", "n_clicks"), + Output("download-data-acheteur", "data"), + Input("btn-download-data-acheteur", "n_clicks"), State(component_id="acheteur_data", component_property="data"), State(component_id="acheteur_nom", component_property="children"), State(component_id="acheteur_year", component_property="value"), @@ -286,9 +287,9 @@ def download_acheteur_data( @callback( - Output("download-acheteur-data-filtered", "data"), + Output("acheteur-download-filtered-data", "data"), State("acheteur_data", "data"), - Input("btn-download-data-acheteur", "n_clicks"), + Input("btn-download-filtered-data-acheteur", "n_clicks"), State("acheteur_nom", "children"), State("acheteur_datatable", "filter_query"), State("acheteur_datatable", "sort_by"), diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index 95f8c8f..ae1cca3 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -6,14 +6,16 @@ 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.utils import ( - add_links_in_dict, df, + filter_table_data, format_number, - format_values, get_annuaire_data, + get_button_properties, + get_default_hidden_columns, get_departement_region, meta_content, - setup_table_columns, + prepare_table_data, + sort_table_data, ) register_page( @@ -26,13 +28,22 @@ register_page( order=5, ) -# 21690123100011 +datatable = html.Div( + className="marches_table", + children=DataTable( + dtid="titulaire_datatable", + page_action="custom", + filter_action="custom", + sort_action="custom", + page_size=10, + hidden_columns=get_default_hidden_columns(page="titulaire"), + ), +) layout = [ dcc.Store(id="titulaire_data", storage_type="memory"), dcc.Location(id="url", refresh="callback-nav"), html.Div( - className="container", children=[ html.Div( className="wrapper", @@ -86,9 +97,9 @@ layout = [ html.P(id="titulaire_acheteurs_differents"), html.Button( "Téléchargement au format Excel", - id="btn-download-titulaire-data", + id="btn-download-data-titulaire", ), - dcc.Download(id="download-titulaire-data"), + dcc.Download(id="download-data-titulaire"), ], ), html.Div(className="org_map", id="titulaire_map"), @@ -103,7 +114,26 @@ layout = [ ), # récupérer les données de l'acheteur sur l'api annuaire html.H3("Derniers marchés publics remportés"), - html.Div(id="titulaire_last_marches", children=""), + dcc.Loading( + overlay_style={"visibility": "visible", "filter": "blur(2px)"}, + id="loading-home", + type="default", + children=[ + html.Div( + [ + html.P("lignes", id="titulaire_nb_rows"), + html.Button( + "Téléchargement désactivé au-delà de 65 000 lignes", + id="btn-download-filtered-data-titulaire", + disabled=True, + ), + dcc.Download(id="titulaire-download-filtered-data"), + ], + className="table-menu", + ), + datatable, + ], + ), ], ), ] @@ -178,68 +208,55 @@ def update_titulaire_stats(data): @callback( Output(component_id="titulaire_data", component_property="data"), + Output("btn-download-data-titulaire", "disabled"), + Output("btn-download-data-titulaire", "children"), + Output("btn-download-data-titulaire", "title"), Input(component_id="url", component_property="pathname"), Input(component_id="titulaire_year", component_property="value"), ) -def get_titulaire_marches_data(url, titulaire_year: str) -> list[dict]: +def get_titulaire_marches_data(url, titulaire_year: str) -> tuple: titulaire_siret = url.split("/")[-1] lff = df.lazy() lff = lff.filter( (pl.col("titulaire_id") == titulaire_siret) & (pl.col("titulaire_typeIdentifiant") == "SIRET") ) - lff = lff.select( - "id", - "uid", - "objet", - "dateNotification", - "acheteur_id", - "acheteur_nom", - "distance", - "montant", - "codeCPV", - "dureeMois", - ) if titulaire_year and titulaire_year != "Toutes": lff = lff.filter( pl.col("dateNotification").cast(pl.String).str.starts_with(titulaire_year) ) lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True) + lff = lff.fill_null("") - data = lff.collect(engine="streaming").to_dicts() - return data + dff: pl.DataFrame = lff.collect(engine="streaming") + download_disabled, download_text, download_title = get_button_properties(dff.height) + + data = dff.to_dicts() + return data, download_disabled, download_text, download_title @callback( - Output(component_id="titulaire_last_marches", component_property="children"), - Input(component_id="titulaire_data", component_property="data"), + Output("titulaire_datatable", "data"), + Output("titulaire_datatable", "columns"), + Output("titulaire_datatable", "tooltip_header"), + Output("titulaire_datatable", "data_timestamp"), + Output("titulaire_nb_rows", "children"), + Output("btn-download-filtered-data-titulaire", "disabled"), + Output("btn-download-filtered-data-titulaire", "children"), + Output("btn-download-filtered-data-titulaire", "title"), + Input("titulaire_data", "data"), + Input("titulaire_datatable", "page_current"), + Input("titulaire_datatable", "page_size"), + Input("titulaire_datatable", "filter_query"), + Input("titulaire_datatable", "sort_by"), + State("titulaire_datatable", "data_timestamp"), ) -def get_last_marches_table(data) -> html.Div: - dff = pl.DataFrame(data) - dff = dff.cast(pl.String) - dff = dff.fill_null("") - dff = format_values(dff) - columns, tooltip = setup_table_columns( - dff, hideable=False, exclude=["acheteur_id", "id"] +def get_last_marches_data( + data, page_current, page_size, filter_query, sort_by, data_timestamp +) -> list[dict]: + return prepare_table_data( + data, data_timestamp, filter_query, page_current, page_size, sort_by ) - data = dff.to_dicts() - # Idéalement on utiliserait add_org_links(), mais le résultat attendu - # est différent de home.py (Tableau) - data = add_links_in_dict(data, "acheteur") - - table = html.Div( - className="marches_table", - children=DataTable( - dtid="titulaire_data_table", - data=data, - page_action="native", - filter_action="native", - columns=columns, - tooltip_header=tooltip, - page_size=10, - ), - ) - return table @callback( @@ -251,8 +268,8 @@ def get_top_acheteurs(data): @callback( - Output("download-titulaire-data", "data"), - Input("btn-download-titulaire-data", "n_clicks"), + Output("download-data-titulaire", "data"), + Input("btn-download-data-titulaire", "n_clicks"), State(component_id="titulaire_data", component_property="data"), State(component_id="titulaire_nom", component_property="children"), State(component_id="titulaire_year", component_property="value"), @@ -273,3 +290,39 @@ def download_titulaire_data( date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") return dcc.send_bytes(to_bytes, filename=f"decp_{titulaire_nom}_{date}.xlsx") + + +@callback( + Output("titulaire-download-filtered-data", "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_datatable", "hidden_columns"), + prevent_initial_call=True, +) +def download_filtered_titulaire_data( + data, n_clicks, titulaire_nom, filter_query, sort_by, hidden_columns: list = None +): + lff: pl.LazyFrame = pl.LazyFrame( + data + ) # start from the full titulaire data, not from paginated table data + + # Les colonnes masquées sont supprimées + if hidden_columns: + lff = lff.drop(hidden_columns) + + if filter_query: + lff = filter_table_data(lff, filter_query) + + if len(sort_by) > 0: + lff = sort_table_data(lff, sort_by) + + def to_bytes(buffer): + lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP") + + date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") + return dcc.send_bytes( + to_bytes, filename=f"decp_filtrées_{titulaire_nom}_{date}.xlsx" + ) diff --git a/src/utils.py b/src/utils.py index d520f74..fecbd76 100644 --- a/src/utils.py +++ b/src/utils.py @@ -330,9 +330,10 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup continue column_object = data_schema.get(column_id) if column_object: - column_name = column_object.get("title", column_id) + column_name = column_object.get("title") else: column_name = column_id + print("Colonne inconnue dans le schéma !", column_id) column = { "name": column_name, @@ -588,16 +589,15 @@ def prepare_table_data( # Formatage des montants dff = format_values(dff) + + # Récupération des colonnes et tooltip columns, tooltip = setup_table_columns(dff) + dicts = dff.to_dicts() - if height > 65000: - download_disabled = True - download_text = "Téléchargement désactivé au-delà de 65 000 lignes" - download_title = "Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul. Contactez-moi pour me présenter votre besoin en téléchargement afin que je puisse adapter la solution." - else: - download_disabled = False - download_text = "Télécharger au format Excel" - download_title = "" + + # Propriétés du bouton de téléchargement + download_disabled, download_text, download_title = get_button_properties(height) + return ( dicts, columns, @@ -610,6 +610,19 @@ def prepare_table_data( ) +def get_button_properties(height): + if height > 65000: + download_disabled = True + download_text = "Téléchargement désactivé au-delà de 65 000 lignes" + download_title = "Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul. Contactez-moi pour me présenter votre besoin en téléchargement afin que je puisse adapter la solution." + else: + print("moins de 65k") + download_disabled = False + download_text = "Télécharger au format Excel" + download_title = "" + return download_disabled, download_text, download_title + + df: pl.DataFrame = get_decp_data() schema = df.collect_schema() From e216b42379fc96fbf4cffa96921ecb9a4031953f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 24 Nov 2025 16:00:21 +0100 Subject: [PATCH 09/16] Meilleure gestion des df vides acheteur/titulaire --- src/pages/acheteur.py | 2 +- src/pages/titulaire.py | 25 ++++++++++++++----------- src/utils.py | 17 ++++++++++++----- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 8d2eca0..24b71cb 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -247,7 +247,7 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> tuple: ) def get_last_marches_data( data, page_current, page_size, filter_query, sort_by, data_timestamp -) -> list[dict]: +) -> tuple: return prepare_table_data( data, data_timestamp, filter_query, page_current, page_size, sort_by ) diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index ae1cca3..433fbe8 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -188,22 +188,25 @@ def update_titulaire_infos(url): def update_titulaire_stats(data): dff = pl.DataFrame(data) if dff.height == 0: - dff = pl.DataFrame(schema=dff.collect_schema()) - df_marches = dff.unique("uid") - nb_marches = format_number(df_marches.height) - # somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item())) - marches_remportes = [html.Strong(nb_marches), " marchés et accord-cadres remportés"] - # + ", pour un total de ", html.Strong(somme_marches + " €")] - del df_marches + nb_marches = 0 + nb_acheteurs = 0 + else: + df_marches = dff.unique("uid") + nb_marches = format_number(df_marches.height) + nb_acheteurs = dff.unique("acheteur_id").height - nb_acheteurs = dff.unique("acheteur_id").height - nb_acheteurs = [ + texte_marches_remportes = [ + html.Strong(nb_marches), + " marchés et accord-cadres remportés", + ] + # + ", pour un total de ", html.Strong(somme_marches + " €")] + + texte_nb_acheteurs = [ html.Strong(format_number(nb_acheteurs)), " acheteurs (SIRET) différents", ] - del dff - return marches_remportes, nb_acheteurs + return texte_marches_remportes, texte_nb_acheteurs @callback( diff --git a/src/utils.py b/src/utils.py index fecbd76..19ab81c 100644 --- a/src/utils.py +++ b/src/utils.py @@ -333,7 +333,6 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup column_name = column_object.get("title") else: column_name = column_id - print("Colonne inconnue dans le schéma !", column_id) column = { "name": column_name, @@ -551,7 +550,7 @@ def prepare_table_data( print(" + + + + + + + + + + + + + + + + + + ") # Récupération des données - if data: + if isinstance(data, list): lff: pl.LazyFrame = pl.LazyFrame(data) else: lff: pl.LazyFrame = df.lazy() # start from the original data @@ -567,7 +566,11 @@ def prepare_table_data( # Matérialisation des filtres dff: pl.DataFrame = lff.collect() height = dff.height - nb_rows = f"{format_number(height)} lignes ({format_number(dff.select('uid').unique().height)} marchés)" + + if height > 0: + nb_rows = f"{format_number(height)} lignes ({format_number(dff.select('uid').unique().height)} marchés)" + else: + nb_rows = "0 lignes (0 marchés)" # Pagination des données start_row = page_current * page_size @@ -588,7 +591,8 @@ def prepare_table_data( dff = add_resource_link(dff) # Formatage des montants - dff = format_values(dff) + if height > 0: + dff = format_values(dff) # Récupération des colonnes et tooltip columns, tooltip = setup_table_columns(dff) @@ -615,8 +619,11 @@ def get_button_properties(height): download_disabled = True download_text = "Téléchargement désactivé au-delà de 65 000 lignes" download_title = "Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul. Contactez-moi pour me présenter votre besoin en téléchargement afin que je puisse adapter la solution." + elif height == 0: + download_disabled = True + download_text = "Pas de données à télécharger" + download_title = "" else: - print("moins de 65k") download_disabled = False download_text = "Télécharger au format Excel" download_title = "" From 9ae6f29391da991c535f8476a41335280c6ff12a Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 24 Nov 2025 16:54:50 +0100 Subject: [PATCH 10/16] =?UTF-8?q?Plus=20de=20tol=C3=A9rance=20dans=20l'ing?= =?UTF-8?q?estion=20des=20data=20dicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +++++ src/callbacks.py | 2 +- src/pages/acheteur.py | 2 +- src/pages/titulaire.py | 2 +- src/utils.py | 2 +- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f7b938e..6a34522 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ Ne pas oublier de mettre à jour les fichier .env. ## Notes de version +#### 2.3.0 (24 nomvembre 2025) + +- Possibilité de filtrer, trier etc. dans les vues acheteur et titulaire +- + ##### 2.2.2 (22 novembre 2025) - Correction d'un bug dans le téléchargement Excel diff --git a/src/callbacks.py b/src/callbacks.py index 6b12013..d5f1522 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -6,7 +6,7 @@ from utils import add_links_in_dict, format_values, setup_table_columns def get_top_org_table(data, org_type: str): - dff = pl.DataFrame(data) + dff = pl.DataFrame(data, strict=False, infer_schema_length=5000) if dff.height == 0: return html.Div() diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 24b71cb..6d51bb8 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -186,7 +186,7 @@ def update_acheteur_infos(url): Input(component_id="acheteur_data", component_property="data"), ) def update_acheteur_stats(data): - dff = pl.DataFrame(data) + dff = pl.DataFrame(data, strict=False, infer_schema_length=5000) if dff.height == 0: dff = pl.DataFrame(schema=df.collect_schema()) df_marches = dff.unique("id") diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index 433fbe8..d75eb78 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -186,7 +186,7 @@ def update_titulaire_infos(url): Input(component_id="titulaire_data", component_property="data"), ) def update_titulaire_stats(data): - dff = pl.DataFrame(data) + dff = pl.DataFrame(data, strict=False, infer_schema_length=5000) if dff.height == 0: nb_marches = 0 nb_acheteurs = 0 diff --git a/src/utils.py b/src/utils.py index 19ab81c..81a7925 100644 --- a/src/utils.py +++ b/src/utils.py @@ -551,7 +551,7 @@ def prepare_table_data( # Récupération des données if isinstance(data, list): - lff: pl.LazyFrame = pl.LazyFrame(data) + lff: pl.LazyFrame = pl.LazyFrame(data, strict=False, infer_schema_length=5000) else: lff: pl.LazyFrame = df.lazy() # start from the original data From 43fd2df2c039cba0e91678ba960458da91b15fbe Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 3 Dec 2025 16:31:05 +0100 Subject: [PATCH 11/16] Suggested by Gemini Pro, but race condition: filters apply before columns are created #58 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt: - In the tableau page (src/pages/tableau.py), I want to allow users to copy a URL in their clipboard that enables opening the page with the same view: applied filters, sorting and column selection of the datatable. Typically to share the view with a colleague via email or chat. In terms of Dash callbacks, that would mean the following: - one callback syncs the filters, the sort parameters and the selection of columns with a read-only text input that stores the URL to copy. This callback also ensures the button to copy to the clipboard is visible, possibly replacing the confirmation message (see next point) - one callback reacts to clicks on a button to store the URLin the clipboard. This button in on the same row as "Télécharger au format Excel" button. When clicked, the URL is stored in the clipboard, and the button is replaced with a confrmation message (URL copiée) - one callback reads the URL, and applies the filters, sorting and column selection - The URL stores the view configuration (filters, sorting and columns) in URL parameters, similar to what is done in REST APIs. The values are the same as stored in the DataTable parameters, just URL encoded. The URL parameters are in French: filtres, tris, colonnes. --- src/pages/tableau.py | 105 ++++++++++++++++++++++++++++++++++++++++++- src/utils.py | 19 ++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index ba938b5..f7a17af 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -1,8 +1,10 @@ +import json import os +import urllib.parse from datetime import datetime import polars as pl -from dash import Input, Output, State, callback, dcc, html, register_page +from dash import Input, Output, State, callback, dcc, html, no_update, register_page from src.figures import DataTable from src.utils import ( @@ -42,6 +44,7 @@ datatable = html.Div( ) layout = [ + dcc.Location(id="url", refresh=False), html.Div( html.Details( children=[ @@ -100,6 +103,8 @@ layout = [ html.Div( [ html.P("lignes", id="nb_rows"), + html.Div(id="copy-container"), + dcc.Input(id="share-url", readOnly=True, style={"display": "none"}), html.Button( "Téléchargement désactivé au-delà de 65 000 lignes", id="btn-download-data", @@ -133,6 +138,11 @@ layout = [ State("table", "data_timestamp"), ) def update_table(page_current, page_size, filter_query, sort_by, data_timestamp): + # if ctx.triggered_id != "url": + # search_params = None + # else: + # search_params = urllib.parse.parse_qs(search_params.lstrip("?")) + return prepare_table_data( None, data_timestamp, filter_query, page_current, page_size, sort_by ) @@ -164,3 +174,96 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx") + + +@callback( + Output("table", "filter_query"), + Output("table", "sort_by"), + Output("table", "hidden_columns"), + Input("url", "search"), +) +def restore_view_from_url(search): + if not search: + return no_update, no_update, no_update + + params = urllib.parse.parse_qs(search.lstrip("?")) + print("params", params) + + filter_query = no_update + sort_by = no_update + hidden_columns = no_update + + if "filtres" in params: + filter_query = params["filtres"][0] + + if "tris" in params: + try: + sort_by = json.loads(params["tris"][0]) + except json.JSONDecodeError: + pass + + if "colonnes" in params: + try: + hidden_columns = json.loads(params["colonnes"][0]) + except json.JSONDecodeError: + pass + + return filter_query, sort_by, hidden_columns + + +@callback( + Output("share-url", "value"), + Output("copy-container", "children"), + Input("table", "filter_query"), + Input("table", "sort_by"), + Input("table", "hidden_columns"), + State("url", "href"), +) +def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href): + if not href: + return no_update, no_update + + # Extract base URL (remove existing query params) + base_url = href.split("?")[0] + + params = {} + if filter_query: + params["filtres"] = filter_query + + if sort_by: + params["tris"] = json.dumps(sort_by) + + if hidden_columns: + params["colonnes"] = json.dumps(hidden_columns) + + query_string = urllib.parse.urlencode(params) + full_url = f"{base_url}?{query_string}" if query_string else base_url + + copy_button = dcc.Clipboard( + id="btn-copy-url", + target_id="share-url", + title="Copier l'URL de cette vue", + style={ + "display": "inline-block", + "fontSize": 20, + "verticalAlign": "top", + "cursor": "pointer", + }, + className="fa fa-link", + ) + + return full_url, copy_button + + +@callback( + Output("copy-container", "children", allow_duplicate=True), + Input("btn-copy-url", "n_clicks", allow_optional=True), + prevent_initial_call=True, +) +def show_confirmation(n_clicks): + if n_clicks: + return html.Span( + "URL copiée", + style={"color": "green", "fontWeight": "bold", "marginLeft": "10px"}, + ) + return no_update diff --git a/src/utils.py b/src/utils.py index 81a7925..9307c92 100644 --- a/src/utils.py +++ b/src/utils.py @@ -543,6 +543,7 @@ def prepare_table_data( :param page_current: :param page_size: :param sort_by: + :param search_params: :return: """ @@ -555,6 +556,24 @@ def prepare_table_data( else: lff: pl.LazyFrame = df.lazy() # start from the original data + # if search_params: + # if "filtres" in search_params: + # filter_query = search_params["filtres"][0] + # + # if "tris" in search_params: + # try: + # sort_by = json.loads(search_params["tris"][0]) + # except json.JSONDecodeError: + # pass + # + # if "colonnes" in search_params: + # try: + # hidden_columns = json.loads(search_params["colonnes"][0]) + # print(hidden_columns) + # lff = lff.drop(hidden_columns) + # except json.JSONDecodeError: + # pass + # Application des filtres if filter_query: lff = filter_table_data(lff, filter_query) From 24ef21761f8c60e13c41cfa986bdbdb30d2379f7 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 18 Dec 2025 01:37:58 +0100 Subject: [PATCH 12/16] Gestion des annonces dans .env --- .template.env | 3 +++ src/app.py | 5 ++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.template.env b/.template.env index 86ff67f..67df0ba 100644 --- a/.template.env +++ b/.template.env @@ -3,6 +3,9 @@ PORT=8050 DEVELOPMENT=True SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234" +# Annonce dans l'en-tête du site +ANNOUNCEMENTS= + # Chemin vers le schéma de données DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a diff --git a/src/app.py b/src/app.py index a2dd1fb..49cb80a 100644 --- a/src/app.py +++ b/src/app.py @@ -1,4 +1,5 @@ import logging +import os import dash_bootstrap_components as dbc import tomllib @@ -92,9 +93,7 @@ app.layout = html.Div( ), html.Div( id="announcements", - children=dcc.Markdown( - "ℹ️ La publication des données consolidées par le MINEF est actuellement [cassée](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-fichiers-consolides/#/discussions/69306148a4871a110fd7a7d0), ce qui casse la mise à jour de decp.info. Je travaille donc en priorité à une [désolidarisation des données du MINEF](https://github.com/ColinMaudry/decp-processing/issues/151). Merci pour votre patience." - ), + children=dcc.Markdown(os.getenv("ANNOUNCEMENTS")), ), html.Div( [ From 43104f611e6f3f9fca9ad5e77d655d91798d9aff Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 18 Dec 2025 03:40:32 +0100 Subject: [PATCH 13/16] =?UTF-8?q?R=C3=A9paration=20des=20callbacks,=20mode?= =?UTF-8?q?=20d'emploi=20#58?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/tableau.py | 25 ++++++++++++++++--------- src/utils.py | 1 + 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index f7a17af..85563c3 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -40,6 +40,7 @@ datatable = html.Div( filter_action="custom", sort_action="custom", hidden_columns=get_default_hidden_columns(None), + columns=[{"id": col, "name": col} for col in df.columns], ), ) @@ -49,10 +50,11 @@ layout = [ html.Details( children=[ html.Summary( - html.H3("Mode d'emploi", style={"text-decoration": "underline"}), + html.H3("Mode d'emploi", style={"textDecoration": "underline"}), ), dcc.Markdown( - """ + dangerously_allow_html=True, + children=""" ##### Définition des colonnes Pour voir la définition d'une colonne, passez votre souris sur son en-tête. @@ -62,10 +64,10 @@ layout = [ 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 est insensible à la casse (majuscules/minuscules) et retourne les valeurs qui contiennent - le texte recherché. Exemple : `rennes` retourne "RENNES METROPOLE". + le texte recherché. Exemple : `rennes` retourne "RENNES METROPOLE". Lorsque vous ouvrez une URL de vue, le format équivalent `icontains rennes` est utilisé. - Champs numériques : vous pouvez soit taper un nombre pour trouver les valeurs égales, 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 : vous pouvez également utiliser **>** ou **<**. Exemples : `< 2024-01-31` pour "avant le 31 janvier 2024", - `2024` pour "en 2024", `> 2022` pour "à partir de 2022" + `2024` pour "en 2024", `> 2022` pour "à partir de 2022". Lorsque vous ouvrez une URL de vue, le format équivalent `i<` ou `i>` est utilisé. Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page. @@ -73,6 +75,10 @@ layout = [ Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre : tri ascendant, tri descendant, pas de tri. + ##### Partager une vue + + Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqué. Vous pouvez copier une adresse Web qui reproduit la vue courante à l'identique en cliquant sur l'icône drawing. En la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres. + ##### 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**. @@ -82,7 +88,7 @@ layout = [ 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.) - """ + """, ), ], id="instructions", @@ -142,7 +148,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("?")) - return prepare_table_data( None, data_timestamp, filter_query, page_current, page_size, sort_by ) @@ -180,11 +185,13 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): Output("table", "filter_query"), Output("table", "sort_by"), Output("table", "hidden_columns"), + Output("url", "search", allow_duplicate=True), Input("url", "search"), + prevent_initial_call=True, ) def restore_view_from_url(search): if not search: - return no_update, no_update, no_update + return no_update, no_update, no_update, no_update params = urllib.parse.parse_qs(search.lstrip("?")) print("params", params) @@ -208,7 +215,7 @@ def restore_view_from_url(search): except json.JSONDecodeError: pass - return filter_query, sort_by, hidden_columns + return filter_query, sort_by, hidden_columns, "" @callback( @@ -263,7 +270,7 @@ def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href): def show_confirmation(n_clicks): if n_clicks: return html.Span( - "URL copiée", + "Adresse de la vue copiée", style={"color": "green", "fontWeight": "bold", "marginLeft": "10px"}, ) return no_update diff --git a/src/utils.py b/src/utils.py index 9307c92..e7d47ad 100644 --- a/src/utils.py +++ b/src/utils.py @@ -27,6 +27,7 @@ def split_filter_part(filter_part): ["i<", "<"], ["i>", ">"], ["icontains", "contains"], + # [" ", "contains"] ] print("filter part", filter_part) for operator_group in operators: From 032ca5554e73c3ca5991af6a95223a27607036d4 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 24 Dec 2025 09:31:33 +0100 Subject: [PATCH 14/16] =?UTF-8?q?D=C3=A9placement=20des=20notes=20de=20ver?= =?UTF-8?q?sion=20vers=20CHANGELOG.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 133 +-------------------------------------------------- src/app.py | 2 +- 3 files changed, 134 insertions(+), 133 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ad0ac24 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,132 @@ +#### 2.3.0 (24 décembre 2025) + +- Possibilité de filtrer, trier etc. dans les vues acheteur et titulaire +- Possibilité de partager les filtres, tris et choix de colonnes via une adresse Web + +##### 2.2.3 (4 décembre 2025) + +- mise à jour de l'adresse email de contact (colmo.tech) +- message sur l'indisponibilité des données MINEF + +##### 2.2.2 (22 novembre 2025) + +- Correction d'un bug dans le téléchargement Excel + +##### 2.2.1 (15 novembre 2025) + +- Le moteur de recherche ignore les tirets ("franche comté" trouve "Bourgogne-Franche-Comté) +- Phrase "tagline" au-dessus du champ de recherche +- Les infos de Contact rebasculent dans À propos +- Police de caractère "Open Sans" généralisée + +#### 2.2.0 (13 novembre 2025) + +- Moteur de recherche (acheteurs et titulaires) en page d'accueil ([#58](https://github.com/ColinMaudry/decp.info/issues/58)) +- Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/decp.info/issues/55))) +- Moins de colonnes affichées par défaut dans Tableau ([#54](https://github.com/ColinMaudry/decp.info/issues/54)) + +##### 2.1.7 (11 novembre 2025) + +- Remplacement du formulaire de contact par une adresse email + +##### 2.1.6 (15 octobre 2025) + +- Stabilisation de la vue marché + +##### 2.1.5 (10 octobre 2025) + +- réparation des filtres (notamment < > sur les montants) +- remplacement des valeurs "null" dans les tableaux par des cellules vides + +##### 2.1.4 (8 octobre 2025) + +- possibilité de filtrer sur le champ "Source" +- création automatique d'une release Github quand je push un tag + +##### 2.1.3 (4 octobre 2025) + +- tentative d'auto-release à chaque création de tag git +- adaptation au format TableSchema + +##### 2.1.2 (3 octobre 2025) + +- dataframe global plutôt que lazyframe, pour plus de résilience et charger toutes les données en mémoire + +##### 2.1.1 (1er octobre 2025) + +- ajout d'une section dans À propos sur la qualité et l'exhaustivité des données ([#43](https://github.com/ColinMaudry/decp.info/issues/43)) +- ajout du nombre de marchés en plus du nombre de lignes dans la vue Tableau + +#### 2.1.0 (30 septembre 2025) + +- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/decp.info/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786) ([#35](https://github.com/ColinMaudry/decp.info/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/decp.info/issues/40)) 🔎 +- Ajout des balises HTML meta Open Graph et Twitter ([#39](https://github.com/ColinMaudry/decp.info/issues/39)) pour de beaux aperçus de liens 🖼️ +- Formulaire de contact ([#48](https://github.com/ColinMaudry/decp.info/issues/48)) 📨 +- Nom de colonnes plus_agréables ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 💅 +- Définition des colonnes quand vous passez votre souris sur les en-têtes ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 📖 +- Affichage du numéro de version près du logo et lien vers ici 🤓 +- Variables globales uniquement en lecture (😁) + +##### 2.0.1 (23 septembre 2025) + +- Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/decp.info/issues/38) +- Amélioration du script de déploiement (deploy.sh) +- Meilleures instructions d'installation et lancement +- Coquilles 🐚 + +### 2.0.0 (23 septembre 2025) + +- détails des sources de données +- section "À propos" plus développée +- correction de bugs dans les filtres de la data table + +#### 2.0.0-alpha + +- Data table fonctionnelle + +### 1.5.0 (28/01/2023 + +- fixation des dépendances Python pour plus de stabilité en cas de réinstallation (Pipfile) + +#### 1.4.1 (14/06/2021) + +- ajout des traductions des opérations de filtrage à toutes les vues, pas seulement /db/decp + +### 1.4.0 (14/06/2021) + +- traduction des opérations de filtrage (ex : contains => contient) +- élargissement des menus de filtrage +- correction du titre de la page des notes de versions + +### 1.3.0 (03/06/2021) + +- utilisation de noms de colonnes plus lisibles dans l'application +- suppression des références à la licence et aux données source sur la page d'accueil +- correction des liens vers le code source +- correction de l'indentation des puces dans les notes de version + +### 1.2.0 (28/05/2021) + +- ajout d'une page "Notes de version" +- meilleur lien pour la documentation des champs +- déplacement du code de decp.info depuis [ColinMaudry/decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) vers [ColinMaudry/decp.info](https://github.com/ColinMaudry/decp.info) + +### 1.1.0 (25/05/2021) + +- ajout de nouvelles vues : + - Marchés publics sans leurs titulaires : vue dédiée aux titulaires de marchés avec des données provenant du répertoire SIRENE + - Données sur les titulaires et géolocalisation : vue sans les titulaires pour analyser les nombres de marchés et les montants +- amélioration de la page d'accueil +- développement de la page "db" avec description des vues et liste des colonnes +- les codes APE sont cliquables +- ajout des mentions légales +- ajout d'un formulatire d'inscription à une lettre d'information +- correction de bugs : + - correction du format de certaines dates dans les données + +### 1.0.0 + +- publication sur https://decp.info +- ajout d'une vue équivalente au format DECP réglementaire +- personnalisation de datasette +- script de conversion quotidien basé sur [dataflows](https://github.com/datahq/dataflows) diff --git a/README.md b/README.md index a804805..b38991f 100644 --- a/README.md +++ b/README.md @@ -37,135 +37,4 @@ Ne pas oublier de mettre à jour les fichier .env. ## Notes de version -#### 2.3.0 (24 novembre 2025) - -- Possibilité de filtrer, trier etc. dans les vues acheteur et titulaire -- - -##### 2.2.3 (4 décembre 2025) - -- mise à jour de l'adresse email de contact (colmo.tech) -- message sur l'indisponibilité des données MINEF - -##### 2.2.2 (22 novembre 2025) - -- Correction d'un bug dans le téléchargement Excel - -##### 2.2.1 (15 novembre 2025) - -- Le moteur de recherche ignore les tirets ("franche comté" trouve "Bourgogne-Franche-Comté) -- Phrase "tagline" au-dessus du champ de recherche -- Les infos de Contact rebasculent dans À propos -- Police de caractère "Open Sans" généralisée - -#### 2.2.0 (13 novembre 2025) - -- Moteur de recherche (acheteurs et titulaires) en page d'accueil ([#58](https://github.com/ColinMaudry/decp.info/issues/58)) -- Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/decp.info/issues/55))) -- Moins de colonnes affichées par défaut dans Tableau ([#54](https://github.com/ColinMaudry/decp.info/issues/54)) - -##### 2.1.7 (11 novembre 2025) - -- Remplacement du formulaire de contact par une adresse email - -##### 2.1.6 (15 octobre 2025) - -- Stabilisation de la vue marché - -##### 2.1.5 (10 octobre 2025) - -- réparation des filtres (notamment < > sur les montants) -- remplacement des valeurs "null" dans les tableaux par des cellules vides - -##### 2.1.4 (8 octobre 2025) - -- possibilité de filtrer sur le champ "Source" -- création automatique d'une release Github quand je push un tag - -##### 2.1.3 (4 octobre 2025) - -- tentative d'auto-release à chaque création de tag git -- adaptation au format TableSchema - -##### 2.1.2 (3 octobre 2025) - -- dataframe global plutôt que lazyframe, pour plus de résilience et charger toutes les données en mémoire - -##### 2.1.1 (1er octobre 2025) - -- ajout d'une section dans À propos sur la qualité et l'exhaustivité des données ([#43](https://github.com/ColinMaudry/decp.info/issues/43)) -- ajout du nombre de marchés en plus du nombre de lignes dans la vue Tableau - -#### 2.1.0 (30 septembre 2025) - -- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/decp.info/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786) ([#35](https://github.com/ColinMaudry/decp.info/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/decp.info/issues/40)) 🔎 -- Ajout des balises HTML meta Open Graph et Twitter ([#39](https://github.com/ColinMaudry/decp.info/issues/39)) pour de beaux aperçus de liens 🖼️ -- Formulaire de contact ([#48](https://github.com/ColinMaudry/decp.info/issues/48)) 📨 -- Nom de colonnes plus_agréables ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 💅 -- Définition des colonnes quand vous passez votre souris sur les en-têtes ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 📖 -- Affichage du numéro de version près du logo et lien vers ici 🤓 -- Variables globales uniquement en lecture (😁) - -##### 2.0.1 (23 septembre 2025) - -- Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/decp.info/issues/38) -- Amélioration du script de déploiement (deploy.sh) -- Meilleures instructions d'installation et lancement -- Coquilles 🐚 - -### 2.0.0 (23 septembre 2025) - -- détails des sources de données -- section "À propos" plus développée -- correction de bugs dans les filtres de la data table - -#### 2.0.0-alpha - -- Data table fonctionnelle - -### 1.5.0 (28/01/2023 - -- fixation des dépendances Python pour plus de stabilité en cas de réinstallation (Pipfile) - -#### 1.4.1 (14/06/2021) - -- ajout des traductions des opérations de filtrage à toutes les vues, pas seulement /db/decp - -### 1.4.0 (14/06/2021) - -- traduction des opérations de filtrage (ex : contains => contient) -- élargissement des menus de filtrage -- correction du titre de la page des notes de versions - -### 1.3.0 (03/06/2021) - -- utilisation de noms de colonnes plus lisibles dans l'application -- suppression des références à la licence et aux données source sur la page d'accueil -- correction des liens vers le code source -- correction de l'indentation des puces dans les notes de version - -### 1.2.0 (28/05/2021) - -- ajout d'une page "Notes de version" -- meilleur lien pour la documentation des champs -- déplacement du code de decp.info depuis [ColinMaudry/decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) vers [ColinMaudry/decp.info](https://github.com/ColinMaudry/decp.info) - -### 1.1.0 (25/05/2021) - -- ajout de nouvelles vues : - - Marchés publics sans leurs titulaires : vue dédiée aux titulaires de marchés avec des données provenant du répertoire SIRENE - - Données sur les titulaires et géolocalisation : vue sans les titulaires pour analyser les nombres de marchés et les montants -- amélioration de la page d'accueil -- développement de la page "db" avec description des vues et liste des colonnes -- les codes APE sont cliquables -- ajout des mentions légales -- ajout d'un formulatire d'inscription à une lettre d'information -- correction de bugs : - - correction du format de certaines dates dans les données - -### 1.0.0 - -- publication sur https://decp.info -- ajout d'une vue équivalente au format DECP réglementaire -- personnalisation de datasette -- script de conversion quotidien basé sur [dataflows](https://github.com/datahq/dataflows) +Voir [CHANGELOG](https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md). diff --git a/src/app.py b/src/app.py index 49cb80a..699c389 100644 --- a/src/app.py +++ b/src/app.py @@ -83,7 +83,7 @@ app.layout = html.Div( html.P( children=html.A( version, - href="https://github.com/ColinMaudry/decp.info?tab=readme-ov-file#notes-de-version", + href="https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md", target="_blank", ), className="version", From 4719ef57547754268799fbf6014453fb8a2d8fa0 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 24 Dec 2025 09:35:55 +0100 Subject: [PATCH 15/16] Ajout de copy.svg --- src/assets/copy.svg | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/assets/copy.svg diff --git a/src/assets/copy.svg b/src/assets/copy.svg new file mode 100644 index 0000000..f14d4e4 --- /dev/null +++ b/src/assets/copy.svg @@ -0,0 +1 @@ + From 99eef0eaa3b76767b97df54ced0bc1c66aca09d8 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 24 Dec 2025 09:40:15 +0100 Subject: [PATCH 16/16] Ajout mode d'emploi #58 --- src/pages/tableau.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 85563c3..42d37b8 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -79,6 +79,8 @@ layout = [ Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqué. Vous pouvez copier une adresse Web qui reproduit la vue courante à l'identique en cliquant sur l'icône drawing. 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**.