Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0691329b04 | |||
| 359ba248ac | |||
| 19d7735306 | |||
| fd2f87e382 | |||
| fdeedde983 | |||
| 925d050c96 | |||
| 5d4ee692f3 | |||
| 6b6d2ca790 | |||
| fa84271759 | |||
| d8f5739168 | |||
| dd07351bdd | |||
| 4fb376d43d | |||
| 2335ae3d59 | |||
| 9e9770b1a0 | |||
| ce4570afad | |||
| 4c72c0e269 | |||
| 9a035ae431 | |||
| f511c4fcef | |||
| d58183beb5 | |||
| 18f8388df5 | |||
| ef39be3346 | |||
| 783fb88d0c | |||
| c19076ee85 | |||
| c44ef311c3 | |||
| 63622d83f5 | |||
| 5bfce2a1d9 | |||
| a3f8bd0981 | |||
| bb6c0dec26 | |||
| 459ca6a846 | |||
| 8637915eb2 | |||
| e187cf33aa | |||
| 2f3ce6a467 | |||
| 1310caef4d | |||
| efd9bd4238 | |||
| 39844071ad | |||
| 5c64177c6a | |||
| 1e85dbe562 | |||
| 6835f328b6 | |||
| a3eb48bacb | |||
| 1848c881ac | |||
| bbb04a4487 | |||
| e9faf80609 | |||
| f6a38a29bd | |||
| c6e096e42a | |||
| 4c6f63f723 | |||
| f3c8fd4481 | |||
| 5ca52fadd7 | |||
| ee91e7a91f | |||
| 392f862baa | |||
| 199476ec30 | |||
| c98a36f759 | |||
| 65e0f4c5f0 | |||
| bc7652f336 | |||
| 188383a882 | |||
| 7f3a0bcb21 | |||
| 3270abb7f6 | |||
| 2eb984a95f | |||
| 776126a481 | |||
| 59abf35a12 | |||
| a40eb64245 | |||
| acae1517cb | |||
| 9a9decde6e | |||
| a1ae962c73 | |||
| 0404aa0973 | |||
| effe01dedf | |||
| aaa70039d2 | |||
| 7dc5f09545 | |||
| 91e7f373ec | |||
| c7768ea8f1 | |||
| 816d0de324 | |||
| c2fab377d5 | |||
| d7cdf38cda | |||
| 85f0084b7b | |||
| 712dc5a7f8 | |||
| 84b94fd882 | |||
| 90e9bc3c8c |
@@ -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=
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# decp.info
|
# decp.info
|
||||||
|
|
||||||
> v2.1.0
|
> v2.2.3
|
||||||
|
|
||||||
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,61 @@ python run.py
|
|||||||
|
|
||||||
## Notes de version
|
## Notes de version
|
||||||
|
|
||||||
#### 2.1.0
|
##### 2.2.3 (4 décembre 2025)
|
||||||
|
|
||||||
|
- mise à jour de l'adresse email de contact (colmo.tech)
|
||||||
|
- message sur l'indisponibilité des données MINEF
|
||||||
|
|
||||||
|
##### 2.2.2 (22 novembre 2025)
|
||||||
|
|
||||||
|
- Correction d'un bug dans le téléchargement Excel
|
||||||
|
|
||||||
|
##### 2.2.1 (15 novembre 2025)
|
||||||
|
|
||||||
|
- Le moteur de recherche ignore les tirets ("franche comté" trouve "Bourgogne-Franche-Comté)
|
||||||
|
- Phrase "tagline" au-dessus du champ de recherche
|
||||||
|
- Les infos de Contact rebasculent dans À propos
|
||||||
|
- Police de caractère "Open Sans" généralisée
|
||||||
|
|
||||||
|
#### 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 🖼️
|
||||||
|
|||||||
+4
-3
@@ -1,10 +1,10 @@
|
|||||||
[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.3"
|
||||||
requires-python = ">= 3.10"
|
requires-python = ">= 3.10"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Colin Maudry", email = "colin+decp@maudry.com" }
|
{ name = "Colin Maudry", email = "colin@colmo.tech" }
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dash==3.2.0",
|
"dash==3.2.0",
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ app.layout = html.Div(
|
|||||||
],
|
],
|
||||||
className="logo",
|
className="logo",
|
||||||
),
|
),
|
||||||
|
html.Div(
|
||||||
|
id="announcements",
|
||||||
|
children=dcc.Markdown(
|
||||||
|
"ℹ️ La publication des données consolidées par le MINEF est actuellement [cassée](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-fichiers-consolides/#/discussions/69306148a4871a110fd7a7d0), ce qui casse la mise à jour de decp.info. Je travaille donc en priorité à une [désolidarisation des données du MINEF](https://github.com/ColinMaudry/decp-processing/issues/151). Merci pour votre patience."
|
||||||
|
),
|
||||||
|
),
|
||||||
html.Div(
|
html.Div(
|
||||||
[
|
[
|
||||||
dcc.Link(
|
dcc.Link(
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 333 KiB |
+56
-10
@@ -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
|
||||||
@@ -64,7 +65,7 @@ td[data-dash-column="objet"] {
|
|||||||
th.dash-header {
|
th.dash-header {
|
||||||
background-color: #b33821;
|
background-color: #b33821;
|
||||||
color: white;
|
color: white;
|
||||||
font-family: sans-serif;
|
font-family: "Open Sans", sans-serif;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ td[data-dash-column="objet"] {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 150px;
|
height: 150px;
|
||||||
text-wrap: wrap;
|
text-wrap: wrap;
|
||||||
font-family: sans-serif;
|
font-family: "Open Sans", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Menu de masquage des colonnes */
|
/* Menu de masquage des colonnes */
|
||||||
@@ -130,11 +131,11 @@ td[data-dash-column="objet"] {
|
|||||||
|
|
||||||
/* Alternance des couleurs pour les lignes */
|
/* Alternance des couleurs pour les lignes */
|
||||||
.marches_table {
|
.marches_table {
|
||||||
font-family: sans-serif;
|
font-family: "Open Sans", sans-serif;
|
||||||
}
|
}
|
||||||
.marches_table .cell-table tr:nth-child(even) td {
|
.marches_table .cell-table tr:nth-child(even) td {
|
||||||
background-color: #feeeee;
|
background-color: #feeeee;
|
||||||
font-family: sans-serif;
|
font-family: "Open Sans", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header > *,
|
#header > *,
|
||||||
@@ -142,6 +143,49 @@ 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 */
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 120%;
|
||||||
|
display: block;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search {
|
||||||
|
margin: 30px auto 0px auto;
|
||||||
|
width: 500px;
|
||||||
|
font-size: 16px;
|
||||||
|
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 +215,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 +223,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 +252,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 {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -36,13 +36,23 @@ les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
|
|||||||
- 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
|
- 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("Contact", id="contact"),
|
||||||
|
dcc.Markdown("""
|
||||||
|
|
||||||
|
- Email : [colin@colmo.tech](mailto:colin@colmo.tech)
|
||||||
|
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
|
||||||
|
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
|
||||||
|
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
|
||||||
|
|
||||||
|
"""),
|
||||||
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](/contact) et on discute !
|
- écrivez-moi et on discute !
|
||||||
|
|
||||||
#### Pour explorer le projet
|
#### Pour explorer le projet
|
||||||
|
|
||||||
@@ -54,7 +64,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
@@ -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"),
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
import os
|
|
||||||
import re
|
|
||||||
import smtplib
|
|
||||||
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 flask import request
|
|
||||||
|
|
||||||
from src.utils import meta_content
|
|
||||||
|
|
||||||
name = "Contact"
|
|
||||||
register_page(
|
|
||||||
__name__,
|
|
||||||
path="/contact",
|
|
||||||
title=meta_content["title"],
|
|
||||||
name=name,
|
|
||||||
description=meta_content["description"],
|
|
||||||
image_url=meta_content["image_url"],
|
|
||||||
order=6,
|
|
||||||
)
|
|
||||||
|
|
||||||
layout = html.Div(
|
|
||||||
className="container",
|
|
||||||
children=[
|
|
||||||
html.H2("Contact", id="contact"),
|
|
||||||
html.P(
|
|
||||||
"Votre message arrivera directement dans ma boîte mail, et je reviendrai vers vous rapidement."
|
|
||||||
),
|
|
||||||
html.Div(
|
|
||||||
[
|
|
||||||
html.Label("Votre nom"),
|
|
||||||
dcc.Input(
|
|
||||||
id="input-name",
|
|
||||||
type="text",
|
|
||||||
style={"width": "100%", "padding": "8px", "margin": "8px 0"},
|
|
||||||
),
|
|
||||||
html.Label("Votre adresse email"),
|
|
||||||
dcc.Input(
|
|
||||||
id="input-email",
|
|
||||||
type="email",
|
|
||||||
style={"width": "100%", "padding": "8px", "margin": "8px 0"},
|
|
||||||
),
|
|
||||||
html.Label("Message"),
|
|
||||||
dcc.Textarea(
|
|
||||||
id="input-message",
|
|
||||||
style={
|
|
||||||
"width": "100%",
|
|
||||||
"height": 200,
|
|
||||||
"padding": "8px",
|
|
||||||
"margin": "8px 0",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
html.Button(
|
|
||||||
"Envoyer ",
|
|
||||||
id="submit-button",
|
|
||||||
n_clicks=0,
|
|
||||||
style={"marginTop": "10px"},
|
|
||||||
),
|
|
||||||
html.Div(
|
|
||||||
id="output-message", style={"marginTop": "10px", "color": "green"}
|
|
||||||
),
|
|
||||||
],
|
|
||||||
style={
|
|
||||||
"maxWidth": "600px",
|
|
||||||
"margin": "auto",
|
|
||||||
"padding": "20px",
|
|
||||||
"lineHeight": "20px",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
dcc.Markdown("""
|
|
||||||
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
|
|
||||||
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
|
|
||||||
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
|
|
||||||
- venez discuter de la transparence de la commande publique [sur le forum teamopendata.org](https://teamopendata.org/c/commande-publique/101)
|
|
||||||
"""),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
rate_limit_store = {} # {ip: last_timestamp}
|
|
||||||
RATE_LIMIT_WINDOW = 300 # 5 minutes
|
|
||||||
|
|
||||||
|
|
||||||
def is_rate_limited():
|
|
||||||
ip = request.remote_addr
|
|
||||||
now = time.time()
|
|
||||||
last_time = rate_limit_store.get(ip)
|
|
||||||
|
|
||||||
if last_time and (now - last_time) < RATE_LIMIT_WINDOW:
|
|
||||||
return True # rate limited !
|
|
||||||
rate_limit_store[ip] = now # màj du timestamp
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_email(email):
|
|
||||||
return re.match(r"[^@]+@[^@]+\.[^@]+", email)
|
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
|
||||||
Output("output-message", "children"),
|
|
||||||
Input("submit-button", "n_clicks"),
|
|
||||||
State("input-name", "value"),
|
|
||||||
State("input-email", "value"),
|
|
||||||
State("input-message", "value"),
|
|
||||||
prevent_initial_call=True,
|
|
||||||
)
|
|
||||||
def send_email(n_clicks, form_name, form_email, form_message):
|
|
||||||
if not all([form_name, form_email, form_message]):
|
|
||||||
return html.Div(
|
|
||||||
"Veuillez s'il vous plaît remplir tous les champs.", style={"color": "red"}
|
|
||||||
)
|
|
||||||
|
|
||||||
client_ip = request.remote_addr
|
|
||||||
if is_rate_limited():
|
|
||||||
wait_time = int(RATE_LIMIT_WINDOW - (time.time() - rate_limit_store[client_ip]))
|
|
||||||
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"Veuillez s'il vous plaît attendre {wait_time} secondes avant de renvoyer un message.",
|
|
||||||
style={"color": "black"},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Configuration du serveur SMTP
|
|
||||||
smtp_server = os.getenv("SENDER_SERVER_DOMAIN")
|
|
||||||
smtp_port = 587
|
|
||||||
login_email = os.getenv("LOGIN_EMAIL")
|
|
||||||
from_email = os.getenv("FROM_EMAIL")
|
|
||||||
to_email = os.getenv("TO_EMAIL", from_email)
|
|
||||||
login_password = os.getenv("LOGIN_PASSWORD")
|
|
||||||
|
|
||||||
print(
|
|
||||||
smtp_server,
|
|
||||||
smtp_port,
|
|
||||||
login_email,
|
|
||||||
from_email,
|
|
||||||
to_email,
|
|
||||||
login_password,
|
|
||||||
sep="\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Création de l'email
|
|
||||||
email = MIMEMultipart()
|
|
||||||
email["From"] = from_email
|
|
||||||
email["To"] = to_email
|
|
||||||
email["Subject"] = f"[decp.info] Message de {form_name}"
|
|
||||||
|
|
||||||
body = f"""
|
|
||||||
Nom : {form_name}
|
|
||||||
Email : {form_email}
|
|
||||||
|
|
||||||
Message :
|
|
||||||
{form_message}
|
|
||||||
"""
|
|
||||||
email.attach(MIMEText(body, "plain"))
|
|
||||||
|
|
||||||
# Send email
|
|
||||||
server = smtplib.SMTP(smtp_server, smtp_port)
|
|
||||||
server.starttls()
|
|
||||||
server.login(login_email, login_password)
|
|
||||||
server.sendmail(from_email, to_email, email.as_string())
|
|
||||||
server.quit()
|
|
||||||
|
|
||||||
return html.Div("✅ Envoi réussi", style={"color": "green"})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
return html.Div(
|
|
||||||
f"❌ Échec de l'envoi du message : {str(e)}", style={"color": "red"}
|
|
||||||
)
|
|
||||||
+9
-13
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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=[
|
||||||
|
html.Div(
|
||||||
|
className="tagline",
|
||||||
|
children=html.P(
|
||||||
|
"Exploration et téléchargement des données des marchés publics"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dcc.Input(
|
||||||
|
id="search",
|
||||||
|
type="text",
|
||||||
|
placeholder="Nom d'acheteur/entreprise, SIREN/SIRET, code département",
|
||||||
|
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("")
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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):
|
||||||
print(" + + + + + + + + + + + + + + + + + + ")
|
if os.getenv("DEVELOPMENT").lower() == "true":
|
||||||
|
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.lazy() # 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
@@ -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"),
|
||||||
|
|||||||
+282
-76
@@ -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,48 +47,66 @@ 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"):
|
||||||
dff = dff.with_columns(
|
for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
|
||||||
pl.when(pl.col("titulaire_typeIdentifiant") == "SIRET")
|
if col in dff.columns:
|
||||||
.then(
|
if col.startswith("titulaire_"):
|
||||||
'<a href = "/titulaires/'
|
dff = dff.with_columns(
|
||||||
+ pl.col("titulaire_id")
|
pl.when(
|
||||||
+ '">'
|
pl.Expr.or_(
|
||||||
+ pl.col("titulaire_id")
|
pl.col("titulaire_typeIdentifiant").is_null(),
|
||||||
+ "</a>"
|
pl.col("titulaire_typeIdentifiant") == "SIRET",
|
||||||
)
|
)
|
||||||
.otherwise(pl.col("titulaire_id"))
|
)
|
||||||
.alias("titulaire_id")
|
.then(
|
||||||
)
|
'<a href = "/titulaires/'
|
||||||
|
+ pl.col("titulaire_id")
|
||||||
for column, path in [("acheteur_id", "acheteurs"), ("uid", "marches")]:
|
+ f'" target="{target}">'
|
||||||
dff = dff.with_columns(
|
+ pl.col(col)
|
||||||
(
|
+ "</a>"
|
||||||
f'<a href = "/{path}/'
|
)
|
||||||
+ pl.col(column)
|
.otherwise(pl.col(col))
|
||||||
+ '" target="_blank">'
|
.alias(col)
|
||||||
+ pl.col(column)
|
)
|
||||||
+ "</a>"
|
if col.startswith("acheteur_"):
|
||||||
).alias(column)
|
dff = dff.with_columns(
|
||||||
)
|
(
|
||||||
|
'<a href = "/acheteurs/'
|
||||||
|
+ pl.col("acheteur_id")
|
||||||
|
+ f'" target="{target}">'
|
||||||
|
+ pl.col(col)
|
||||||
|
+ "</a>"
|
||||||
|
).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>'
|
||||||
)
|
)
|
||||||
marche["id"] = f'<a href="/marches/{marche["uid"]}">{marche["id"]}</a>'
|
if marche.get("uid"):
|
||||||
marche["uid"] = f'<a href="/marches/{marche["uid"]}">{marche["uid"]}</a>'
|
marche["id"] = f'<a href="/marches/{marche["uid"]}">{marche["id"]}</a>'
|
||||||
|
marche["uid"] = f'<a href="/marches/{marche["uid"]}">{marche["uid"]}</a>'
|
||||||
new_data.append(marche)
|
new_data.append(marche)
|
||||||
return new_data
|
return new_data
|
||||||
|
|
||||||
@@ -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])
|
||||||
print("filter_value:", filter_value)
|
if debug:
|
||||||
print("filter_value_type:", type(filter_value))
|
print("filter_value:", filter_value)
|
||||||
print("col_type:", col_type)
|
print("filter_value_type:", type(filter_value))
|
||||||
|
print("operator:", operator)
|
||||||
|
print("col_type:", col_type)
|
||||||
|
|
||||||
|
lff = lff.filter(pl.col(col_name).is_not_null())
|
||||||
|
|
||||||
if col_type == "Date":
|
if col_type == "Date":
|
||||||
# Convertir la colonne en chaînes de caractères
|
# Convertir la colonne date en chaînes de caractères
|
||||||
lff = dates_to_strings(lff, col_name)
|
lff = dates_to_strings(lff, col_name)
|
||||||
|
col_type = "String"
|
||||||
|
if col_type == "String":
|
||||||
|
lff = lff.filter(pl.col(col_name) != pl.lit(""))
|
||||||
|
|
||||||
if operator in ("<", "<=", ">", ">="):
|
elif col_type.startswith("Int") or col_type.startswith("Float"):
|
||||||
lff = lff.filter(
|
try:
|
||||||
pl.col(col_name).is_not_null() & (pl.col(col_name) != pl.lit(""))
|
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":
|
||||||
elif col_type.startswith("Int") or col_type.startswith("Float"):
|
if col_type in ["String", "Date"]:
|
||||||
try:
|
lff = lff.filter(
|
||||||
filter_value = int(filter_value)
|
pl.col(col_name).str.contains("(?i)" + filter_value)
|
||||||
except ValueError:
|
)
|
||||||
logger.error(f"Invalid numeric filter value: {filter_value}")
|
elif col_type.startswith("Int") or col_type.startswith("Float"):
|
||||||
continue
|
lff = lff.filter(pl.col(col_name) == filter_value)
|
||||||
lff = lff.filter(pl.col(col_name) == filter_value)
|
else:
|
||||||
|
logger.error(f"Invalid column type: {col_type}")
|
||||||
elif operator == "contains" and col_type in ["String", "Date"]:
|
else:
|
||||||
lff = lff.filter(pl.col(col_name).str.contains("(?i)" + filter_value))
|
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,156 @@ 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=" ").str.replace(
|
||||||
|
"-", " "
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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 +518,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",
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user