Merge branch 'main' into feature/28_vue_acheteur

This commit is contained in:
Colin Maudry
2025-09-27 10:08:30 +02:00
14 changed files with 342 additions and 77 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
- name: Checkout repositorypu
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up SSH key
@@ -40,4 +40,4 @@ jobs:
key: ${{ secrets.ARTIFACT_SSH_KEY }}
passphrase: ${{ secrets.SSH_PSWD }}
command_timeout: 5m
script: ${{ secrets.APP_PATH }}/deploy.sh
script: ${{ secrets.APP_PATH }}/deploy.sh ${{ env.APP_NAME }}
+1
View File
@@ -1,3 +1,4 @@
DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432
PORT=8050
DEVELOPMENT=True
SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234"
+16
View File
@@ -0,0 +1,16 @@
decp.info is a web application that enable analysis and download of
French public procurement data.
Copyright (C) 2025 Colin Maudry
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
+33 -3
View File
@@ -1,5 +1,7 @@
# decp.info
> v2.0.1
Outil d'exploration et de téléchargement des données essentielles de la commande publique.
=> [decp.info](https://decp.info)
@@ -10,16 +12,44 @@ Outil d'exploration et de téléchargement des données essentielles de la comma
python -m venv .venv
source .venv/bin/activate
pip install .
# Copie et personnalisation du .env
cp template.env .env
nano .env
# Pour la production
gunicorn app:server
# Pour avoir le debuggage et le hot reload
python run.py
```
## Dépôts de code connexes
## 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).
- **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.
## Liens connexes
- [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données)
- [decp-table-schema](https://github.com/ColinMaudry/decp-table-schema) (schéma de données tabulaire)
- [colin.maudry.com](https://colin.maudry.com) (blog)
## Notes de version
### 2.0.0-alpha
#### 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
Executable
+15
View File
@@ -0,0 +1,15 @@
#!bin/bash
# Utilisé principalement avec les Github Actions
# cf. .github/workflows/deploy.yaml
appname = "$1"
systemctl stop $appname
cd /var/www/$appname
git pull
source .venv/bin/activate
pip install .
deactivate
chown -R $appname:www-data *
systemctl start $appname
+2 -1
View File
@@ -1,13 +1,14 @@
[project]
name = "decp.info"
description = ""
version = "2.0.0"
version = "2.0.1"
requires-python = ">= 3.10"
authors = [
{ name = "Colin Maudry", email = "colin+decp@maudry.com" }
]
dependencies = [
"dash==3.2.0",
"dash[compress]",
"polars",
"gunicorn",
"dash-bootstrap-components",
+14 -1
View File
@@ -2,13 +2,26 @@ import logging
import dash_bootstrap_components as dbc
from dash import Dash, dcc, html, page_container, page_registry
from flask import send_from_directory
app = Dash(external_stylesheets=[dbc.themes.SIMPLEX], title="decp.info", use_pages=True)
app = Dash(
external_stylesheets=[dbc.themes.SIMPLEX],
title="decp.info",
use_pages=True,
compress=True,
)
# COSMO (belle font, blue),
# UNITED (rouge, ubuntu font),
# LUMEN (gros séparateur, blue clair),
# SIMPLEX (rouge, séparateur)
# robots.txt
@app.server.route("/robots.txt")
def robots():
return send_from_directory("./assets", "robots.txt", mimetype="text/plain")
logger = logging.getLogger("decp.info")
logging.basicConfig(
format="%(asctime)s %(levelname)-8s %(message)s",
+41
View File
@@ -0,0 +1,41 @@
# START YOAST BLOCK
# Copié depuis https://next.ink/robots.txt
# ---------------------------
User-agent: *
Allow: /
# ---------------------------
# END YOAST BLOCK
User-agent: GPTBot
Disallow: /
User-agent: ChatGPT-User
Disallow: /
User-agent: Google-Extended
Disallow: /
User-agent: PerplexityBot
Disallow: /
User-agent: Amazonbot
Disallow: /
User-agent: ClaudeBot
Disallow: /
User-Agent: FacebookBot
Disallow: /
User-Agent: Applebot
Disallow: /
User-agent: anthropic-ai
Disallow: /
User-agent: Bytespider
Disallow: /
User-agent: Claude-Web
Disallow: /
User-agent: Diffbot
Disallow: /
User-agent: ImagesiftBot
Disallow: /
User-agent: Omgilibot
Disallow: /
User-agent: Omgili
Disallow: /
User-agent: YouBot
Disallow: /
+8
View File
@@ -10,6 +10,14 @@
float: left;
}
#source_table p {
line-height: 1.5;
}
#source_table {
margin-bottom: 25px;
}
/* Réduire la taille du texte de la colonne Objet */
td[data-dash-column="objet"] {
+82 -15
View File
@@ -2,15 +2,16 @@ import json
import plotly.express as px
import polars as pl
from dash import dash_table, html
def get_map_count_marches(lf):
def get_map_count_marches(lf: pl.LazyFrame):
lf = lf.with_columns(
pl.col("lieuExecution_code").str.head(2).str.zfill(2).alias("Département")
)
lf = (
lf.unique(subset="uid")
.select(["uid", "Département"])
lf.select(["uid", "Département"])
.drop_nulls()
.unique(subset="uid")
.group_by("Département")
.len("uid")
@@ -59,34 +60,100 @@ def get_barchart_sources(lf: pl.LazyFrame, type_date: str):
"datePublicationDonnees": "publication des données",
}
lf = lf.select("uid", type_date, "source")
lf = lf.select("uid", type_date, "sourceDataset")
lf = lf.unique("uid")
# Rassemblement des datasets Atexo pour ne pas surcharger le graphique
lf = lf.with_columns(
pl.when(pl.col("sourceDataset").str.starts_with("atexo"))
.then(pl.lit("plateformes atexo"))
.otherwise(pl.col("sourceDataset"))
.alias("sourceDataset")
)
# Rassemblement des datasets AWS pour ne pas surcharger le graphique
lf = lf.with_columns(
pl.when(pl.col("sourceDataset").str.contains(r"aws|marches\-publics.info"))
.then(pl.lit("aws"))
.otherwise(pl.col("sourceDataset"))
.alias("sourceDataset")
)
lf = lf.with_columns(pl.col(type_date).dt.year().alias("annee"))
lf = lf.filter(
pl.col(type_date).is_not_null() & pl.col("annee").is_between(2018, 2025)
pl.col(type_date).is_not_null() & pl.col("annee").is_between(2019, 2025)
)
lf = lf.sort(by=[type_date, "source"], descending=False)
lf = lf.with_columns(pl.col(type_date).cast(pl.String).str.head(7))
lf = (
lf.group_by([type_date, "sourceDataset"])
.len()
.sort(by=[type_date, "len"], descending=True)
)
lf = lf.group_by([type_date, "source"]).len()
lf = lf.with_columns(
pl.when(pl.col("source").is_null()).then(
pl.lit("Source inconnue").alias("source")
)
)
lf = lf.sort(by=[type_date, "source"], descending=False)
# lf = lf.with_columns(
# pl.when(pl.col("sourceDataset").is_null()).then(
# pl.lit("Source inconnue")).alias("sourceDataset")
# )
lf = lf.sort(by=["sourceDataset"], descending=False)
df: pl.DataFrame = lf.collect()
fig = px.bar(
df,
x=type_date,
y="len",
color="source",
color="sourceDataset",
title=f"Nombre de marchés attribués par date de {labels[type_date]} et source de données",
labels={
"len": "Nombre de marchés",
type_date: f"Mois de {labels[type_date]}",
"source": "Source de données",
"sourceDataset": "Source de données",
},
)
return fig
def get_sources_tables(source_path) -> html.Div:
df = pl.read_csv(source_path)
df = df.with_columns(
(
pl.lit('<a href = "')
+ pl.col("url")
+ pl.lit('">')
+ pl.col("nom")
+ pl.lit("</a>")
).alias("nom")
)
df = df.drop("url")
df = df.sort(by=["nb_marchés"], descending=True)
datatable = dash_table.DataTable(
id="source_table",
columns=[
{
"name": i,
"id": i,
"presentation": "markdown",
"type": "text",
"format": {"nully": "N/A"},
}
for i in df.schema.names()
],
style_cell_conditional=[
{
"if": {"column_id": ["nom", "organisation"]},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
],
sort_action="native",
markdown_options={"html": True},
)
datatable.data = df.to_dicts()
return html.Div(children=datatable)
+62 -10
View File
@@ -1,7 +1,14 @@
import os
from dash import dcc, html, register_page
from dotenv import load_dotenv
from src.figures import get_sources_tables
title = "À propos"
load_dotenv()
register_page(
__name__, path="/a-propos", title=f"decp.info - {title}", name=title, order=5
)
@@ -12,7 +19,7 @@ layout = [
children=[
html.H2(title),
dcc.Markdown(
"""Outil d'exploration des [Données Essentielles de la Commande Publique](), développé par Colin Maudry, sous licence GPL v3 (libre et gratuit).
"""Outil d'exploration libre et gratuit des données de marchés publics, développé par Colin Maudry.
Ce projet vise à démocratiser l'accès aux données des marchés publics et à un outil performant et gratuit. Si vous le trouvez utile
j'aimerais beaucoup échanger avec vous pour comprendre vos cas d'usages et vos besoins. Cet outil ne peut rester performant que si je comprends les problèmes qu'il peut aider à résoudre. Ce projet ne peut rester gratuit que grâce au financement du développement de nouvelles fonctionnalités.
@@ -21,28 +28,73 @@ En effet, le potentiel des données d'attribution de marchés et des données qu
les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
- de nombreuses visualisations de données (cartes, graphiques, tableaux) sur des thématiques variées (vivacité de la concurrence, secteurs d'activité, insertion par l'activité économique (IAE), distance acheteur-fournisseur...)
- la sauvegarde de filtre pour les retrouver plus tard et les partager
- la sauvegarde de filtres pour les retrouver plus tard et les partager
- des alertes par email si des marchés correspondant à certains critères
- le développement d'une API pour alimenter d'autres logiciels
- ...et toutes les fonctionnalités auxquelles vous pourrez penser :)
- ...et toutes les fonctionnalités auxquelles vous pourrez penser
"""
),
html.H4("Pour contribuer", id="contribuer"),
dcc.Markdown("""
- via l'achat d'une prestation de service (devis, prestation, facture), vous pouvez financer le développement de [fonctionnalités prévues](https://github.com/ColinMaudry/decp.info/issues), ou d'autres !
- ma société accepte aussi les dons (pas de réduction d'impôt possible)
- envoyez un mail et on discute !
#### Pour aller plus loin
#### Pour explorer le projet
- ✉️ [inscription à la liste de diffusion](https://6254d9a3.sibforms.com/serve/MUIFAEonUVkoSVrdgey18CTgLyI16xw4yeu-M-YOUzhWE_AgfQfbgkyT7GvA_RYLro9MfuRqkzQxSvu7-uzbMSv2a2ZQPsliM7wtiiqIL8kR2zOvl6m11fb5qjcOxMAYsLiY_YBi3P7NY95CTJ8vRY4CpsDclF2iLooOElKkTgIgi5nePe7zAIrgiYM5v2EuALlGJZMEG9vBP-Cu) (annonces des mises à jour et évènements, maximum une fois par mois)
- 💾 [données consolidées en Open Data](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire/)
- 🗞️ [mon blog](https://colin.maudry.com), qui parle beaucoup de transparence des marchés publics
- 📔 [wiki du projet](https://github.com/ColinMaudry/decp-processing/wiki)
- 🚰 code source
- [de decp.info](https://github.com/ColinMaudry/decp.info)
- [du traitement des données](https://github.com/ColinMaudry/decp-processing)
#### Contact
- venez discuter de la transparence de la commande publique [sur le forum teamopendata.org](https://teamopendata.org/c/commande-publique/101)
"""),
html.H4("Contact", id="contact"),
dcc.Markdown("""
- Email : [colin+decp@maudry.com](mailto:colin+decp@maudry.com)
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
"""
),
- venez discuter de la transparence de la commande publique [sur le forum teamopendata.org](https://teamopendata.org/c/commande-publique/101)
"""),
html.H4("Sources de données", id="sources"),
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
html.H4("Mentions légales", id="mentions-legales"),
dcc.Markdown("""
##### Publication
Site Web développé et édité par [SAS Colmo](https://annuaire-entreprises.data.gouv.fr/entreprise/colmo-989393350), 989 393 350 RCS Rennes au capital de 3 000 euros.
Siège social : 1 carrefour Jouaust, 35000 Rennes
Hébergement : serveur situé en France et administré par Scaleway, 8 rue de la Ville lEvêque, 75008 Paris
##### Suivi d'audience
Ce site dépose un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le consultez ([Wikipédia](https://fr.wikipedia.org/wiki/Cookie_(informatique))). Cela me permet de mesurer le nombre de visites, de distinguer les nouveaux visiteurs des utilisateurs réguliers et ainsi de communiquer sur l'impact de decp.info.
**Ce site naffiche pas de bannière de consentement aux cookies, pourquoi ?**
Cest vrai, vous navez pas eu à cliquer sur un bloc qui recouvre la moitié de la page pour dire que vous êtes daccord avec le dépôt de cookies.
Rien dexceptionnel, je respecte simplement la loi, qui dit que certains outils de suivi daudience, correctement configurés pour respecter la vie privée, sont exemptés dautorisation préalable.
Jutilise pour cela [Matomo](https://matomo.org/), un outil [libre](https://matomo.org/free-software/), paramétré pour être en conformité avec [la recommandation « Cookies »](https://www.cnil.fr/fr/solutions-pour-les-cookies-de-mesure-daudience) de la CNIL. Cela signifie que votre adresse IP, par exemple, est anonymisée avant d’être enregistrée. Il mest donc impossible dassocier vos visites sur ce site à votre personne."""),
# Matomo propose cependant ce formulaire si vous souhaitez totalement désactiver le suivi de vos sessions sur ce site :"""),
# html.Div(
# id="matomo-opt-out",
# style={
# "border": "1pt solid lightgrey",
# "padding": "12px",
# "margin": "auto 0 auto 12px",
# "width": "80%",
# },
# children=["Vous utilisez un bloqueur de suivi de trafic."],
# ),
# html.Script(
# src="https://analytics.maudry.com/index.php?module=CoreAdminHome&action=optOutJS&divId=matomo-opt-out&language=auto&showIntro=1"
# ),
],
)
]
+41 -8
View File
@@ -24,8 +24,6 @@ df_filtered = pl.DataFrame()
# Suppression des colonnes inutiles
lf = lf.drop(
[
"titulaire_siren",
"acheteur_siren",
"donneesActuelles",
]
)
@@ -81,6 +79,14 @@ datatable = dash_table.DataTable(
"lineHeight": "14px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_nom"},
"minWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
],
data_timestamp=0,
markdown_options={"html": True},
@@ -90,7 +96,9 @@ layout = [
html.Div(
html.Details(
children=[
html.Summary(html.H3("Mode d'emploi")),
html.Summary(
html.H3("Mode d'emploi", style={"text-decoration": "underline"}),
),
dcc.Markdown(
"""
@@ -129,7 +137,11 @@ layout = [
html.Div(
[
html.P("lignes", id="nb_rows"),
html.Button("Télécharger au format Excel", id="btn-download-data"),
html.Button(
"Téléchargement désactivé au-delà de 65 000 lignes",
id="btn-download-data",
disabled=True,
),
dcc.Download(id="download-data"),
html.P("Données mises à jour le " + str(update_date)),
],
@@ -145,6 +157,9 @@ layout = [
Output("table", "data"),
Output("table", "data_timestamp"),
Output("nb_rows", "children"),
Output("btn-download-data", "disabled"),
Output("btn-download-data", "children"),
Output("btn-download-data", "title"),
Input("table", "page_current"),
Input("table", "page_size"),
Input("table", "filter_query"),
@@ -203,7 +218,9 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp)
df_filtered = dff.clone()
nb_rows = f"{format_number(dff.height)} lignes"
height = dff.height
nb_rows = f"{format_number(height)} lignes"
# Pagination des données
start_row = page_current * page_size
@@ -211,7 +228,23 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp)
dff = dff.slice(start_row, page_size)
dicts = dff.to_dicts()
return dicts, data_timestamp + 1, nb_rows
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,
data_timestamp + 1,
nb_rows,
download_disabled,
download_text,
download_title,
)
@callback(
@@ -227,8 +260,8 @@ def download_data(n_clicks, hidden_columns: list = None):
# Rétablissement des colonnes source et sourceOpenData (voir add_resource_link)
df_to_download = df_to_download.with_columns(
pl.col("source").str.extract(r'href="(.*?)"').alias("sourceOpenData"),
pl.col("source").str.extract(r'">(.*?)<').alias("source"),
pl.col("source").str.extract(r'href="(.*?)"').alias("sourceFile"),
pl.col("source").str.extract(r'">(.*?)<').alias("sourceDataset"),
)
# Les colonnes masquées sont supprimées
+11 -21
View File
@@ -15,33 +15,25 @@ layout = [
className="container",
children=[
html.H2(title),
dcc.Markdown("""
À savoir, les données suivantes existent mais sont en cours d'intégration dans ce projet :
- les données publiées dans le [format DECP 2019 (période 2018-2022)](https://www.data.gouv.fr/fr/datasets/donnees-essentielles-de-la-commande-publique-fichiers-consolides/#/resources/16962018-5c31-4296-9454-5998585496d2)
- les données [collectées par l'AIFE](https://github.com/ColinMaudry/decp-processing/issues/68) (API DUME, notamment achatpublic.info)
- les données [des plateformes Atexo](https://github.com/ColinMaudry/decp-processing/issues/57)
"""),
dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-1",
id="loading-statistques",
type="default",
children=[
html.Div(
children=[
dcc.Loading(
overlay_style={
"visibility": "visible",
"filter": "blur(2px)",
},
id="loading-stats",
type="default",
children=[
dcc.Markdown("""
La publication de données essentielles de marchés publics (DECP) est souvent effectuée par
les plateformes de marchés publics (profils d'acheteurs). Cependant, certaines plateformes ne publient pas,
ou publient d'une manière qui rend la récupération des données compliquée. Les données présentées sur ce site
ne représentent donc pas tous les marchés attribués en France, seulement une partie significative.
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_barchart_sources(
lf, "dateNotification"
)
figure=get_barchart_sources(lf, "dateNotification")
),
dcc.Graph(
figure=get_barchart_sources(
@@ -50,8 +42,6 @@ layout = [
),
],
)
]
),
],
),
],
+4 -6
View File
@@ -44,10 +44,10 @@ def split_filter_part(filter_part):
def add_resource_link(lff: pl.LazyFrame) -> pl.LazyFrame:
lff = lff.with_columns(
(
'<a href="' + pl.col("sourceOpenData") + '">' + pl.col("source") + "</a>"
'<a href="' + pl.col("sourceFile") + '">' + pl.col("sourceDataset") + "</a>"
).alias("source")
)
lff = lff.drop("sourceOpenData")
lff = lff.drop(["sourceFile", "sourceDataset"])
return lff
@@ -117,14 +117,12 @@ def get_decp_data() -> pl.LazyFrame:
logger.info(
f"Lecture du fichier parquet ({os.getenv('DATA_FILE_PARQUET_PATH')})..."
)
df: pl.DataFrame = pl.read_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
lff: pl.LazyFrame = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
except ComputeError:
# Le fichier est probablement en cours de mise à jour
logger.info("Échec, nouvelle tentative dans 10s...")
sleep(10)
df: pl.DataFrame = pl.read_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
lff: pl.LazyFrame = df.lazy()
lff: pl.LazyFrame = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
# Remplacement des valeurs numériques par des chaînes de caractères
# lff = numbers_to_strings(lff)