Merge branch 'release/2.4.0'

This commit is contained in:
Colin Maudry
2026-01-22 17:57:53 +01:00
16 changed files with 569 additions and 219 deletions
+7
View File
@@ -1,3 +1,10 @@
#### 2.4.0
- Site à peu près utilisable sur petit écran (smartphone) ([#63](https://github.com/ColinMaudry/decp.info/issues/63))
- Amélioration du référencement Web (sitemap, titres, descriptions) ([#50](https://github.com/ColinMaudry/decp.info/issues/50))
- Possibilité dans les champs non-numériques de filtrer le texte selon son début ou sa fin (`text*` et `*text`)
- Ajout d'une table des matières dans la page [À propos](https://decp.infi/a-propos) ([#36](https://github.com/ColinMaudry/decp.info/issues/36))
##### 2.3.1 (16 janvier 2026) ##### 2.3.1 (16 janvier 2026)
- Les champs absents du [schéma](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire?resource_id=9a4144c0-ee44-4dec-bee5-bbef38191d9a) sont ignorés pour éviter les erreurs - Les champs absents du [schéma](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire?resource_id=9a4144c0-ee44-4dec-bee5-bbef38191d9a) sont ignorés pour éviter les erreurs
+1 -1
View File
@@ -1,6 +1,6 @@
# decp.info # decp.info
> v2.3.1 > v2.4.0
> 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.
=> [decp.info](https://decp.info) => [decp.info](https://decp.info)
+2 -2
View File
@@ -1,13 +1,13 @@
[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.3.1" version = "2.4.0"
requires-python = ">= 3.10" requires-python = ">= 3.10"
authors = [ authors = [
{ name = "Colin Maudry", email = "colin@colmo.tech" } { name = "Colin Maudry", email = "colin@colmo.tech" }
] ]
dependencies = [ dependencies = [
"dash==3.3.0", "dash==3.4.0",
"dash[compress]", "dash[compress]",
"polars", "polars",
"gunicorn", "gunicorn",
+91 -31
View File
@@ -3,9 +3,9 @@ import os
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
import tomllib import tomllib
from dash import Dash, dcc, html, page_container, page_registry from dash import Dash, Input, Output, State, dcc, html, page_container, page_registry
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import send_from_directory from flask import Response, send_from_directory
load_dotenv() load_dotenv()
@@ -14,6 +14,9 @@ app = Dash(
title="decp.info", title="decp.info",
use_pages=True, use_pages=True,
compress=True, compress=True,
meta_tags=[
{"name": "viewport", "content": "width=device-width, initial-scale=1"},
],
) )
# COSMO (belle font, blue), # COSMO (belle font, blue),
# UNITED (rouge, ubuntu font), # UNITED (rouge, ubuntu font),
@@ -27,6 +30,25 @@ def robots():
return send_from_directory("./assets", "robots.txt", mimetype="text/plain") return send_from_directory("./assets", "robots.txt", mimetype="text/plain")
@app.server.route("/sitemap.xml")
def sitemap():
base_url = "https://decp.info"
pages = [
"/",
"/statistiques",
"/tableau",
"/a-propos",
]
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for page in pages:
xml += " <url>\n"
xml += f" <loc>{base_url}{page}</loc>\n"
xml += " </url>\n"
xml += "</urlset>"
return Response(xml, mimetype="text/xml")
logger = logging.getLogger("decp.info") logger = logging.getLogger("decp.info")
logging.basicConfig( logging.basicConfig(
format="%(asctime)s %(levelname)-8s %(message)s", format="%(asctime)s %(levelname)-8s %(message)s",
@@ -73,43 +95,81 @@ app.index_string = """
</html> </html>
""" """
app.layout = html.Div( navbar = dbc.Navbar(
[ dbc.Container(
html.Div( fluid=True,
[ children=[
html.Div( dbc.NavItem(
[ children=[
html.A(children=html.H1("decp.info"), href="/"), dcc.Link(html.H1("decp.info"), href="/", className="logo"),
html.P( html.P(
children=html.A( [
html.A(
version, version,
href="https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md", href="https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md",
target="_blank", )
), ],
className="version", className="version",
), ),
], ],
className="logo", style={"minWidth": "230px"},
), ),
html.Div( dbc.Nav(
id="announcements", children=[dcc.Markdown(os.getenv("ANNOUNCEMENTS"), id="announcements")],
children=dcc.Markdown(os.getenv("ANNOUNCEMENTS")), style={
), "maxWidth": "1200px",
html.Div( "display": "inline-block",
},
navbar=True,
id="announcements-nav",
),
dbc.NavbarToggler(id="navbar-toggler"),
dbc.Collapse(
dbc.Nav(
[ [
dcc.Link( dbc.NavItem(
page["name"], href=page["relative_path"], className="nav" dbc.NavLink(
page["name"].replace(" ", " "),
href=page["relative_path"],
active="exact",
)
) )
for page in page_registry.values() for page in page_registry.values()
if page["name"] not in ["Acheteur", "Titulaire", "Marché"] if page["name"] not in ["Acheteur", "Titulaire", "Marché"]
] ],
className="ms-auto",
navbar=True,
), ),
], id="navbar-collapse",
className="navbar", navbar=True,
),
],
),
color="light",
dark=False,
className="mb-4",
expand="lg",
)
app.layout = html.Div(
[
navbar,
dbc.Container(
page_container,
fluid=True,
id="page-content-container",
className="mb-4",
), ),
page_container,
] ]
) )
if __name__ == "__main__":
app.run(debug=True) @app.callback(
Output("navbar-collapse", "is_open"),
[Input("navbar-toggler", "n_clicks")],
[State("navbar-collapse", "is_open")],
)
def toggle_navbar_collapse(n, is_open):
if n:
return not is_open
return is_open
-33
View File
@@ -6,36 +6,3 @@ Allow: /
# --------------------------- # ---------------------------
# END YOAST BLOCK # 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: /
+89 -45
View File
@@ -28,8 +28,10 @@ details > div {
/* Logo et version */ /* Logo et version */
div.logo { a.logo {
width: 230px; color: black;
text-decoration: none;
float: left;
} }
p.version { p.version {
@@ -40,15 +42,11 @@ p.version {
p.version > a { p.version > a {
text-decoration: none; text-decoration: none;
margin-top: 10px;
} }
div.logo h1 { .navbar-brand {
float: left; margin-right: 2px;
}
div.logo > a {
text-decoration: none;
color: black;
} }
/* Réduire la taille du texte de la colonne Objet */ /* Réduire la taille du texte de la colonne Objet */
@@ -130,7 +128,8 @@ td[data-dash-column="objet"],td[data-dash-column="titulaire_nom"],td[data-dash-c
} }
/* Alternance des couleurs pour les lignes */ /* Alternance des couleurs pour les lignes */
.marches_table { .marches_table table td,
.marches_table table th {
font-family: "Open Sans", sans-serif; font-family: "Open Sans", sans-serif;
} }
@@ -151,9 +150,9 @@ td[data-dash-column="objet"],td[data-dash-column="titulaire_nom"],td[data-dash-c
/* Annonces */ /* Annonces */
#announcements { #announcements {
margin-top: 25px; margin: 25px 40px 0 60px;
max-width: 30%;
font-size: 90%; font-size: 90%;
max-width: 900px;
} }
/* Page de recherche */ /* Page de recherche */
@@ -194,22 +193,8 @@ td[data-dash-column="objet"],td[data-dash-column="titulaire_nom"],td[data-dash-c
/* Menu de navigation */ /* Menu de navigation */
.navbar {
width: 100%;
height: 100px;
padding: 0 10px;
border: 0;
border-bottom: solid 1px black;
}
a.nav {
float: left;
margin-right: 40px;
font-size: 120%;
}
h3 { h3 {
margin: 36px 0 24px 0; margin: 36px 0 20px 0;
} }
summary > h3 { summary > h3 {
@@ -263,30 +248,89 @@ summary > h3 {
grid-row: 3; grid-row: 3;
} }
/* Vue marché */ @media (max-width: 992px) {
#announcements-nav {
.marche_infos p { display: none !important;
margin-top: 6px; }
margin-bottom: 6px;
} }
.marche_title { /* CSS for the /a-propos page Layout and Table of Contents */
grid-column: 1/3;
grid-row: 1; .a-propos-container {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
position: relative;
max-width: 1200px;
margin: 0 auto;
} }
.marche_map { .a-propos-content {
grid-column: 3; flex: 1 1 70%;
grid-row: 2; max-width: 75%;
width: 400px; padding-right: 40px;
} }
.marche_infos_1 { .a-propos-toc {
grid-column: 1; flex: 0 0 25%;
grid-row: 2; max-width: 25%;
/* Keeps it from growing too large */
position: sticky;
top: 40px;
/* Sticks 40px from the top of the viewport */
border-left: 2px solid #333;
/* Dark vertical line like hedgedoc */
padding-left: 15px;
margin-top: 40px;
background-color: #fff;
/* Aligns visually with the first header */
} }
.marche_infos_2 { /* Hide TOC on smaller screens */
grid-column: 2; @media (max-width: 992px) {
grid-row: 2; .a-propos-content {
max-width: 100%;
padding-right: 0;
flex: 1 1 100%;
}
.a-propos-toc {
display: none;
}
}
/* Styling for TOC links */
.toc-link {
display: block;
color: #666;
text-decoration: none;
font-size: 0.9em;
padding: 2px 0;
transition: color 0.2s, font-weight 0.2s;
line-height: 1.4;
}
.toc-link:hover {
color: #000;
text-decoration: none;
}
.toc-active {
color: #000;
font-weight: bold;
}
/* Indentation for H5 levels (Level 2 in our simplified TOC) */
/* Using specific classes if I generate them, or just generic hierarchy if nested */
.toc-level-2 {
margin-left: 15px;
font-size: 0.85em;
}
/* Header title for TOC (optional) */
.toc-header {
font-weight: bold;
margin-bottom: 10px;
display: block;
color: #333;
} }
+1 -1
View File
@@ -21,7 +21,7 @@ def get_top_org_table(data, org_type: str):
dff_nb = dff_nb.fill_null("") dff_nb = dff_nb.fill_null("")
dff_nb = format_values(dff_nb) dff_nb = format_values(dff_nb)
columns, tooltip = setup_table_columns( columns, tooltip = setup_table_columns(
dff_nb, hideable=False, exclude=[f"{org_type}_id"] dff_nb, hideable=False, exclude=[f"{org_type}_id"], new_columns=["Attributions"]
) )
data = dff_nb.to_dicts() data = dff_nb.to_dicts()
data = add_links_in_dict(data, f"{org_type}") data = add_links_in_dict(data, f"{org_type}")
+127
View File
@@ -2,9 +2,12 @@ import json
from typing import Literal from typing import Literal
import plotly.express as px import plotly.express as px
import plotly.graph_objects as go
import polars as pl import polars as pl
from dash import dash_table, dcc, html from dash import dash_table, dcc, html
from src.utils import format_number
def get_map_count_marches(df: pl.DataFrame): def get_map_count_marches(df: pl.DataFrame):
lf = df.lazy() lf = df.lazy()
@@ -56,6 +59,43 @@ def get_map_count_marches(df: pl.DataFrame):
return fig return fig
def get_yearly_statistics(statistics, today_str) -> html.Div:
# Build DataFrame from statistics
years = list(reversed(range(2018, int(today_str.split("/")[-1]) + 1)))
data = []
for year in years:
year_str = str(year)
stat = statistics[year_str]
data.append(
{
"Année": year_str,
"Marchés et accord-cadres": format_number(
stat["nb_notifications_marches"]
),
"Acheteurs": format_number(stat["nb_acheteurs_uniques"]),
"Titulaires": format_number(stat["nb_titulaires_uniques"]),
}
)
df = pl.DataFrame(data)
# Create Dash DataTable
table = dash_table.DataTable(
data=df.to_dicts(),
columns=[
{"name": "Année", "id": "Année"},
{"name": "Marchés et accord-cadres", "id": "Marchés et accord-cadres"},
{"name": "Acheteurs", "id": "Acheteurs"},
{"name": "Titulaires", "id": "Titulaires"},
],
page_size=10,
sort_action="none",
filter_action="none",
)
return html.Div(children=table, className="marches_table")
def get_barchart_sources(df: pl.DataFrame, type_date: str): def get_barchart_sources(df: pl.DataFrame, type_date: str):
lf = df.lazy() lf = df.lazy()
labels = { labels = {
@@ -259,3 +299,90 @@ class DataTable(dash_table.DataTable):
hidden_columns=hidden_columns, hidden_columns=hidden_columns,
**kwargs, # Possibilité de remplacer des arguments **kwargs, # Possibilité de remplacer des arguments
) )
def get_duplicate_matrix() -> html.Div:
"""
Fonction développée avec l'aide de la LLM Euria d'Infomaniak.
:return:
"""
result_df = pl.read_parquet(
"https://www.data.gouv.fr/api/1/datasets/r/a545bf6c-8b24-46ed-b49f-a32bf02eaffa"
).sort("sourceDataset")
result_df = result_df.select(
["sourceDataset", "unique"] + sorted(result_df.columns[2:])
)
description = dcc.Markdown("""
Ce graphique illustre les doublons de marchés publics entre sources, c'est-à-dire la proportion de marchés publiés par plus d'une source. Il s'appuie sur les identifiants `uid` qui sont pour chaque marché la concaténation du SIRET de l'acheteur et de l'identifiant interne du marché.
**Comment lire ce graphique ?**
On part des codes de sources de données en ordonnée. Ces jeux de données sont documentés dans [À propos](/a-propos#sources).
La première colonne (**unique**) représente le pourcentage de marchés fournis par cette source qui sont uniquement disponibles dans cette source. Plus le rouge est foncé, plus important est le pourcentage. Donc, à l'inverse, plus le rouge est clair dans la première colonne, plus la source en ordonnée a des marchés en commun avec d'autres sources, et donc plus on trouvera sur la même ligne d'autres cases plus ou moins foncées qui indiqueront avec quelles autres sources cette source partage des marchés.
Passez votre souris sur une case pour avoir les pourcentages exacts. À noter que ces statistiques sont produites avant le dédoublonnement qui a lieu avant la publication en Open Data et sur ce site.""")
# Assuming result_df is your DataFrame with structure:
# | sourceDataset | unique | dataset1 | dataset2 | dataset3 |
# |---------------|--------|----------|----------|----------|
# | dataset1 | 0.8 | | 0.15 | 0.2 |
# | dataset2 | 0.75 | 0.15 | | 0.12 |
# | dataset3 | 0.85 | 0.2 | 0.12 | |
# Extract data
z_data = result_df.select(pl.all().exclude("sourceDataset")).fill_null(0).to_numpy()
x_labels = result_df.columns[1:] # columns after "sourceDataset"
y_labels = result_df["sourceDataset"].to_list()
# Create heatmap
fig = go.Figure(
data=go.Heatmap(
z=z_data,
x=x_labels,
y=y_labels,
# colorscale=[
# [0, "white"], # 0% → white
# [0.10, "lightblue"], # 1% → light blue (soft start)
# [0.50, "steelblue"], # 50% → medium blue
# [1, "darkblue"], # 100% → dark blue
# ],
colorscale=[
[0.0, "white"], # 0% → white
[0.10, "lightsalmon"], # 10% → light warm tone
[1.0, "darkred"], # 100% → deep red
],
zmin=0,
zmax=1,
# texttemplate="%{z:.0%}", # Format as percentage
# textfont={"size": 10, "color": "black"}, # Smaller font
hoverongaps=False,
showscale=True,
hovertemplate=(
"<b>%{z:.0%}</b> des marchés de <b>%{y}</b> sont également présents dans <b>%{x}</b>"
),
)
)
# Update layout: make it wider and taller
fig.update_layout(
title="",
xaxis_title="Sources de données",
yaxis_title="Sources de données",
yaxis=dict(autorange="reversed"),
xaxis=dict(tickangle=45, tickfont=dict(size=10)), # Smaller x-tick labels
coloraxis_colorbar=dict(title="Percentage", tickfont=dict(size=10)),
width=1000, # Wider
height=1000, # Taller
font=dict(size=11), # Overall font size
margin=dict(l=100, r=50, t=80, b=100), # Add margin for labels
)
return html.Div(
children=[
html.H3("Doublons de marchés entre les sources"),
description,
dcc.Graph(figure=fig),
]
)
+122 -58
View File
@@ -10,20 +10,26 @@ name = "À propos"
register_page( register_page(
__name__, __name__,
path="/a-propos", path="/a-propos",
title=meta_content["title"], title="À propos | decp.info",
name=name, name="À propos",
description=meta_content["description"], description="En savoir plus sur decp.info, l'outil d'exploration des données essentielles de la commande publique.",
image_url=meta_content["image_url"], image_url=meta_content["image_url"],
order=5, order=5,
) )
layout = [ layout = html.Div(
html.Div( className="container",
className="container", children=[
children=[ html.H2(name),
html.H2(name), html.Div(
dcc.Markdown( className="a-propos-container",
"""Outil d'exploration libre et gratuit des données de marchés publics, développé par Colin Maudry. children=[
# Main Content Column
html.Div(
className="a-propos-content",
children=[
dcc.Markdown(
"""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 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. 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.
@@ -32,30 +38,39 @@ 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 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-titulaire...) - 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-titulaire...)
- la sauvegarde de filtres pour les retrouver plus tard et les partager
- des alertes par email si des marchés correspondant à certains critères - 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("Consommer les données brutes", id="donnees-brutes"),
dcc.Markdown(
"""
Vous pouvez consommer les données qui alimentent decp.info
""" - en les téléchargeant [sur data.gouv.fr](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire) (Parquet, CSV), pensez à lire la description du jeu de données
), - en interrogeant l'[API REST ouverte](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire#user-content-api-rest)
html.H4("Contact", id="contact"), """
dcc.Markdown(""" ),
html.H4("Contact", id="contact"),
dcc.Markdown(
"""
- Email : [colin@colmo.tech](mailto:colin@colmo.tech) - Email : [colin@colmo.tech](mailto:colin@colmo.tech)
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social) - Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m) - Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/) - LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
"""
"""), ),
html.H4("Pour contribuer", id="contribuer"), html.H4("Pour contribuer", id="contribuer"),
dcc.Markdown(""" 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 ! - 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) - ma société accepte aussi les dons (pas de réduction d'impôt possible)
- écrivez-moi et on discute ! - écrivez-moi et on discute !
"""
#### Pour explorer le projet ),
html.H4("Pour explorer le projet", id="explorer"),
dcc.Markdown(
"""
- ✉️ [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) - ✉️ [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/) - 💾 [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) - 🗞️ [mon blog](https://colin.maudry.com)
@@ -63,34 +78,40 @@ les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
- 🚰 code source - 🚰 code source
- [de decp.info](https://github.com/ColinMaudry/decp.info) - [de decp.info](https://github.com/ColinMaudry/decp.info)
- [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"), ),
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. 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 : 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) - klekoon.fr (ils y travaillent)
- safetender.com (Omnikles)
**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. **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."""), 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("Sources de données ", id="sources"),
html.H4("Mentions légales", id="mentions-legales"), get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
dcc.Markdown(""" html.H4("Mentions légales", id="mentions-legales"),
##### Publication html.H5("Publication", id="publication"),
dcc.Markdown(
"""
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. 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 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 Hébergement : serveur situé en France et administré par Scaleway, 8 rue de la Ville lEvêque, 75008 Paris
"""
##### Suivi d'audience ),
html.H5("Suivi d'audience", id="audience"),
dcc.Markdown(
"""
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 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 ?** **Ce site naffiche pas de bannière de consentement aux cookies, pourquoi ?**
@@ -99,21 +120,64 @@ Cest vrai, vous navez pas eu à cliquer sur un bloc qui recouvre la moiti
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. 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."""), 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={ # Table of Contents Column
# "border": "1pt solid lightgrey", html.Div(
# "padding": "12px", className="a-propos-toc",
# "margin": "auto 0 auto 12px", children=[
# "width": "80%", html.Div(
# }, [
# children=["Vous utilisez un bloqueur de suivi de trafic."], html.A(
# ), "Consommer les données brutes",
# html.Script( href="#donnees-brutes",
# src="https://analytics.maudry.com/index.php?module=CoreAdminHome&action=optOutJS&divId=matomo-opt-out&language=auto&showIntro=1" className="toc-link",
# ), ),
], html.A(
) "Contact", href="#contact", className="toc-link"
] ),
html.A(
"Pour contribuer",
href="#contribuer",
className="toc-link",
),
html.A(
"Pour explorer le projet",
href="#explorer",
className="toc-link",
),
html.A(
"Qualité et exhaustivité des données",
href="#qualite-exhausitivite",
className="toc-link",
),
html.A(
"Sources de données",
href="#sources",
className="toc-link",
),
html.A(
"Mentions légales",
href="#mentions-legales",
className="toc-link",
),
html.A(
"Publication",
href="#publication",
className="toc-link toc-level-2",
),
html.A(
"Suivi d'audience",
href="#audience",
className="toc-link toc-level-2",
),
]
),
],
),
],
),
],
)
+7 -2
View File
@@ -18,12 +18,17 @@ from src.utils import (
sort_table_data, sort_table_data,
) )
def get_title(acheteur_id: str = None) -> str:
return f"Acheteur {acheteur_id} | decp.info"
register_page( register_page(
__name__, __name__,
path_template="/acheteurs/<acheteur_id>", path_template="/acheteurs/<acheteur_id>",
title=meta_content["title"], title=get_title,
name="Acheteur", name="Acheteur",
description=meta_content["description"], description="Consultez les marchés publics attribués par cet acheteur.",
image_url=meta_content["image_url"], image_url=meta_content["image_url"],
order=5, order=5,
) )
+39 -26
View File
@@ -1,17 +1,23 @@
from datetime import datetime from datetime import datetime
import dash_bootstrap_components as dbc
import polars as pl 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, df, format_values, meta_content from src.utils import data_schema, df, format_values, meta_content
def get_title(uid: str = None) -> str:
return f"Marché {uid} | decp.info"
register_page( register_page(
__name__, __name__,
path_template="/marches/<uid>", path_template="/marches/<uid>",
title=meta_content["title"], title=get_title,
name="Marché", name="Marché",
description=meta_content["description"], description="Consultez les détails de ce marché public : montant, acheteur, titulaires, modifications, etc.",
image_url=meta_content["image_url"], image_url=meta_content["image_url"],
order=7, order=7,
) )
@@ -20,37 +26,44 @@ layout = [
dcc.Store(id="marche_data"), dcc.Store(id="marche_data"),
dcc.Store(id="titulaires_data"), dcc.Store(id="titulaires_data"),
dcc.Location(id="url", refresh="callback-nav"), dcc.Location(id="url", refresh="callback-nav"),
html.Div( dbc.Container(
className="container marche_infos", className="marche_infos",
children=[ children=[
html.P("Vous consultez un résumé des données de ce marché public"), dbc.Row(
html.Ul( dbc.Col(
[
html.P(
"Vous consultez un résumé des données de ce marché public"
),
html.Ul(
[
html.Li(
"après son attribution aux titulaires qui l'ont remporté à la suite d'un appel d'offres (ou sans appel d'offres via une attribution directe)"
),
html.Li(
"après avoir appliqué les éventuelles modifications de montant, durée ou titulaires renseignées par l'acheteur"
),
]
),
html.P(
"Le montant total payé aux titulaires, la durée du marché et la liste des titulaires peuvent cependant encore évoluer jusqu'à la fin de l'exécution du marché."
),
]
)
),
dbc.Row(
[ [
html.Li( dbc.Col(id="marche_infos_1", width=12, md=4),
"après son attribution aux titulaires qui l'ont remporté à la suite d'un appel d'offres (ou sans appel d'offres via une attribution directe)" dbc.Col(id="marche_infos_2", width=12, md=4),
), dbc.Col(
html.Li( width=12,
"après avoir appliqué les éventuelles modifications de montant, durée ou titulaires renseignées par l'acheteur" md=4,
),
]
),
html.P(
"Le montant total payé aux titulaires, la durée du marché et la liste des titulaires peuvent cependant encore évoluer jusqu'à la fin de l'exécution du marché."
),
html.Div(
className="wrapper",
children=[
html.Div(
className="marche_map",
id="marche_map",
children=[ children=[
html.H4("Titulaires"), html.H4("Titulaires"),
html.Ul(id="marche_infos_titulaires"), html.Ul(id="marche_infos_titulaires"),
], ],
), ),
html.Div(className="marche_infos_1", id="marche_infos_1"), ]
html.Div(className="marche_infos_2", id="marche_infos_2"),
],
), ),
], ],
), ),
+2 -2
View File
@@ -14,9 +14,9 @@ name = "Recherche"
register_page( register_page(
__name__, __name__,
path="/", path="/",
title=meta_content["title"], title="Recherche de marchés publics | decp.info",
name=name, name=name,
description=meta_content["description"], description="Recherchez des des acheteurs et des titulaires parmi les données essentielles de la commande publique.",
image_url=meta_content["image_url"], image_url=meta_content["image_url"],
order=0, order=0,
) )
+36 -6
View File
@@ -1,20 +1,29 @@
from datetime import datetime
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 (
from src.utils import df, meta_content get_barchart_sources,
get_duplicate_matrix,
get_map_count_marches,
get_yearly_statistics,
)
from src.utils import df, format_number, get_statistics, meta_content
name = "Statistiques" name = "Statistiques"
register_page( register_page(
__name__, __name__,
path="/statistiques", path="/statistiques",
title=meta_content["title"], title="Statistiques | decp.info",
name=name, name=name,
description=meta_content["description"], description="Visualisez l'état de la publication des données essentielles des marchés publics en France.",
image_url=meta_content["image_url"], image_url=meta_content["image_url"],
order=3, order=3,
) )
statistics: dict = get_statistics()
today_str = datetime.fromisoformat(statistics["datetime"]).strftime("%d/%m/%Y")
layout = [ layout = [
html.Div( html.Div(
@@ -28,16 +37,37 @@ layout = [
children=[ children=[
html.Div( html.Div(
children=[ children=[
dcc.Markdown(""" dcc.Markdown(f"""
La publication de données essentielles de marchés publics (DECP) est souvent effectuée par 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, 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 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. 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), L'ajout de nouvelles plateformes [est en cours](https://github.com/ColinMaudry/decp-processing/issues?q=is%3Aissue%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é.
Les statistiques publiées sur cette page ont été produites automatiquement à partir des données les plus récentes ({today_str}).
"""), """),
dcc.Graph(figure=get_map_count_marches(df)), dcc.Graph(figure=get_map_count_marches(df)),
html.H3(
"Statistiques générales sur les marchés",
id="marches",
),
html.P(
"À noter qu'une fois un marché attribué ses données essentielles peuvent malheureusement mettre plusieurs mois à être publiées par l'acheteur."
),
html.H4("Statistiques cumulées"),
dcc.Markdown(f"""
- Nombre de marchés publics et accord-cadres : {format_number(statistics["nb_marches"])}
- Nombre d'acheteurs publics (SIRET) : {format_number(statistics["nb_acheteurs_uniques"])}
- Nombre de titulaires (SIRET) : {format_number(statistics["nb_titulaires_uniques"])}
Je ne publie pas encore de statistiques sur les montants de marchés car je n'ai pas encore trouvé la bonne formule pour traiter les trop nombreux montants fantaisistes qui polluent les calculs.
"""),
html.H4("Statistiques par année"),
get_yearly_statistics(statistics, today_str),
get_duplicate_matrix(),
html.H3("Nombre de marchés par source dans le temps"),
dcc.Graph( dcc.Graph(
figure=get_barchart_sources(df, "dateNotification") figure=get_barchart_sources(df, "dateNotification")
), ),
+4 -3
View File
@@ -26,9 +26,9 @@ name = "Tableau"
register_page( register_page(
__name__, __name__,
path="/tableau", path="/tableau",
title=meta_content["title"], title="Tableau des marchés publics | decp.info",
name=name, name=name,
description=meta_content["description"], description="Consultez, filtrez et exportez les données essentielles de la commande publique sous forme de tableau.",
image_url=meta_content["image_url"], image_url=meta_content["image_url"],
order=1, order=1,
) )
@@ -66,10 +66,11 @@ 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`. 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 - Champs textuels : la recherche est insensible à la casse (majuscules/minuscules) et retourne les valeurs qui contiennent
le texte recherché. Exemple : `rennes` retourne "RENNES METROPOLE". Lorsque vous ouvrez une URL de vue, le format équivalent `icontains rennes` est utilisé. le texte recherché. Exemple : `rennes` retourne "RENNES METROPOLE". Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances`. Lorsque vous ouvrez une URL de vue, 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 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", - 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". Lorsque vous ouvrez une URL de vue, le format équivalent `i<` ou `i>` est utilisé. `2024` pour "en 2024", `> 2022` pour "à partir de 2022". Lorsque vous ouvrez une URL de vue, le format équivalent `i<` ou `i>` est utilisé.
- Pour les champs textuels et dates : pour chercher du texte qui **commence par** votre texte, entez `texte*`, pour chercher du texte qui **finit par** votre texte, entez `*texte`. C'est par exemple utile pour filtrer des acheteurs ou titulaires par numéro SIREN (`123456789*`).
Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page. Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page.
+7 -2
View File
@@ -18,12 +18,17 @@ from src.utils import (
sort_table_data, sort_table_data,
) )
def get_title(titulaire_id: str = None) -> str:
return f"Titulaire {titulaire_id} | decp.info"
register_page( register_page(
__name__, __name__,
path_template="/titulaires/<titulaire_id>", path_template="/titulaires/<titulaire_id>",
title=meta_content["title"], title=get_title,
name="Titulaire", name="Titulaire",
description=meta_content["description"], description="Consultez les marchés publics remportés par ce titulaire.",
image_url=meta_content["image_url"], image_url=meta_content["image_url"],
order=5, order=5,
) )
+34 -7
View File
@@ -240,6 +240,17 @@ def get_org_data(dff: pl.DataFrame, org_type: str) -> pl.DataFrame:
return lff.collect() return lff.collect()
def get_statistics() -> dict:
return (
get(
"https://www.data.gouv.fr/api/1/datasets/r/0ccf4a75-f3aa-4b46-8b6a-18aeb63e36df",
follow_redirects=True,
)
.raise_for_status()
.json()
)
def get_departements() -> dict: def get_departements() -> dict:
with open("data/departements.json", "rb") as f: with open("data/departements.json", "rb") as f:
data = json.load(f) data = json.load(f)
@@ -297,9 +308,18 @@ def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
elif operator == "contains": elif operator == "contains":
if col_type in ["String", "Date"]: if col_type in ["String", "Date"]:
filter_value = filter_value.strip('"') filter_value = filter_value.strip('"')
lff = lff.filter( if filter_value.endswith("*"):
pl.col(col_name).str.contains("(?i)" + filter_value) lff = lff.filter(
) pl.col(col_name).str.starts_with(filter_value[:-1])
)
elif filter_value.startswith("*"):
lff = lff.filter(
pl.col(col_name).str.ends_with(filter_value[1:])
)
else:
lff = lff.filter(
pl.col(col_name).str.contains("(?i)" + filter_value)
)
elif col_type.startswith("Int") or col_type.startswith("Float"): elif col_type.startswith("Int") or col_type.startswith("Float"):
lff = lff.filter(pl.col(col_name) == filter_value) lff = lff.filter(pl.col(col_name) == filter_value)
else: else:
@@ -323,7 +343,11 @@ def sort_table_data(lff: pl.LazyFrame, sort_by: list) -> pl.LazyFrame:
return lff return lff
def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tuple: def setup_table_columns(
dff, hideable: bool = True, exclude: list = None, new_columns: list = None
) -> tuple:
new_columns = new_columns or []
# Liste finale de colonnes # Liste finale de colonnes
columns = [] columns = []
tooltip = {} tooltip = {}
@@ -334,9 +358,12 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup
if column_object: if column_object:
column_name = column_object.get("title") column_name = column_object.get("title")
else: else:
# Si le champ n'est pas dans le schéma, on le skip if column_id not in new_columns:
print(f"Champ innatendu : {column_id}") # Si le champ n'est pas dans le schéma et pas annoncé, on le skip
continue print("Champ innatendu : ")
print(dff[column_id].head())
continue
column_name = column_id
column = { column = {
"name": column_name, "name": column_name,