Merge branch 'hotfix/2.1.2'

This commit is contained in:
Colin Maudry
2025-10-03 16:45:35 +02:00
10 changed files with 53 additions and 41 deletions
+7 -3
View File
@@ -1,6 +1,6 @@
# decp.info # decp.info
> v2.1.1 > v2.1.2
Outil d'exploration et de téléchargement des données essentielles de la commande publique. Outil d'exploration et de téléchargement des données essentielles de la commande publique.
@@ -38,12 +38,16 @@ Ne pas oublier de mettre à jour les fichier .env.
## Notes de version ## Notes de version
##### 2.1.1 ##### 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 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 - ajout du nombre de marchés en plus du nombre de lignes dans la vue Tableau
#### 2.1.0 #### 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 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 🖼️ - 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 🖼️
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "decp.info" name = "decp.info"
description = "Interface d'exploration et d'analyse des marchés publics français." description = "Interface d'exploration et d'analyse des marchés publics français."
version = "2.1.1" version = "2.1.2"
requires-python = ">= 3.10" requires-python = ">= 3.10"
authors = [ authors = [
{ name = "Colin Maudry", email = "colin+decp@maudry.com" } { name = "Colin Maudry", email = "colin+decp@maudry.com" }
+6 -4
View File
@@ -5,7 +5,8 @@ import polars as pl
from dash import dash_table, dcc, html from dash import dash_table, dcc, html
def get_map_count_marches(lf: pl.LazyFrame): def get_map_count_marches(df: pl.DataFrame):
lf = df.lazy()
lf = lf.with_columns( lf = lf.with_columns(
pl.col("lieuExecution_code").str.head(2).str.zfill(2).alias("Département") pl.col("lieuExecution_code").str.head(2).str.zfill(2).alias("Département")
) )
@@ -26,7 +27,7 @@ def get_map_count_marches(lf: pl.LazyFrame):
for f in departements["features"]: for f in departements["features"]:
f["id"] = f["properties"]["code"] f["id"] = f["properties"]["code"]
df = lf.collect() df = lf.collect(engine="streaming")
fig = px.choropleth( fig = px.choropleth(
df, df,
@@ -54,7 +55,8 @@ def get_map_count_marches(lf: pl.LazyFrame):
return fig return fig
def get_barchart_sources(lf: pl.LazyFrame, type_date: str): def get_barchart_sources(df: pl.DataFrame, type_date: str):
lf = df.lazy()
labels = { labels = {
"dateNotification": "notification", "dateNotification": "notification",
"datePublicationDonnees": "publication des données", "datePublicationDonnees": "publication des données",
@@ -97,7 +99,7 @@ def get_barchart_sources(lf: pl.LazyFrame, type_date: str):
# ) # )
lf = lf.sort(by=["sourceDataset"], descending=False) lf = lf.sort(by=["sourceDataset"], descending=False)
df: pl.DataFrame = lf.collect() df: pl.DataFrame = lf.collect(engine="streaming")
fig = px.bar( fig = px.bar(
df, df,
+1 -1
View File
@@ -55,7 +55,7 @@ les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
- [du traitement des données](https://github.com/ColinMaudry/decp-processing) - [du traitement des données](https://github.com/ColinMaudry/decp-processing)
"""), """),
html.H4("Qualité et exhaustivité des données", id="qualite-exhausitivite"), html.H4("Qualité et exhaustivité des données", id="qualite-exhausitivite"),
dcc.Markdown("""Les données visibles sur ce site proviennent exclusivement de la publication de données ouvertes par les acheteurs publics ou en leur nom, régie par [l'arrêté du 22 décembre 2022](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000046850496). Leur qualité est donc principalement liée à la qualité de leur saisie par les agents publics, parfois peu aidé·es par la qualité des outils à leur disposition. Je pense que l'analyse de marchés individuels et le comptage de marchés sur des critères autres que financiers sont plutôt fiables. En revanche, certains montants de marché estimés à des valeurs farfelues ([1 euro](https://decp.info/marches/432766947000192025S01301), [1 milliard](https://decp.info/marches/2459004280001320210000000271) faussent les calculs par aggrégation (sommes, moyennes, médianes) et donc la production de statistiques financières fiables. Acheteurs, acheteuses : s'il vous plaît, essayez d'estimer les montants des marchés publics attribués de manière plus précise. dcc.Markdown("""Les données visibles sur ce site proviennent exclusivement de la publication de données ouvertes par les acheteurs publics ou en leur nom, régie par [l'arrêté du 22 décembre 2022](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000046850496). Leur qualité est donc principalement liée à la qualité de leur saisie par les agents publics, parfois peu aidé·es par la qualité des outils à leur disposition. Je pense que l'analyse de marchés individuels et le comptage de marchés sur des critères autres que financiers sont plutôt fiables. En revanche, certains montants de marché estimés à des valeurs farfelues ([1 euro](https://decp.info/marches/432766947000192025S01301), [1 milliard](https://decp.info/marches/2459004280001320210000000271)) faussent les calculs par aggrégation (sommes, moyennes, médianes) et donc la production de statistiques financières fiables. Acheteurs, acheteuses : s'il vous plaît, essayez d'estimer les montants des marchés publics attribués de manière plus précise.
Quant à l'exhaustivité, je consolide toutes les sources de données exploitables que j'ai pu identifier (voir [statistiques](/statistiques)). Certains profils d'acheteurs ne publient pas leurs données malgré l'obligation réglementaire : Quant à l'exhaustivité, je consolide toutes les sources de données exploitables que j'ai pu identifier (voir [statistiques](/statistiques)). Certains profils d'acheteurs ne publient pas leurs données malgré l'obligation réglementaire :
+5 -4
View File
@@ -6,11 +6,11 @@ from dash import Input, Output, State, callback, dash_table, dcc, html, register
from src.figures import point_on_map from src.figures import point_on_map
from src.utils import ( from src.utils import (
add_links_in_dict, add_links_in_dict,
df,
format_montant, format_montant,
format_number, format_number,
get_annuaire_data, get_annuaire_data,
get_departement_region, get_departement_region,
lf,
meta_content, meta_content,
setup_table_columns, setup_table_columns,
) )
@@ -148,7 +148,7 @@ def update_acheteur_infos(url):
def update_acheteur_stats(data): def update_acheteur_stats(data):
df = pl.DataFrame(data) df = pl.DataFrame(data)
if df.height == 0: if df.height == 0:
df = pl.DataFrame(schema=lf.collect_schema()) df = pl.DataFrame(schema=df.collect_schema())
df_marches = df.unique("id") df_marches = df.unique("id")
nb_marches = format_number(df_marches.height) nb_marches = format_number(df_marches.height)
# somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item())) # somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item()))
@@ -171,9 +171,10 @@ def update_acheteur_stats(data):
Input(component_id="url", component_property="pathname"), Input(component_id="url", component_property="pathname"),
Input(component_id="acheteur_year", component_property="value"), Input(component_id="acheteur_year", component_property="value"),
) )
def get_acheteur_marches_data(url, acheteur_year: str) -> pl.LazyFrame: def get_acheteur_marches_data(url, acheteur_year: str) -> list[dict]:
acheteur_siret = url.split("/")[-1] acheteur_siret = url.split("/")[-1]
lff = lf.filter(pl.col("acheteur_id") == acheteur_siret) lff = df.lazy()
lff = lff.filter(pl.col("acheteur_id") == acheteur_siret)
lff = lff.fill_null("") lff = lff.fill_null("")
lff = lff.select( lff = lff.select(
"id", "id",
+5 -4
View File
@@ -4,7 +4,7 @@ import polars as pl
from dash import Input, Output, callback, dcc, html, register_page from dash import Input, Output, callback, dcc, html, register_page
from polars import selectors as cs from polars import selectors as cs
from src.utils import data_schema, format_montant, lf, meta_content from src.utils import data_schema, df, format_montant, meta_content
register_page( register_page(
__name__, __name__,
@@ -62,11 +62,12 @@ layout = [
Output("titulaires_data", "data"), Output("titulaires_data", "data"),
Input(component_id="url", component_property="pathname"), Input(component_id="url", component_property="pathname"),
) )
def get_marche_data(url): def get_marche_data(url) -> tuple[dict, list]:
marche_uid = url.split("/")[-1] marche_uid = url.split("/")[-1]
# Récupération des données du marché à partir du lf global # Récupération des données du marché à partir du df global
lff = lf.filter(pl.col("uid") == pl.lit(marche_uid)) lff = df.lazy()
lff = lff.filter(pl.col("uid") == pl.lit(marche_uid))
# Données des titulaires du marché # Données des titulaires du marché
dff_titulaires = lff.select(cs.starts_with("titulaire")).collect(engine="streaming") dff_titulaires = lff.select(cs.starts_with("titulaire")).collect(engine="streaming")
+4 -4
View File
@@ -1,7 +1,7 @@
from dash import dcc, html, register_page from dash import dcc, html, register_page
from src.figures import get_barchart_sources, get_map_count_marches from src.figures import get_barchart_sources, get_map_count_marches
from src.utils import lf, meta_content from src.utils import df, meta_content
name = "Statistiques" name = "Statistiques"
@@ -37,13 +37,13 @@ layout = [
L'ajout de nouvelles plateformes [est en cours](https://github.com/ColinMaudry/decp-processing/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22source%20de%20donn%C3%A9es%22), L'ajout de nouvelles plateformes [est en cours](https://github.com/ColinMaudry/decp-processing/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22source%20de%20donn%C3%A9es%22),
toutes les [contributions](/a-propos#contribuer) sont les bienvenues pour atteindre l'exhaustivité. toutes les [contributions](/a-propos#contribuer) sont les bienvenues pour atteindre l'exhaustivité.
"""), """),
dcc.Graph(figure=get_map_count_marches(lf)), dcc.Graph(figure=get_map_count_marches(df)),
dcc.Graph( dcc.Graph(
figure=get_barchart_sources(lf, "dateNotification") figure=get_barchart_sources(df, "dateNotification")
), ),
dcc.Graph( dcc.Graph(
figure=get_barchart_sources( figure=get_barchart_sources(
lf, "datePublicationDonnees" df, "datePublicationDonnees"
) )
), ),
], ],
+5 -4
View File
@@ -7,10 +7,10 @@ from dash import Input, Output, State, callback, dash_table, dcc, html, register
from src.utils import ( from src.utils import (
add_links, add_links,
add_resource_link, add_resource_link,
df,
filter_table_data, filter_table_data,
format_montant, format_montant,
format_number, format_number,
lf,
meta_content, meta_content,
setup_table_columns, setup_table_columns,
sort_table_data, sort_table_data,
@@ -19,7 +19,7 @@ from src.utils import (
update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH")) update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y") update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y")
schema = lf.collect_schema() schema = df.collect_schema()
name = "Tableau" name = "Tableau"
register_page( register_page(
@@ -171,10 +171,11 @@ layout = [
State("table", "data_timestamp"), State("table", "data_timestamp"),
) )
def update_table(page_current, page_size, filter_query, sort_by, data_timestamp): def update_table(page_current, page_size, filter_query, sort_by, data_timestamp):
if os.getenv("DEVELOPMENT").lower() == "true":
print(" + + + + + + + + + + + + + + + + + + ") print(" + + + + + + + + + + + + + + + + + + ")
# Application des filtres # Application des filtres
lff: pl.LazyFrame = lf # start from the original data lff: pl.LazyFrame = df.lazy() # start from the original data
if filter_query: if filter_query:
lff = filter_table_data(lff, filter_query) lff = filter_table_data(lff, filter_query)
@@ -238,7 +239,7 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp)
prevent_initial_call=True, prevent_initial_call=True,
) )
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
lff: pl.LazyFrame = lf # start from the original data lff: pl.LazyFrame = df # start from the original data
# Les colonnes masquées sont supprimées # Les colonnes masquées sont supprimées
if hidden_columns: if hidden_columns:
+10 -9
View File
@@ -6,11 +6,11 @@ from dash import Input, Output, State, callback, dash_table, dcc, html, register
from src.figures import point_on_map from src.figures import point_on_map
from src.utils import ( from src.utils import (
add_links_in_dict, add_links_in_dict,
df,
format_montant, format_montant,
format_number, format_number,
get_annuaire_data, get_annuaire_data,
get_departement_region, get_departement_region,
lf,
meta_content, meta_content,
setup_table_columns, setup_table_columns,
) )
@@ -148,22 +148,22 @@ def update_titulaire_infos(url):
Input(component_id="titulaire_data", component_property="data"), Input(component_id="titulaire_data", component_property="data"),
) )
def update_titulaire_stats(data): def update_titulaire_stats(data):
df = pl.DataFrame(data) dff = pl.DataFrame(data)
if df.height == 0: if dff.height == 0:
df = pl.DataFrame(schema=lf.collect_schema()) dff = pl.DataFrame(schema=dff.collect_schema())
df_marches = df.unique("uid") df_marches = dff.unique("uid")
nb_marches = format_number(df_marches.height) nb_marches = format_number(df_marches.height)
# somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item())) # 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"] marches_remportes = [html.Strong(nb_marches), " marchés et accord-cadres remportés"]
# + ", pour un total de ", html.Strong(somme_marches + " €")] # + ", pour un total de ", html.Strong(somme_marches + " €")]
del df_marches del df_marches
nb_acheteurs = df.unique("acheteur_id").height nb_acheteurs = dff.unique("acheteur_id").height
nb_acheteurs = [ nb_acheteurs = [
html.Strong(format_number(nb_acheteurs)), html.Strong(format_number(nb_acheteurs)),
" titulaires (SIRET) différents", " titulaires (SIRET) différents",
] ]
del df del dff
return marches_remportes, nb_acheteurs return marches_remportes, nb_acheteurs
@@ -173,9 +173,10 @@ def update_titulaire_stats(data):
Input(component_id="url", component_property="pathname"), Input(component_id="url", component_property="pathname"),
Input(component_id="titulaire_year", component_property="value"), Input(component_id="titulaire_year", component_property="value"),
) )
def get_titulaire_marches_data(url, titulaire_year: str) -> pl.LazyFrame: def get_titulaire_marches_data(url, titulaire_year: str) -> list[dict]:
titulaire_siret = url.split("/")[-1] titulaire_siret = url.split("/")[-1]
lff = lf.filter( lff = df.lazy()
lff = lff.filter(
(pl.col("titulaire_id") == titulaire_siret) (pl.col("titulaire_id") == titulaire_siret)
& (pl.col("titulaire_typeIdentifiant") == "SIRET") & (pl.col("titulaire_typeIdentifiant") == "SIRET")
) )
+5 -3
View File
@@ -157,7 +157,7 @@ def get_annuaire_data(siret: str) -> dict:
return response.json()["results"][0] return response.json()["results"][0]
def get_decp_data() -> pl.LazyFrame: def get_decp_data() -> pl.DataFrame:
# Chargement du fichier parquet # Chargement du fichier parquet
# Le fichier est chargé en mémoire, ce qui est plus rapide qu'une base de données pour le moment. # Le fichier est chargé en mémoire, ce qui est plus rapide qu'une base de données pour le moment.
# On utilise polars pour la rapidité et la facilité de manipulation des données. # On utilise polars pour la rapidité et la facilité de manipulation des données.
@@ -186,7 +186,7 @@ def get_decp_data() -> pl.LazyFrame:
# ça génère une erreur dans la page acheteur (acheteur_data.table) : # ç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) # AttributeError: partially initialized module 'pandas' has no attribute 'NaT' (most likely due to a circular import)
return lff return lff.collect()
def get_departements() -> dict: def get_departements() -> dict:
@@ -206,11 +206,13 @@ def get_departement_region(code_postal):
def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame: def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
debug = os.getenv("DEVELOPMENT", "False").lower() == "true"
schema = lff.collect_schema() schema = lff.collect_schema()
filtering_expressions = filter_query.split(" && ") filtering_expressions = filter_query.split(" && ")
for filter_part in filtering_expressions: for filter_part in filtering_expressions:
col_name, operator, filter_value = split_filter_part(filter_part) col_name, operator, filter_value = split_filter_part(filter_part)
col_type = str(schema[col_name]) col_type = str(schema[col_name])
if debug:
print("filter_value:", filter_value) print("filter_value:", filter_value)
print("filter_value_type:", type(filter_value)) print("filter_value_type:", type(filter_value))
print("col_type:", col_type) print("col_type:", col_type)
@@ -293,7 +295,7 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup
return columns, tooltip return columns, tooltip
lf = get_decp_data() df: pl.DataFrame = get_decp_data()
departements = get_departements() departements = get_departements()
domain_name = ( domain_name = (
"test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info" "test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"