Compare commits

...

16 Commits

Author SHA1 Message Date
Colin Maudry 59abf35a12 Merge branch 'hotfix/2.1.4' 2025-10-08 09:34:00 +02:00
Colin Maudry a40eb64245 Changelog 2.1.4 2025-10-08 09:33:51 +02:00
Colin Maudry acae1517cb Nouvelle tentative auto-release 2025-10-08 09:31:41 +02:00
Colin Maudry 9a9decde6e Possible de filtrer sur le champ 'Source' 2025-10-08 09:26:44 +02:00
Colin Maudry a1ae962c73 Changelog 2.1.3 2025-10-04 19:15:56 +02:00
Colin Maudry 0404aa0973 Adaptation au format TableSchema 2025-10-04 19:13:51 +02:00
Colin Maudry effe01dedf Tentative d'auto release à partir d'un tag 2025-10-04 19:13:25 +02:00
Colin Maudry aaa70039d2 Merge branch 'hotfix/2.1.2' 2025-10-03 16:45:35 +02:00
Colin Maudry 7dc5f09545 Changelog 2.1.2 2025-10-03 16:45:29 +02:00
Colin Maudry 91e7f373ec Coquille 2025-10-03 16:42:44 +02:00
Colin Maudry c7768ea8f1 Global lf => global df pour être moins sensible au update de fichier source 2025-10-03 16:29:58 +02:00
Colin Maudry c2fab377d5 Merge branch 'hotfix/2.1.1' 2025-10-01 13:22:07 +02:00
Colin Maudry d7cdf38cda Changelog 2.1.1 2025-10-01 13:22:02 +02:00
Colin Maudry 85f0084b7b Exhaustivité et qualité des données close #43 2025-10-01 13:19:56 +02:00
Colin Maudry 712dc5a7f8 Rétrécissement de la carte 2025-10-01 13:18:58 +02:00
Colin Maudry 84b94fd882 Affiche du nombre de marchés 2025-10-01 11:57:55 +02:00
12 changed files with 149 additions and 57 deletions
+29
View File
@@ -0,0 +1,29 @@
on:
push:
# Sequence of patterns matched against refs/tags
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
name: Create Release
jobs:
build:
name: Auto-release d'un tag
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@master
- name: Get tag message
run: echo "TAG_MESSAGE=`git show ${{ github.ref_name }} | grep '^\- '`" >> "$GITHUB_ENV"
- name: Create Release
id: create_release
uses: actions/create-release@latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
TAG_MESSAGE: ${{ env.TAG_MESSAGE }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref_name }}
body: ${{ env.TAG_MESSAGE }}
draft: false
prerelease: false
+24 -3
View File
@@ -1,6 +1,6 @@
# decp.info
> v2.1.0
> v2.1.4
Outil d'exploration et de téléchargement des données essentielles de la commande publique.
@@ -26,9 +26,11 @@ python run.py
## Déploiement
- **Production** (branche `main`, [decp.info](https://decp.info)) : déploiement manuel via un déclenchement de la Github Action [Déploiement](https://github.com/ColinMaudry/decp.info/actions/workflows/deploy.yaml).
- **Production** (branche `main`, [decp.info](https://decp.info)) : déploiement manuel via un déclenchement de la Github Action [Déploiement](https://github.com/ColinMaudry/decp.info/actions/workflows/deploy.yaml)
- **Test** (branche `dev`, [test.decp.info](https://test.decp.info)) : déploiement automatique à chaque push sur la branche `dev`, via la même Github Action.
Ne pas oublier de mettre à jour les fichier .env.
## Liens connexes
- [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données)
@@ -36,7 +38,26 @@ python run.py
## Notes de version
#### 2.1.0
##### 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 🖼️
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "decp.info"
description = "Interface d'exploration et d'analyse des marchés publics français."
version = "2.1.0"
version = "2.1.4"
requires-python = ">= 3.10"
authors = [
{ name = "Colin Maudry", email = "colin+decp@maudry.com" }
Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

+8 -6
View File
@@ -5,7 +5,8 @@ import polars as pl
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(
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"]:
f["id"] = f["properties"]["code"]
df = lf.collect()
df = lf.collect(engine="streaming")
fig = px.choropleth(
df,
@@ -38,8 +39,8 @@ def get_map_count_marches(lf: pl.LazyFrame):
range_color=(df["uid"].min(), df["uid"].max()),
labels={"uid": "Marchés attribués"},
scope="europe",
width=1000,
height=800,
width=900,
height=700,
)
fig.update_geos(fitbounds="locations", visible=False)
@@ -54,7 +55,8 @@ def get_map_count_marches(lf: pl.LazyFrame):
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 = {
"dateNotification": "notification",
"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)
df: pl.DataFrame = lf.collect()
df: pl.DataFrame = lf.collect(engine="streaming")
fig = px.bar(
df,
+14 -1
View File
@@ -54,7 +54,20 @@ les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
- [de decp.info](https://github.com/ColinMaudry/decp.info)
- [du traitement des données](https://github.com/ColinMaudry/decp-processing)
"""),
html.H4("Sources de données", id="sources"),
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.
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 :
- marches-securises.fr
- klekoon.fr (ils y travaillent)
**marches-publics.gouv.fr** (PLACE, [AIFE](https://aife.economie.gouv.fr)), la plateforme des marchés de l'État, ne publie plus de données depuis le 1er janvier 2024 et l'entrée en vigueur de l'arrêté. Nous n'avons donc plus de données sur les marchés publics passés par les ministères, ainsi que sur ceux passés par les acheteurs clients d'[achatpublic.com](https://www.achatpublic.com), qui s'appuie sur les moyens de publication de l'AIFE.
**marches-publics.info** (AWS) publie ses données de manière assez sporadique depuis début 2023. Compte tenu de son poids dans le secteur, c'est assez dommageable pour la transparence des marchés publics.
Au milieu de ces mauvaises nouvelles, je tiens à souligner la belle continuité de la publication par la DGFiP des données des marchés publics remontées via le [protocole PES](https://www.collectivites-locales.gouv.fr/finances-locales/le-protocole-dechange-standard-pes). Merci à leurs équipes."""),
html.H4("Sources de données ", id="qualite-exhausitivite"),
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
html.H4("Mentions légales", id="mentions-legales"),
dcc.Markdown("""
+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.utils import (
add_links_in_dict,
df,
format_montant,
format_number,
get_annuaire_data,
get_departement_region,
lf,
meta_content,
setup_table_columns,
)
@@ -148,7 +148,7 @@ def update_acheteur_infos(url):
def update_acheteur_stats(data):
df = pl.DataFrame(data)
if df.height == 0:
df = pl.DataFrame(schema=lf.collect_schema())
df = pl.DataFrame(schema=df.collect_schema())
df_marches = df.unique("id")
nb_marches = format_number(df_marches.height)
# 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="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]
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.select(
"id",
+7 -5
View File
@@ -4,7 +4,7 @@ import polars as pl
from dash import Input, Output, callback, dcc, html, register_page
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(
__name__,
@@ -62,11 +62,12 @@ layout = [
Output("titulaires_data", "data"),
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]
# Récupération des données du marché à partir du lf global
lff = lf.filter(pl.col("uid") == pl.lit(marche_uid))
# Récupération des données du marché à partir du df global
lff = df.lazy()
lff = lff.filter(pl.col("uid") == pl.lit(marche_uid))
# Données des titulaires du marché
dff_titulaires = lff.select(cs.starts_with("titulaire")).collect(engine="streaming")
@@ -92,7 +93,8 @@ def get_marche_data(url):
def update_marche_info(marche, titulaires):
def make_parameter(col):
column_object = data_schema.get(col)
column_name = column_object.get("friendly_name") if column_object else col
print(column_object)
column_name = column_object.get("title") if column_object else col
if marche[col]:
if col == "acheteur_nom":
+4 -4
View File
@@ -1,7 +1,7 @@
from dash import dcc, html, register_page
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"
@@ -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),
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(
figure=get_barchart_sources(lf, "dateNotification")
figure=get_barchart_sources(df, "dateNotification")
),
dcc.Graph(
figure=get_barchart_sources(
lf, "datePublicationDonnees"
df, "datePublicationDonnees"
)
),
],
+7 -7
View File
@@ -7,10 +7,10 @@ from dash import Input, Output, State, callback, dash_table, dcc, html, register
from src.utils import (
add_links,
add_resource_link,
df,
filter_table_data,
format_montant,
format_number,
lf,
meta_content,
setup_table_columns,
sort_table_data,
@@ -19,7 +19,7 @@ from src.utils import (
update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y")
schema = lf.collect_schema()
schema = df.collect_schema()
name = "Tableau"
register_page(
@@ -171,10 +171,11 @@ layout = [
State("table", "data_timestamp"),
)
def update_table(page_current, page_size, filter_query, sort_by, data_timestamp):
print(" + + + + + + + + + + + + + + + + + + ")
if os.getenv("DEVELOPMENT").lower() == "true":
print(" + + + + + + + + + + + + + + + + + + ")
# 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:
lff = filter_table_data(lff, filter_query)
@@ -188,8 +189,7 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp)
dff: pl.DataFrame = lff.collect()
height = dff.height
nb_rows = f"{format_number(height)} lignes"
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
@@ -239,7 +239,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 = lf # start from the original data
lff: pl.LazyFrame = df # start from the original data
# Les colonnes masquées sont supprimées
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.utils import (
add_links_in_dict,
df,
format_montant,
format_number,
get_annuaire_data,
get_departement_region,
lf,
meta_content,
setup_table_columns,
)
@@ -148,22 +148,22 @@ def update_titulaire_infos(url):
Input(component_id="titulaire_data", component_property="data"),
)
def update_titulaire_stats(data):
df = pl.DataFrame(data)
if df.height == 0:
df = pl.DataFrame(schema=lf.collect_schema())
df_marches = df.unique("uid")
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_acheteurs = df.unique("acheteur_id").height
nb_acheteurs = dff.unique("acheteur_id").height
nb_acheteurs = [
html.Strong(format_number(nb_acheteurs)),
" titulaires (SIRET) différents",
]
del df
del dff
return marches_remportes, nb_acheteurs
@@ -173,9 +173,10 @@ def update_titulaire_stats(data):
Input(component_id="url", component_property="pathname"),
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]
lff = lf.filter(
lff = df.lazy()
lff = lff.filter(
(pl.col("titulaire_id") == titulaire_siret)
& (pl.col("titulaire_typeIdentifiant") == "SIRET")
)
+40 -17
View File
@@ -43,9 +43,9 @@ def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
dff = dff.with_columns(
(
'<a href="' + pl.col("sourceFile") + '">' + pl.col("sourceDataset") + "</a>"
).alias("source")
).alias("sourceDataset")
)
dff = dff.drop(["sourceFile", "sourceDataset"])
dff = dff.drop(["sourceFile"])
return dff
@@ -157,7 +157,7 @@ def get_annuaire_data(siret: str) -> dict:
return response.json()["results"][0]
def get_decp_data() -> pl.LazyFrame:
def get_decp_data() -> pl.DataFrame:
# 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.
# 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) :
# 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:
@@ -206,14 +206,17 @@ def get_departement_region(code_postal):
def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
debug = os.getenv("DEVELOPMENT", "False").lower() == "true"
schema = lff.collect_schema()
filtering_expressions = filter_query.split(" && ")
for filter_part in filtering_expressions:
col_name, operator, filter_value = split_filter_part(filter_part)
col_type = str(schema[col_name])
print("filter_value:", filter_value)
print("filter_value_type:", type(filter_value))
print("col_type:", col_type)
if debug:
print("filter_value:", filter_value)
print("filter_value_type:", type(filter_value))
print("col_type:", col_type)
if col_type == "Date":
# Convertir la colonne en chaînes de caractères
@@ -268,7 +271,7 @@ 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("friendly_name", column_id)
column_name = column_object.get("title", column_id)
else:
column_name = column_id
@@ -284,7 +287,7 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup
if column_object:
tooltip[column_id] = {
"value": f"""**{column_object.get("friendly_name")}** ({column_id})
"value": f"""**{column_object.get("title")}** ({column_id})
"""
+ column_object["description"],
@@ -293,7 +296,33 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup
return columns, tooltip
lf = get_decp_data()
def get_data_schema() -> dict:
# Récupération du schéma des données tabulaires
path = os.getenv("DATA_SCHEMA_PATH")
if path.startswith("http"):
original_schema: dict = get(
os.getenv("DATA_SCHEMA_PATH"), follow_redirects=True
).json()
elif os.path.exists(path):
with open(path) as f:
original_schema: dict = json.load(f)
else:
raise Exception(f"Chemin vers le schéma invalide: {path}")
new_schema = {}
for col in original_schema["fields"]:
new_schema[col["name"]] = col
new_schema["sourceDataset"] = {
"description": "Code de la source des données, avec un lien vers le fichier Open Data dont proviennent les données de ce marché public.",
"title": "Source des données",
"short_name": "Source",
}
return new_schema
df: pl.DataFrame = get_decp_data()
departements = get_departements()
domain_name = (
"test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"
@@ -306,10 +335,4 @@ meta_content = {
"Pour une commande publique accessible à toutes et tous."
),
}
# Récupération du schéma des données tabulaires
data_schema: dict = get(os.getenv("DATA_SCHEMA_PATH"), follow_redirects=True).json()
data_schema["source"] = {
"description": "Code de la source des données, avec un lien vers le fichier Open Data dont proviennent les données de ce marché public.",
"friendly_name": "Source des données",
}
data_schema = get_data_schema()