Compare commits

...

58 Commits

Author SHA1 Message Date
Colin Maudry d58183beb5 Merge branch 'release/2.2.0' 2025-11-13 14:26:41 +01:00
Colin Maudry 18f8388df5 Changelog 2.2.0 2025-11-13 14:25:41 +01:00
Colin Maudry ef39be3346 Réduction du logging httpx 2025-11-13 14:23:08 +01:00
Colin Maudry 783fb88d0c Line height 2025-11-13 14:13:36 +01:00
Colin Maudry c19076ee85 Hauteur de lignes tableau 2025-11-12 22:18:46 +01:00
Colin Maudry c44ef311c3 Suppression du temps de latence entre les recherches #58 2025-11-12 22:09:15 +01:00
Colin Maudry 63622d83f5 Suivi des recherches #58 2025-11-12 16:08:38 +01:00
Colin Maudry 5bfce2a1d9 Merge branch 'main' into dev 2025-11-12 15:07:36 +01:00
Colin Maudry a3f8bd0981 Merge branch 'feature/58_search' into dev 2025-11-12 14:56:37 +01:00
Colin Maudry bb6c0dec26 Changelog #58 2025-11-12 14:55:38 +01:00
Colin Maudry 459ca6a846 Réarrangements résultats #58 2025-11-12 14:54:12 +01:00
Colin Maudry 8637915eb2 Cleanup HTML 2025-11-11 12:50:58 +01:00
Colin Maudry e187cf33aa Recherche fonctinonelle acheteurs et titulaires #58 2025-11-11 12:42:01 +01:00
Colin Maudry 2f3ce6a467 Amélioration du message d'annonce 2025-11-11 09:04:11 +01:00
Colin Maudry 1310caef4d Merge tag 'v2.1.7' into dev
- Remplacement du formulaire de contact par une adresse email
2025-11-11 08:42:28 +01:00
Colin Maudry efd9bd4238 Merge branch 'hotfix/2.1.7' 2025-11-11 08:39:58 +01:00
Colin Maudry 39844071ad Changelog 2.1.7 2025-11-11 08:39:41 +01:00
Colin Maudry 5c64177c6a Remplacement du formulaire de contact par email 2025-11-11 08:37:47 +01:00
Colin Maudry 1e85dbe562 Champ de recherche, récup de données dédiée #58 2025-11-11 07:47:00 +01:00
Colin Maudry 6835f328b6 Ajout de la distance dans les tableaux top #55 2025-11-10 15:30:47 +01:00
Colin Maudry a3eb48bacb Format values inclut le formatage des distances 2025-11-10 15:29:44 +01:00
Colin Maudry 1848c881ac Merge branch 'feature/55_top10' into dev 2025-11-10 13:33:42 +01:00
Colin Maudry bbb04a4487 Changelog #55 2025-11-10 13:33:37 +01:00
Colin Maudry e9faf80609 Top 10 acheteurs sur les pages titulaire #55 2025-11-10 13:30:50 +01:00
Colin Maudry f6a38a29bd Top 10 titulaires sur les pages acheteur #55 2025-11-10 13:01:17 +01:00
Colin Maudry c6e096e42a Merge branch 'feature/54_colonnes_defaut' into dev 2025-11-07 08:19:12 +01:00
Colin Maudry 4c6f63f723 Changelog #54 2025-11-07 08:15:18 +01:00
Colin Maudry f3c8fd4481 Configuration des colonnes affichées par défaut #54 2025-11-07 08:13:35 +01:00
Colin Maudry 5ca52fadd7 Merge tag 'v2.1.6' into dev
- Stabilisation de la vue marché
2025-10-15 19:44:55 +02:00
Colin Maudry ee91e7a91f Merge branch 'hotfix/2.1.6' 2025-10-15 19:44:09 +02:00
Colin Maudry 392f862baa Changelog 2.1.6 2025-10-15 19:44:02 +02:00
Colin Maudry 199476ec30 Stabilisation affichage fiche marché #40 2025-10-15 19:42:53 +02:00
Colin Maudry c98a36f759 Fix fiche marche 2025-10-14 12:54:47 +02:00
Colin Maudry 65e0f4c5f0 Gestion des montants inexistants 2025-10-10 18:25:08 +02:00
Colin Maudry bc7652f336 Merge branch 'main' into dev 2025-10-10 17:48:47 +02:00
Colin Maudry 188383a882 Changelog 2.1.5 2025-10-10 17:47:18 +02:00
Colin Maudry 7f3a0bcb21 Renommage de la Github action d'auto release 2025-10-10 17:45:15 +02:00
Colin Maudry 3270abb7f6 Refactor du filtre pour être plus résilient (et fonctionner sur les montants) 2025-10-10 17:44:51 +02:00
Colin Maudry 2eb984a95f Ne plus afficher de nulls dans les tableaux de données (tableau, titulaire, acheteur) 2025-10-10 17:44:16 +02:00
Colin Maudry 776126a481 Merge tag 'v2.1.4' into dev
- possibilité de filtrer sur le champ "Source"
- création automatique d'une release Github quand je push un tag
2025-10-08 09:34:30 +02:00
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 816d0de324 Merge tag 'v2.1.1' into dev
- 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
2025-10-01 13:22:42 +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
Colin Maudry 90e9bc3c8c Merge tag 'v2.1.0' into dev
- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/decp.info/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786)
([#35](https://github.com/ColinMaudry/decp.info/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/decp.info/issues/40)) 🔎
 - Ajout des balises HTML meta Open Graph et Twitter ([#39](https://github.com/ColinMaudry/decp.info/issues/39)) pour de beaux aperçus de liens 🖼️
- Formulaire de contact ([#48](https://github.com/ColinMaudry/decp.info/issues/48)) 📨
- Nom de colonnes plus_agréables ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 💅
- Définition des colonnes quand vous passez votre souris sur les en-têtes ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 📖#
- Affichage du numéro de version près du logo et lien vers ici 🤓
- Variables globales uniquement en lecture (😁)
2025-09-30 17:16:45 +02:00
18 changed files with 824 additions and 274 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: Auto-release d'un tag
jobs:
build:
name: auto-release
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
+8
View File
@@ -6,9 +6,17 @@ SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4
# Chemin vers le schéma de données # Chemin vers le schéma de données
DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a
# Colonnes masquées par défaut
DISPLAYED_COLUMNS="uid, acheteur_id, acheteur_nom, montant, objet, titulaire_nom, titulaire_id, dateNotification, dureeMois, acheteur_departement_code, sourceDataset"
# Formulaire de contact # Formulaire de contact
SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP
LOGIN_PASSWORD="" # mot de passe du serveur LOGIN_PASSWORD="" # mot de passe du serveur
LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP
FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From) FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From)
TO_EMAIL="to@example.com" # adresse de destination des emails (To) TO_EMAIL="to@example.com" # adresse de destination des emails (To)
# Matomo
MATOMO_ID_SITE=
MATOMO_BASE_URL=
MATOMO_TOKEN=
+43 -3
View File
@@ -1,6 +1,6 @@
# decp.info # decp.info
> v2.1.0 > v2.1.7
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.
@@ -26,9 +26,11 @@ python run.py
## Déploiement ## 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. - **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 ## Liens connexes
- [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données) - [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données)
@@ -36,7 +38,45 @@ python run.py
## Notes de version ## Notes de version
#### 2.1.0 #### 2.2.0 (13 novembre 2025)
- Moteur de recherche (acheteurs et titulaires) en page d'accueil ([#58](https://github.com/ColinMaudry/decp.info/issues/58))
- Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/decp.info/issues/55)))
- Moins de colonnes affichées par défaut dans Tableau ([#54](https://github.com/ColinMaudry/decp.info/issues/54))
##### 2.1.7 (11 novembre 2025)
- Remplacement du formulaire de contact par une adresse email
##### 2.1.6 (15 octobre 2025)
- Stabilisation de la vue marché
##### 2.1.5 (10 octobre 2025)
- réparation des filtres (notamment < > sur les montants)
- remplacement des valeurs "null" dans les tableaux par des cellules vides
##### 2.1.4 (8 octobre 2025)
- possibilité de filtrer sur le champ "Source"
- création automatique d'une release Github quand je push un tag
##### 2.1.3 (4 octobre 2025)
- tentative d'auto-release à chaque création de tag git
- adaptation au format TableSchema
##### 2.1.2 (3 octobre 2025)
- dataframe global plutôt que lazyframe, pour plus de résilience et charger toutes les données en mémoire
##### 2.1.1 (1er octobre 2025)
- ajout d'une section dans À propos sur la qualité et l'exhaustivité des données ([#43](https://github.com/ColinMaudry/decp.info/issues/43))
- ajout du nombre de marchés en plus du nombre de lignes dans la vue Tableau
#### 2.1.0 (30 septembre 2025)
- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/decp.info/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786) ([#35](https://github.com/ColinMaudry/decp.info/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/decp.info/issues/40)) 🔎 - Ajout des 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 🖼️
+3 -2
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.0" version = "2.2.0"
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" }
@@ -16,7 +16,8 @@ dependencies = [
"xlsxwriter", "xlsxwriter",
"plotly[express]", "plotly[express]",
"httpx", "httpx",
"pandas" # utilisé pour la création de certains graphiques "pandas", # utilisé pour la création de certains graphiques
"unidecode"
] ]
[project.optional-dependencies] [project.optional-dependencies]
+6
View File
@@ -90,6 +90,12 @@ app.layout = html.Div(
], ],
className="logo", className="logo",
), ),
html.Div(
id="announcements",
children=dcc.Markdown(
"️ Le formulaire de contact, défectueux, a été remplacé par [une adresse email](/contact)."
),
),
html.Div( html.Div(
[ [
dcc.Link( dcc.Link(
Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

+45 -6
View File
@@ -53,9 +53,10 @@ div.logo > a {
/* Réduire la taille du texte de la colonne Objet */ /* Réduire la taille du texte de la colonne Objet */
td[data-dash-column="objet"] { /*
td[data-dash-column="objet"],td[data-dash-column="titulaire_nom"],td[data-dash-column="acheteur_nom"], {
font-size: 85%; font-size: 85%;
} }*/
/* Couleur des en-têtes */ /* Couleur des en-têtes */
.dash-table-container .dash-table-container
@@ -142,6 +143,42 @@ td[data-dash-column="objet"] {
margin: 0 0 20px 20px; margin: 0 0 20px 20px;
} }
/* Annonces */
#announcements {
margin-top: 25px;
max-width: 30%;
font-size: 90%;
}
/* Page de recherche */
#search {
margin: 50px auto 0px auto;
width: 450px;
font-size: 18px;
height: 30px;
display: block;
}
.search_options {
margin: 16px auto;
width: 450px;
}
.search_options input {
margin-right: 12px;
}
.results_acheteur {
grid-column: 1;
grid-row: 1;
}
.results_titulaire {
grid-column: 2;
grid-row: 1;
}
/* Menu de navigation */ /* Menu de navigation */
.navbar { .navbar {
@@ -171,7 +208,7 @@ summary > h3 {
padding-top: 28px; padding-top: 28px;
} }
/* Vue acheteur/titulaire */ /* Vue acheteur/titulaire/recherche */
.wrapper { .wrapper {
display: grid; display: grid;
grid-gap: 10px; grid-gap: 10px;
@@ -179,9 +216,6 @@ summary > h3 {
justify-content: space-between; justify-content: space-between;
} }
.wrapper > div {
}
.org_title { .org_title {
grid-column: 1 / 3; grid-column: 1 / 3;
grid-row: 1; grid-row: 1;
@@ -211,6 +245,11 @@ summary > h3 {
grid-row: 2; grid-row: 2;
} }
.org_top {
grid-column: 1/3;
grid-row: 3;
}
/* Vue marché */ /* Vue marché */
.marche_infos p { .marche_infos p {
+65
View File
@@ -0,0 +1,65 @@
import polars as pl
from dash import dash_table, html
from utils import add_links_in_dict, format_values, setup_table_columns
def get_top_org_table(data, org_type: str):
dff = pl.DataFrame(data)
if dff.height == 0:
return html.Div()
dff = dff.select(
["uid", f"{org_type}_id", f"{org_type}_nom", "distance", "montant"]
)
dff_nb = dff.group_by(f"{org_type}_id", f"{org_type}_nom", "distance").agg(
pl.len().alias("Attributions"), pl.sum("montant").alias("montant")
)
dff_nb = dff_nb.sort(by="montant", descending=True)
dff_nb = dff_nb.cast(pl.String)
dff_nb = dff_nb.fill_null("")
dff_nb = format_values(dff_nb)
columns, tooltip = setup_table_columns(
dff_nb, hideable=False, exclude=[f"{org_type}_id"]
)
data = dff_nb.to_dicts()
data = add_links_in_dict(data, f"{org_type}")
return dash_table.DataTable(
data=data,
markdown_options={"html": True},
page_action="native",
page_size=10,
columns=columns,
cell_selectable=False,
tooltip_header=tooltip,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
"fontSize": "85%",
},
{
"if": {"column_id": "acheteur_nom"},
"minWidth": "200px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "16px",
# "fontSize": "85%",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "200px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "16px",
"whiteSpace": "normal",
# "fontSize": "85%",
},
],
)
+8 -6
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,
@@ -38,8 +39,8 @@ def get_map_count_marches(lf: pl.LazyFrame):
range_color=(df["uid"].min(), df["uid"].max()), range_color=(df["uid"].min(), df["uid"].max()),
labels={"uid": "Marchés attribués"}, labels={"uid": "Marchés attribués"},
scope="europe", scope="europe",
width=1000, width=900,
height=800, height=700,
) )
fig.update_geos(fitbounds="locations", visible=False) fig.update_geos(fitbounds="locations", visible=False)
@@ -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,
+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) - [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("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")), get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
html.H4("Mentions légales", id="mentions-legales"), html.H4("Mentions légales", id="mentions-legales"),
dcc.Markdown(""" dcc.Markdown("""
+34 -13
View File
@@ -3,14 +3,15 @@ import datetime
import polars as pl import polars as pl
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
from src.callbacks import get_top_org_table
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,
format_montant, df,
format_number, format_number,
format_values,
get_annuaire_data, get_annuaire_data,
get_departement_region, get_departement_region,
lf,
meta_content, meta_content,
setup_table_columns, setup_table_columns,
) )
@@ -89,6 +90,13 @@ layout = [
], ],
), ),
html.Div(className="org_map", id="acheteur_map"), html.Div(className="org_map", id="acheteur_map"),
html.Div(
className="org_top",
children=[
html.H3("Top titulaires"),
html.Div(className="marches_table", id="top10_titulaires"),
],
),
], ],
), ),
# récupérer les données de l'acheteur sur l'api annuaire # récupérer les données de l'acheteur sur l'api annuaire
@@ -146,22 +154,22 @@ def update_acheteur_infos(url):
Input(component_id="acheteur_data", component_property="data"), Input(component_id="acheteur_data", component_property="data"),
) )
def update_acheteur_stats(data): def update_acheteur_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=df.collect_schema())
df_marches = df.unique("id") df_marches = dff.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()))
marches_attribues = [html.Strong(nb_marches), " marchés et accord-cadres attribués"] marches_attribues = [html.Strong(nb_marches), " marchés et accord-cadres attribués"]
# + ", pour un total de ", html.Strong(somme_marches + " €")] # + ", pour un total de ", html.Strong(somme_marches + " €")]
del df_marches del df_marches
nb_titulaires = df.unique("titulaire_id").height nb_titulaires = dff.unique("titulaire_id").height
nb_titulaires = [ nb_titulaires = [
html.Strong(format_number(nb_titulaires)), html.Strong(format_number(nb_titulaires)),
" titulaires (SIRET) différents", " titulaires (SIRET) différents",
] ]
del df del dff
return marches_attribues, nb_titulaires return marches_attribues, nb_titulaires
@@ -171,10 +179,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.fill_null("") lff = lff.filter(pl.col("acheteur_id") == acheteur_siret)
lff = lff.select( lff = lff.select(
"id", "id",
"uid", "uid",
@@ -183,6 +191,7 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> pl.LazyFrame:
"titulaire_id", "titulaire_id",
"titulaire_typeIdentifiant", "titulaire_typeIdentifiant",
"titulaire_nom", "titulaire_nom",
"distance",
"montant", "montant",
"codeCPV", "codeCPV",
"dureeMois", "dureeMois",
@@ -203,7 +212,11 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> pl.LazyFrame:
) )
def get_last_marches_table(data) -> html.Div: def get_last_marches_table(data) -> html.Div:
dff = pl.DataFrame(data) dff = pl.DataFrame(data)
dff = format_montant(dff) if dff.height == 0:
return html.Div(html.P("Aucun marché trouvé."))
dff = dff.cast(pl.String)
dff = dff.fill_null("")
dff = format_values(dff)
columns, tooltip = setup_table_columns( columns, tooltip = setup_table_columns(
dff, dff,
hideable=False, hideable=False,
@@ -233,7 +246,7 @@ def get_last_marches_table(data) -> html.Div:
"minWidth": "300px", "minWidth": "300px",
"textAlign": "left", "textAlign": "left",
"overflow": "hidden", "overflow": "hidden",
"lineHeight": "14px", "lineHeight": "18px",
"whiteSpace": "normal", "whiteSpace": "normal",
}, },
{ {
@@ -250,6 +263,14 @@ def get_last_marches_table(data) -> html.Div:
return table return table
@callback(
Output(component_id="top10_titulaires", component_property="children"),
Input(component_id="acheteur_data", component_property="data"),
)
def get_top_titulaires(data):
return get_top_org_table(data, "titulaire")
@callback( @callback(
Output("download-acheteur-data", "data"), Output("download-acheteur-data", "data"),
Input("btn-download-acheteur-data", "n_clicks"), Input("btn-download-acheteur-data", "n_clicks"),
+117 -120
View File
@@ -1,11 +1,7 @@
import os
import re import re
import smtplib
import time import time
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from dash import Input, Output, State, callback, dcc, html, register_page from dash import dcc, html, register_page
from flask import request from flask import request
from src.utils import meta_content from src.utils import meta_content
@@ -25,51 +21,52 @@ layout = html.Div(
className="container", className="container",
children=[ children=[
html.H2("Contact", id="contact"), html.H2("Contact", id="contact"),
html.P( # html.P(
"Votre message arrivera directement dans ma boîte mail, et je reviendrai vers vous rapidement." # "Votre message arrivera directement dans ma boîte mail, et je reviendrai vers vous rapidement."
), # ),
html.Div( # html.Div(
[ # [
html.Label("Votre nom"), # html.Label("Votre nom"),
dcc.Input( # dcc.Input(
id="input-name", # id="input-name",
type="text", # type="text",
style={"width": "100%", "padding": "8px", "margin": "8px 0"}, # style={"width": "100%", "padding": "8px", "margin": "8px 0"},
), # ),
html.Label("Votre adresse email"), # html.Label("Votre adresse email"),
dcc.Input( # dcc.Input(
id="input-email", # id="input-email",
type="email", # type="email",
style={"width": "100%", "padding": "8px", "margin": "8px 0"}, # style={"width": "100%", "padding": "8px", "margin": "8px 0"},
), # ),
html.Label("Message"), # html.Label("Message"),
dcc.Textarea( # dcc.Textarea(
id="input-message", # id="input-message",
style={ # style={
"width": "100%", # "width": "100%",
"height": 200, # "height": 200,
"padding": "8px", # "padding": "8px",
"margin": "8px 0", # "margin": "8px 0",
}, # },
), # ),
html.Button( # html.Button(
"Envoyer ", # "Envoyer ",
id="submit-button", # id="submit-button",
n_clicks=0, # n_clicks=0,
style={"marginTop": "10px"}, # style={"marginTop": "10px"},
), # ),
html.Div( # html.Div(
id="output-message", style={"marginTop": "10px", "color": "green"} # id="output-message", style={"marginTop": "10px", "color": "green"}
), # ),
], # ],
style={ # style={
"maxWidth": "600px", # "maxWidth": "600px",
"margin": "auto", # "margin": "auto",
"padding": "20px", # "padding": "20px",
"lineHeight": "20px", # "lineHeight": "20px",
}, # },
), # ),
dcc.Markdown(""" dcc.Markdown("""
- Email : [colin+decp@maudry.com](mailto:colin+decp@maudry.com)
- 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/)
@@ -97,74 +94,74 @@ def sanitize_email(email):
return re.match(r"[^@]+@[^@]+\.[^@]+", email) return re.match(r"[^@]+@[^@]+\.[^@]+", email)
@callback( # @callback(
Output("output-message", "children"), # Output("output-message", "children"),
Input("submit-button", "n_clicks"), # Input("submit-button", "n_clicks"),
State("input-name", "value"), # State("input-name", "value"),
State("input-email", "value"), # State("input-email", "value"),
State("input-message", "value"), # State("input-message", "value"),
prevent_initial_call=True, # prevent_initial_call=True,
) # )
def send_email(n_clicks, form_name, form_email, form_message): # def send_email(n_clicks, form_name, form_email, form_message):
if not all([form_name, form_email, form_message]): # if not all([form_name, form_email, form_message]):
return html.Div( # return html.Div(
"Veuillez s'il vous plaît remplir tous les champs.", style={"color": "red"} # "Veuillez s'il vous plaît remplir tous les champs.", style={"color": "red"}
) # )
#
client_ip = request.remote_addr # client_ip = request.remote_addr
if is_rate_limited(): # if is_rate_limited():
wait_time = int(RATE_LIMIT_WINDOW - (time.time() - rate_limit_store[client_ip])) # wait_time = int(RATE_LIMIT_WINDOW - (time.time() - rate_limit_store[client_ip]))
return html.Div( # return html.Div(
f"⏳ J'ai mis en place une protection contre le spam, et vous m'avez écrit il y a moins de 5 minutes. " # f"⏳ J'ai mis en place une protection contre le spam, et vous m'avez écrit il y a moins de 5 minutes. "
f"Veuillez s'il vous plaît attendre {wait_time} secondes avant de renvoyer un message.", # f"Veuillez s'il vous plaît attendre {wait_time} secondes avant de renvoyer un message.",
style={"color": "black"}, # style={"color": "black"},
) # )
#
try: # try:
# Configuration du serveur SMTP # # Configuration du serveur SMTP
smtp_server = os.getenv("SENDER_SERVER_DOMAIN") # smtp_server = os.getenv("SENDER_SERVER_DOMAIN")
smtp_port = 587 # smtp_port = 587
login_email = os.getenv("LOGIN_EMAIL") # login_email = os.getenv("LOGIN_EMAIL")
from_email = os.getenv("FROM_EMAIL") # from_email = os.getenv("FROM_EMAIL")
to_email = os.getenv("TO_EMAIL", from_email) # to_email = os.getenv("TO_EMAIL", from_email)
login_password = os.getenv("LOGIN_PASSWORD") # login_password = os.getenv("LOGIN_PASSWORD")
#
print( # print(
smtp_server, # smtp_server,
smtp_port, # smtp_port,
login_email, # login_email,
from_email, # from_email,
to_email, # to_email,
login_password, # login_password,
sep="\n", # sep="\n",
) # )
#
# Création de l'email # # Création de l'email
email = MIMEMultipart() # email = MIMEMultipart()
email["From"] = from_email # email["From"] = from_email
email["To"] = to_email # email["To"] = to_email
email["Subject"] = f"[decp.info] Message de {form_name}" # email["Subject"] = f"[decp.info] Message de {form_name}"
#
body = f""" # body = f"""
Nom : {form_name} # Nom : {form_name}
Email : {form_email} # Email : {form_email}
#
Message : # Message :
{form_message} # {form_message}
""" # """
email.attach(MIMEText(body, "plain")) # email.attach(MIMEText(body, "plain"))
#
# Send email # # Send email
server = smtplib.SMTP(smtp_server, smtp_port) # server = smtplib.SMTP(smtp_server, smtp_port)
server.starttls() # server.starttls()
server.login(login_email, login_password) # server.login(login_email, login_password)
server.sendmail(from_email, to_email, email.as_string()) # server.sendmail(from_email, to_email, email.as_string())
server.quit() # server.quit()
#
return html.Div("✅ Envoi réussi", style={"color": "green"}) # return html.Div("✅ Envoi réussi", style={"color": "green"})
#
except Exception as e: # except Exception as e:
print(e) # print(e)
return html.Div( # return html.Div(
f"❌ Échec de l'envoi du message : {str(e)}", style={"color": "red"} # f"❌ Échec de l'envoi du message : {str(e)}", style={"color": "red"}
) # )
+9 -13
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_values, meta_content
register_page( register_page(
__name__, __name__,
@@ -62,22 +62,20 @@ 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")
# Données du marché # Données du marché
dff_marche = ( dff_marche = lff.unique("uid").collect(engine="streaming")
lff.select(~cs.starts_with("titulaires")).unique().collect(engine="streaming") dff_marche = format_values(dff_marche)
)
dff_marche = format_montant(dff_marche)
assert dff_marche.height == 1
return dff_marche.to_dicts()[0], dff_titulaires.to_dicts() return dff_marche.to_dicts()[0], dff_titulaires.to_dicts()
@@ -92,7 +90,7 @@ def get_marche_data(url):
def update_marche_info(marche, titulaires): def update_marche_info(marche, titulaires):
def make_parameter(col): def make_parameter(col):
column_object = data_schema.get(col) column_object = data_schema.get(col)
column_name = column_object.get("friendly_name") if column_object else col column_name = column_object.get("title") if column_object else col
if marche[col]: if marche[col]:
if col == "acheteur_nom": if col == "acheteur_nom":
@@ -108,8 +106,6 @@ def update_marche_info(marche, titulaires):
# Dates # Dates
elif col in ["dateNotification", "datePublicationDonnees"]: elif col in ["dateNotification", "datePublicationDonnees"]:
print(marche[col])
value = datetime.fromisoformat(marche[col]).strftime("%d/%m/%Y") value = datetime.fromisoformat(marche[col]).strftime("%d/%m/%Y")
# Listes # Listes
+105
View File
@@ -0,0 +1,105 @@
from dash import Input, Output, callback, dash_table, dcc, html, register_page
from src.utils import (
df_acheteurs,
df_titulaires,
meta_content,
search_org,
setup_table_columns,
)
name = "Recherche"
register_page(
__name__,
path="/",
title=meta_content["title"],
name=name,
description=meta_content["description"],
image_url=meta_content["image_url"],
order=0,
)
layout = html.Div(
className="container",
children=[
dcc.Input(
id="search",
type="text",
placeholder="Nom d'acheteur, d'entreprise, SIREN...",
autoFocus=True,
),
# html.Div(
# className="search_options",
# children=[dcc.RadioItems(options=["Acheteur(s)"])],
# ),
html.Div(id="search_results", className="wrapper"),
],
)
@callback(
Output("search_results", "children"),
Input("search", "value"),
prevent_initial_call=True,
)
def update_search_results(query):
if len(query) >= 1:
content = []
for org_type in ["acheteur", "titulaire"]:
if org_type == "acheteur":
dff = df_acheteurs
elif org_type == "titulaire":
dff = df_titulaires
else:
raise ValueError(f"{org_type} is not supported")
# Search acheteurs and titulaires using the same function
results = search_org(dff, query, org_type=org_type)
count = results.height
# Format output
columns, tooltip = setup_table_columns(results, hideable=False)
org_content = [
html.Div(
className=f"results_{org_type}",
children=[
html.H3(f"{org_type.title()}s : {count}"),
dash_table.DataTable(
columns=columns,
data=results.to_dicts(),
page_size=10,
# style_table={"overflowX": "auto"},
markdown_options={"html": True},
cell_selectable=False,
style_cell_conditional=[
{
"if": {"column_id": "acheteur_nom"},
"maxWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"maxWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
),
],
)
if count > 0
else html.P(f"Aucun {org_type} trouvé."),
]
content.extend(org_content)
return content
else:
return html.P("")
+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"
) )
), ),
], ],
+20 -15
View File
@@ -7,10 +7,11 @@ 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_number, format_number,
lf, format_values,
get_default_hidden_columns,
meta_content, meta_content,
setup_table_columns, setup_table_columns,
sort_table_data, sort_table_data,
@@ -19,12 +20,12 @@ 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(
__name__, __name__,
path="/", path="/tableau",
title=meta_content["title"], title=meta_content["title"],
name=name, name=name,
description=meta_content["description"], description=meta_content["description"],
@@ -52,7 +53,7 @@ datatable = html.Div(
"minWidth": "350px", "minWidth": "350px",
"textAlign": "left", "textAlign": "left",
"overflow": "hidden", "overflow": "hidden",
"lineHeight": "14px", "lineHeight": "18px",
"whiteSpace": "normal", "whiteSpace": "normal",
}, },
{ {
@@ -60,7 +61,7 @@ datatable = html.Div(
"minWidth": "250px", "minWidth": "250px",
"textAlign": "left", "textAlign": "left",
"overflow": "hidden", "overflow": "hidden",
"lineHeight": "14px", "lineHeight": "18px",
"whiteSpace": "normal", "whiteSpace": "normal",
}, },
{ {
@@ -68,7 +69,7 @@ datatable = html.Div(
"minWidth": "250px", "minWidth": "250px",
"textAlign": "left", "textAlign": "left",
"overflow": "hidden", "overflow": "hidden",
"lineHeight": "14px", "lineHeight": "18px",
"whiteSpace": "normal", "whiteSpace": "normal",
}, },
], ],
@@ -76,6 +77,7 @@ datatable = html.Div(
markdown_options={"html": True}, markdown_options={"html": True},
tooltip_duration=8000, tooltip_duration=8000,
tooltip_delay=350, tooltip_delay=350,
hidden_columns=get_default_hidden_columns(schema),
), ),
) )
@@ -171,31 +173,34 @@ 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)
if len(sort_by) > 0: if len(sort_by) > 0:
lff = sort_table_data(lff, sort_by) lff = sort_table_data(lff, sort_by)
# Remplace les strings null par "", mais pas les numeric null
lff = lff.fill_null("")
# Matérialisation des filtres # Matérialisation des filtres
dff: pl.DataFrame = lff.collect() dff: pl.DataFrame = lff.collect()
height = dff.height height = dff.height
nb_rows = f"{format_number(height)} lignes ({format_number(dff.select('uid').unique().height)} marchés)"
nb_rows = f"{format_number(height)} lignes"
# Pagination des données # Pagination des données
start_row = page_current * page_size start_row = page_current * page_size
# end_row = (page_current + 1) * page_size # end_row = (page_current + 1) * page_size
dff = dff.slice(start_row, page_size) dff = dff.slice(start_row, page_size)
# Tout devient string
dff = dff.cast(pl.String)
# Remplace les strings null par "", mais pas les numeric null
dff = dff.fill_null("")
# Ajout des liens vers l'annuaire des entreprises # Ajout des liens vers l'annuaire des entreprises
dff = add_links(dff) dff = add_links(dff)
@@ -203,7 +208,7 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp)
dff = add_resource_link(dff) dff = add_resource_link(dff)
# Formatage des montants # Formatage des montants
dff = format_montant(dff) dff = format_values(dff)
columns, tooltip = setup_table_columns(dff) columns, tooltip = setup_table_columns(dff)
@@ -239,7 +244,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:
+33 -14
View File
@@ -3,14 +3,15 @@ import datetime
import polars as pl import polars as pl
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
from src.callbacks import get_top_org_table
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,
format_montant, df,
format_number, format_number,
format_values,
get_annuaire_data, get_annuaire_data,
get_departement_region, get_departement_region,
lf,
meta_content, meta_content,
setup_table_columns, setup_table_columns,
) )
@@ -91,6 +92,13 @@ layout = [
], ],
), ),
html.Div(className="org_map", id="titulaire_map"), html.Div(className="org_map", id="titulaire_map"),
html.Div(
className="org_top",
children=[
html.H3("Top acheteurs"),
html.Div(className="marches_table", id="top10_acheteurs"),
],
),
], ],
), ),
# récupérer les données de l'acheteur sur l'api annuaire # récupérer les données de l'acheteur sur l'api annuaire
@@ -148,22 +156,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", " acheteurs (SIRET) différents",
] ]
del df del dff
return marches_remportes, nb_acheteurs return marches_remportes, nb_acheteurs
@@ -173,13 +181,13 @@ 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")
) )
lff = lff.fill_null("")
lff = lff.select( lff = lff.select(
"id", "id",
"uid", "uid",
@@ -187,6 +195,7 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> pl.LazyFrame:
"dateNotification", "dateNotification",
"acheteur_id", "acheteur_id",
"acheteur_nom", "acheteur_nom",
"distance",
"montant", "montant",
"codeCPV", "codeCPV",
"dureeMois", "dureeMois",
@@ -217,7 +226,9 @@ def get_last_marches_table(data) -> html.Div:
] ]
dff = pl.DataFrame(data) dff = pl.DataFrame(data)
dff = format_montant(dff) dff = dff.cast(pl.String)
dff = dff.fill_null("")
dff = format_values(dff)
columns, tooltip = setup_table_columns( columns, tooltip = setup_table_columns(
dff, hideable=False, exclude=["acheteur_id", "id"] dff, hideable=False, exclude=["acheteur_id", "id"]
) )
@@ -247,7 +258,7 @@ def get_last_marches_table(data) -> html.Div:
"minWidth": "300px", "minWidth": "300px",
"textAlign": "left", "textAlign": "left",
"overflow": "hidden", "overflow": "hidden",
"lineHeight": "14px", "lineHeight": "18px",
"whiteSpace": "normal", "whiteSpace": "normal",
}, },
{ {
@@ -264,6 +275,14 @@ def get_last_marches_table(data) -> html.Div:
return table return table
@callback(
Output(component_id="top10_acheteurs", component_property="children"),
Input(component_id="titulaire_data", component_property="data"),
)
def get_top_acheteurs(data):
return get_top_org_table(data, "acheteur")
@callback( @callback(
Output("download-titulaire-data", "data"), Output("download-titulaire-data", "data"),
Input("btn-download-titulaire-data", "n_clicks"), Input("btn-download-titulaire-data", "n_clicks"),
+264 -60
View File
@@ -1,22 +1,19 @@
import json import json
import logging import logging
import os import os
from time import sleep import uuid
from time import localtime, sleep
import polars as pl import polars as pl
import polars.selectors as cs import polars.selectors as cs
from httpx import get from httpx import get, post
from polars import Schema
from polars.exceptions import ComputeError from polars.exceptions import ComputeError
from unidecode import unidecode
operators = [
["s<", "<"],
["s>", ">"],
["i<", "<"],
["i>", ">"],
["icontains", "contains"],
]
logger = logging.getLogger("decp.info") logger = logging.getLogger("decp.info")
logging.getLogger("httpx").setLevel("WARNING")
logging.basicConfig( logging.basicConfig(
format="%(asctime)s %(levelname)-8s %(message)s", format="%(asctime)s %(levelname)-8s %(message)s",
level=logging.INFO, level=logging.INFO,
@@ -25,6 +22,13 @@ logging.basicConfig(
def split_filter_part(filter_part): def split_filter_part(filter_part):
operators = [
["s<", "<"],
["s>", ">"],
["i<", "<"],
["i>", ">"],
["icontains", "contains"],
]
print("filter part", filter_part) print("filter part", filter_part)
for operator_group in operators: for operator_group in operators:
if operator_group[0] in filter_part: if operator_group[0] in filter_part:
@@ -43,46 +47,64 @@ def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
dff = dff.with_columns( dff = dff.with_columns(
( (
'<a href="' + pl.col("sourceFile") + '">' + pl.col("sourceDataset") + "</a>" '<a href="' + pl.col("sourceFile") + '">' + pl.col("sourceDataset") + "</a>"
).alias("source") ).alias("sourceDataset")
) )
dff = dff.drop(["sourceFile", "sourceDataset"]) dff = dff.drop(["sourceFile"])
return dff return dff
def add_links(dff: pl.DataFrame): def add_links(dff: pl.DataFrame, target: str = "_blank"):
for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
if col in dff.columns:
if col.startswith("titulaire_"):
dff = dff.with_columns( dff = dff.with_columns(
pl.when(pl.col("titulaire_typeIdentifiant") == "SIRET") pl.when(
pl.Expr.or_(
pl.col("titulaire_typeIdentifiant").is_null(),
pl.col("titulaire_typeIdentifiant") == "SIRET",
)
)
.then( .then(
'<a href = "/titulaires/' '<a href = "/titulaires/'
+ pl.col("titulaire_id") + pl.col("titulaire_id")
+ '">' + f'" target="{target}">'
+ pl.col("titulaire_id") + pl.col(col)
+ "</a>" + "</a>"
) )
.otherwise(pl.col("titulaire_id")) .otherwise(pl.col(col))
.alias("titulaire_id") .alias(col)
) )
if col.startswith("acheteur_"):
for column, path in [("acheteur_id", "acheteurs"), ("uid", "marches")]:
dff = dff.with_columns( dff = dff.with_columns(
( (
f'<a href = "/{path}/' '<a href = "/acheteurs/'
+ pl.col(column) + pl.col("acheteur_id")
+ '" target="_blank">' + f'" target="{target}">'
+ pl.col(column) + pl.col(col)
+ "</a>" + "</a>"
).alias(column) ).alias(col)
)
if col == "uid":
dff = dff.with_columns(
(
'<a href = "/marches/'
+ pl.col("uid")
+ f'" target="{target}">'
+ pl.col("uid")
+ "</a>"
).alias("uid")
) )
return dff return dff
def add_links_in_dict(data: list, org_type: str) -> list: def add_links_in_dict(data: list[dict], org_type: str) -> list:
new_data = [] new_data = []
for marche in data: for marche in data:
org_id = marche[org_type + "_id"] org_id = marche[org_type + "_id"]
marche[org_type + "_nom"] = ( marche[org_type + "_nom"] = (
f'<a href="/{org_type}s/{org_id}">{marche[org_type + "_nom"]}</a>' f'<a href="/{org_type}s/{org_id}">{marche[org_type + "_nom"]}</a>'
) )
if marche.get("uid"):
marche["id"] = f'<a href="/marches/{marche["uid"]}">{marche["id"]}</a>' marche["id"] = f'<a href="/marches/{marche["uid"]}">{marche["id"]}</a>'
marche["uid"] = f'<a href="/marches/{marche["uid"]}">{marche["uid"]}</a>' marche["uid"] = f'<a href="/marches/{marche["uid"]}">{marche["uid"]}</a>'
new_data.append(marche) new_data.append(marche)
@@ -123,10 +145,11 @@ def format_number(number) -> str:
return number return number
def format_montant(dff: pl.DataFrame) -> pl.DataFrame: def format_values(dff: pl.DataFrame) -> pl.DataFrame:
def format_function(expr, scale=None): def format_montant(expr, scale=None):
# https://stackoverflow.com/a/78636786 # https://stackoverflow.com/a/78636786
expr = expr.cast(pl.String).str.splitn(".", 2) expr = expr.cast(pl.String)
expr = expr.str.splitn(".", 2)
num = expr.struct[0] num = expr.struct[0]
frac = expr.struct[1] frac = expr.struct[1]
@@ -141,13 +164,29 @@ def format_montant(dff: pl.DataFrame) -> pl.DataFrame:
frac: pl.Expr = ( frac: pl.Expr = (
pl.when(frac.is_not_null() & ~frac.is_in(["0"])) pl.when(frac.is_not_null() & ~frac.is_in(["0"]))
.then("," + frac) .then("," + frac.str.head(2))
.otherwise(pl.lit("")) .otherwise(pl.lit(""))
) )
return num + frac + pl.lit("") montant: pl.Expr = (
pl.when((num + frac) == pl.lit(""))
.then(pl.lit(""))
.otherwise(num + frac + pl.lit(""))
)
return montant
def format_distance(expr):
expr = expr.cast(pl.String)
return pl.concat_str(expr, pl.lit(" km"))
if "montant" in dff.columns:
dff = dff.with_columns(pl.col("montant").pipe(format_montant).alias("montant"))
if "distance" in dff.columns:
dff = dff.with_columns(
pl.col("distance").pipe(format_distance).alias("distance")
)
dff = dff.with_columns(pl.col("montant").pipe(format_function).alias("montant"))
return dff return dff
@@ -157,7 +196,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 +225,19 @@ 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_org_data(dff: pl.DataFrame, org_type: str) -> pl.DataFrame:
lff = dff.lazy()
lff = lff.select(
"uid",
cs.starts_with(org_type).exclude(
f"{org_type}_latitude", f"{org_type}_longitude"
),
)
lff = lff.group_by(cs.starts_with(org_type)).len("Marchés")
return lff.collect()
def get_departements() -> dict: def get_departements() -> dict:
@@ -206,23 +257,35 @@ 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("operator:", operator)
print("col_type:", col_type) print("col_type:", col_type)
if col_type == "Date": lff = lff.filter(pl.col(col_name).is_not_null())
# Convertir la colonne en chaînes de caractères
lff = dates_to_strings(lff, col_name)
if operator in ("<", "<=", ">", ">="): if col_type == "Date":
lff = lff.filter( # Convertir la colonne date en chaînes de caractères
pl.col(col_name).is_not_null() & (pl.col(col_name) != pl.lit("")) lff = dates_to_strings(lff, col_name)
) col_type = "String"
if col_type == "String":
lff = lff.filter(pl.col(col_name) != pl.lit(""))
elif col_type.startswith("Int") or col_type.startswith("Float"):
try:
filter_value = int(filter_value)
except ValueError:
logger.error(f"Invalid numeric filter value: {filter_value}")
continue
if operator in ("contains", "<", "<=", ">", ">="):
if operator == "<": if operator == "<":
lff = lff.filter(pl.col(col_name) < filter_value) lff = lff.filter(pl.col(col_name) < filter_value)
elif operator == ">": elif operator == ">":
@@ -231,17 +294,17 @@ def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
lff = lff.filter(pl.col(col_name) >= filter_value) lff = lff.filter(pl.col(col_name) >= filter_value)
elif operator == "<=": elif operator == "<=":
lff = lff.filter(pl.col(col_name) <= filter_value) lff = lff.filter(pl.col(col_name) <= filter_value)
elif operator == "contains":
if col_type in ["String", "Date"]:
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"):
try:
filter_value = int(filter_value)
except ValueError:
logger.error(f"Invalid numeric filter value: {filter_value}")
continue
lff = lff.filter(pl.col(col_name) == filter_value) lff = lff.filter(pl.col(col_name) == filter_value)
else:
elif operator == "contains" and col_type in ["String", "Date"]: logger.error(f"Invalid column type: {col_type}")
lff = lff.filter(pl.col(col_name).str.contains("(?i)" + filter_value)) else:
logger.error(f"Invalid operator: {operator}")
# elif operator == 'datestartswith': # elif operator == 'datestartswith':
# lff = lff.filter(pl.col(col_name).str.startswith(filter_value)") # lff = lff.filter(pl.col(col_name).str.startswith(filter_value)")
@@ -268,7 +331,7 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup
continue continue
column_object = data_schema.get(column_id) column_object = data_schema.get(column_id)
if column_object: if column_object:
column_name = column_object.get("friendly_name", column_id) column_name = column_object.get("title", column_id)
else: else:
column_name = column_id column_name = column_id
@@ -284,7 +347,7 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup
if column_object: if column_object:
tooltip[column_id] = { tooltip[column_id] = {
"value": f"""**{column_object.get("friendly_name")}** ({column_id}) "value": f"""**{column_object.get("title")}** ({column_id})
""" """
+ column_object["description"], + column_object["description"],
@@ -293,7 +356,154 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup
return columns, tooltip return columns, tooltip
lf = get_decp_data() def get_default_hidden_columns(schema: Schema):
displayed_columns = os.getenv("DISPLAYED_COLUMNS")
hidden_columns = []
if displayed_columns:
displayed_columns = displayed_columns.replace(" ", "").split(",")
for col in schema.names():
if col in displayed_columns:
continue
else:
hidden_columns.append(col)
return hidden_columns
raise ValueError("DISPLAYED_COLUMNS n'est pas configuré")
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
def track_search(query):
if (
len(query) >= 4
and os.getenv("DEVELOPMENT").lower != "true"
and os.getenv("MATOMO_DOMAIN")
):
if os.getenv("DEVELOPMENT").lower() == "true":
url = "https://test.decp.info"
else:
url = "https://decp.info"
params = {
"idsite": os.getenv("MATOMO_ID_SITE"),
"url": url,
"rec": "1",
"action_name": "front_page_search",
"rand": uuid.uuid4().hex,
"apiv": "1",
"h": localtime().tm_hour,
"m": localtime().tm_min,
"s": localtime().tm_sec,
"search": query,
"token_auth": os.getenv("MATOMO_TOKEN"),
}
post(
url=f"https://{os.getenv('MATOMO_DOMAIN')}/matomo.php",
params=params,
).raise_for_status()
def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
"""
Search in either 'acheteur' or 'titulaire' DataFrame.
:param dff: Polars DataFrame with acheteur or titulaire columns
:param query: User search string
:param org_type: 'acheteur' or 'titulaire'
:return: Filtered DataFrame with 'matches' column
"""
if not query.strip():
return dff.select(pl.lit(False).alias("matches"))
# Enregistrement des recherche dans Matomo
track_search(query)
# Normalize query
normalized_query = unidecode(query.strip()).upper()
tokens = [" " + t.strip() for t in normalized_query.split() if t.strip()]
# Define columns based on entity type
cols = [
f"{org_type}_id",
f"{org_type}_nom",
f"{org_type}_departement_nom",
f"{org_type}_departement_code",
f"{org_type}_commune_nom",
]
# Concatenate all fields into one string per row
org_str = pl.concat_str(pl.lit(" "), pl.col(cols), separator=" ")
# For each token, create a boolean column: True if token is found
token_matches = []
for token in tokens:
token_match = org_str.str.contains(token).alias(f"token_{token}")
token_matches.append(token_match)
# Count how many tokens match per row
match_score = pl.sum_horizontal(token_matches).alias("match_score")
# For each token, create a boolean column: True if token is found
token_matches = []
for token in tokens:
token_match = org_str.str.contains(token).alias(f"token_{token}")
token_matches.append(token_match)
# Sélection des colonnes
if org_type == "acheteur":
dff = dff.select(cols + ["Marchés"])
if org_type == "titulaire":
dff = dff.select(cols + ["Marchés", "titulaire_typeIdentifiant"])
# Apply and filter
dff = (
dff.with_columns(token_matches + [match_score])
.filter(pl.col("match_score") == len(tokens))
.sort("Marchés", descending=True)
.drop([f"token_{token}" for token in tokens])
)
# Format result
dff = add_links(dff, target="")
dff = dff.with_columns(
pl.concat_str(
pl.col(f"{org_type}_departement_nom"),
pl.lit(" ("),
pl.col(f"{org_type}_departement_code"),
pl.lit(")"),
).alias("Département")
)
dff = dff.select(f"{org_type}_id", f"{org_type}_nom", "Département", "Marchés")
return dff
df: pl.DataFrame = get_decp_data()
df_acheteurs = get_org_data(df, "acheteur")
df_titulaires = get_org_data(df, "titulaire")
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"
@@ -306,10 +516,4 @@ meta_content = {
"Pour une commande publique accessible à toutes et tous." "Pour une commande publique accessible à toutes et tous."
), ),
} }
data_schema = get_data_schema()
# 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",
}