Compare commits

...

64 Commits

Author SHA1 Message Date
Colin Maudry 59abf35a12 Merge branch 'hotfix/2.1.4' 2025-10-08 09:34:00 +02:00
Colin Maudry a40eb64245 Changelog 2.1.4 2025-10-08 09:33:51 +02:00
Colin Maudry acae1517cb Nouvelle tentative auto-release 2025-10-08 09:31:41 +02:00
Colin Maudry 9a9decde6e Possible de filtrer sur le champ 'Source' 2025-10-08 09:26:44 +02:00
Colin Maudry a1ae962c73 Changelog 2.1.3 2025-10-04 19:15:56 +02:00
Colin Maudry 0404aa0973 Adaptation au format TableSchema 2025-10-04 19:13:51 +02:00
Colin Maudry effe01dedf Tentative d'auto release à partir d'un tag 2025-10-04 19:13:25 +02:00
Colin Maudry aaa70039d2 Merge branch 'hotfix/2.1.2' 2025-10-03 16:45:35 +02:00
Colin Maudry 7dc5f09545 Changelog 2.1.2 2025-10-03 16:45:29 +02:00
Colin Maudry 91e7f373ec Coquille 2025-10-03 16:42:44 +02:00
Colin Maudry c7768ea8f1 Global lf => global df pour être moins sensible au update de fichier source 2025-10-03 16:29:58 +02:00
Colin Maudry c2fab377d5 Merge branch 'hotfix/2.1.1' 2025-10-01 13:22:07 +02:00
Colin Maudry d7cdf38cda Changelog 2.1.1 2025-10-01 13:22:02 +02:00
Colin Maudry 85f0084b7b Exhaustivité et qualité des données close #43 2025-10-01 13:19:56 +02:00
Colin Maudry 712dc5a7f8 Rétrécissement de la carte 2025-10-01 13:18:58 +02:00
Colin Maudry 84b94fd882 Affiche du nombre de marchés 2025-10-01 11:57:55 +02:00
Colin Maudry 442f1ded27 Merge branch 'release/2.1.0' 2025-09-30 17:14:16 +02:00
Colin Maudry e4dcc19e8b template.env 2025-09-30 17:13:42 +02:00
Colin Maudry 0b828c3d67 Numéro de version, description toml 2025-09-30 17:12:41 +02:00
Colin Maudry bc930bf6c9 Màj instruction #40 2025-09-30 15:09:21 +02:00
Colin Maudry 070e8c98b9 Merge branch 'feature/40_vue_marché' into dev 2025-09-30 15:02:04 +02:00
Colin Maudry 7c157d9d71 Vue marché simple #40 2025-09-30 15:01:54 +02:00
Colin Maudry 79d11b326e Formulaire de contact avec rate limiting simple #48 2025-09-30 11:35:18 +02:00
Colin Maudry 51c023a803 Merge branch 'feature/48_contact_form' into dev 2025-09-29 20:07:19 +02:00
Colin Maudry 819f2e315f Affichage du numéro de version et lien vers changelog 2025-09-29 20:06:04 +02:00
Colin Maudry 928f75f504 Formulaire de contact avec rate limiting simple #48 2025-09-29 19:32:15 +02:00
Colin Maudry 1d21cbb69d Début de configuration de colonnes partagée 2025-09-29 17:36:12 +02:00
Colin Maudry ee2bae190d Récupération du schéma #33 2025-09-29 17:08:45 +02:00
Colin Maudry ad6005259e Les filtres <> marchent aussi avec les strings #42 2025-09-29 17:07:35 +02:00
Colin Maudry f01a991f4f Améliorations de style 2025-09-29 17:06:16 +02:00
Colin Maudry a44e1a37cb Noms de colonnes friendly et définition de colonne (tooltip) #33 2025-09-29 17:05:37 +02:00
Colin Maudry 502f652791 Chemin vers le schéma de données configurable 2025-09-29 17:03:44 +02:00
Colin Maudry fdb38b3b9b Formatage des montants #45 2025-09-28 23:11:09 +02:00
Colin Maudry 4394f38e35 Fix link titulaire 2025-09-28 22:19:07 +02:00
Colin Maudry ce9d23100d Correction des balises meta #39 2025-09-28 22:08:03 +02:00
Colin Maudry 70c5b91e6d Configuration des filtres (case insensitive, placeholder text #28 #35 2025-09-28 21:27:40 +02:00
Colin Maudry 4895bfc5ac Ajout des filtres natifs #28 #35 2025-09-28 21:05:23 +02:00
Colin Maudry d9db156626 Liens entre pages acheteur et titulaire, fournisseur => titulaire #28 #35 2025-09-28 21:01:22 +02:00
Colin Maudry d00efcd500 Script de déploiement intégré au yaml 2025-09-28 20:40:35 +02:00
Colin Maudry 41df3f6d8e Merge branch 'feature/39_meta' into dev 2025-09-28 20:21:36 +02:00
Colin Maudry 0db814710b Changelog 2025-09-28 20:21:26 +02:00
Colin Maudry c1955ac807 Ajout des balises meta og et twitter (à tester) #39 2025-09-28 20:13:38 +02:00
Colin Maudry 4081b369d9 Recherche possible dans les dates (comme des strings) #37 2025-09-28 19:16:50 +02:00
Colin Maudry 8a63f72bee Vue fournisseur minimaliste #35 2025-09-28 18:40:00 +02:00
Colin Maudry cc05ac0d99 departements.json 2025-09-28 18:02:31 +02:00
Colin Maudry 8d236ab8dc Module httpx 2025-09-28 17:57:32 +02:00
Colin Maudry 0e20b0012f Début de vue fournisseur #35 2025-09-28 00:34:47 +02:00
Colin Maudry 5122bb7f3a Merge branch 'feature/28_vue_acheteur' into dev 2025-09-28 00:33:33 +02:00
Colin Maudry 4b8565dcf2 Chanlog 2025-09-28 00:33:21 +02:00
Colin Maudry 98b8699afb Rétablissement de la carte des marchés par dpt 2025-09-28 00:30:52 +02:00
Colin Maudry 1c5868d75f Utilisation de State plutôt qu'Input pour ne pas trigger le download #28 2025-09-27 23:58:23 +02:00
Colin Maudry bfa181cefd Suppression de la variable globale df_filtered, et adaptations 2025-09-27 23:57:51 +02:00
Colin Maudry 944f207d9b Téléchargement des données acheteur filtrées #28 2025-09-27 23:15:20 +02:00
Colin Maudry 8f335a52ea Notifié => attribué #28 2025-09-27 21:50:33 +02:00
Colin Maudry e88992a470 Le lien acheteur mène maintenant à la page acheteur au lieu de l'annuaire #28 2025-09-27 21:39:40 +02:00
Colin Maudry 9e5673c28f Ajout des liens dans le tableau le plus tard possible 2025-09-27 21:14:11 +02:00
Colin Maudry 2fe92d8a14 Plus d'informations, stats #28 2025-09-27 20:49:15 +02:00
Colin Maudry b88d1c9f93 Pagination derniers marchés #28 2025-09-27 18:16:53 +02:00
Colin Maudry 59321fc80a Commune acheteur, carte localisation #28 2025-09-27 14:29:48 +02:00
Colin Maudry df04286a4a Derniers marché notifiés #28 2025-09-27 11:48:57 +02:00
Colin Maudry 2fc319d1bc Merge branch 'main' into feature/28_vue_acheteur 2025-09-27 10:08:30 +02:00
Colin Maudry 29428a8706 Merge tag 'v2.0.1' into dev
- Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/decp.info/issues/38)
- Amélioration du script de déploiement (deploy.sh)
- Meilleures instructions d'installation et lancement
- Coquilles 🐚
2025-09-24 10:24:09 +02:00
Colin Maudry 41d3ca7539 WIP #28 2025-09-19 08:20:15 +02:00
Colin Maudry 636e0b059d Récupération des données acheteur depuis annuaire #28 2025-08-21 11:56:39 +02:00
20 changed files with 2147 additions and 376 deletions
+9 -1
View File
@@ -40,4 +40,12 @@ jobs:
key: ${{ secrets.ARTIFACT_SSH_KEY }}
passphrase: ${{ secrets.SSH_PSWD }}
command_timeout: 5m
script: ${{ secrets.APP_PATH }}/deploy.sh ${{ env.APP_NAME }}
script: |
systemctl stop ${{ vars.APP_NAME }}
cd /var/www/${{ vars.APP_NAME }}
git pull
source .venv/bin/activate
pip install .
deactivate
chown -R ${{ vars.APP_NAME }}:www-data *
systemctl start ${{ vars.APP_NAME }}
+29
View File
@@ -0,0 +1,29 @@
on:
push:
# Sequence of patterns matched against refs/tags
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
name: Create Release
jobs:
build:
name: Auto-release d'un tag
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@master
- name: Get tag message
run: echo "TAG_MESSAGE=`git show ${{ github.ref_name }} | grep '^\- '`" >> "$GITHUB_ENV"
- name: Create Release
id: create_release
uses: actions/create-release@latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
TAG_MESSAGE: ${{ env.TAG_MESSAGE }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref_name }}
body: ${{ env.TAG_MESSAGE }}
draft: false
prerelease: false
+10
View File
@@ -2,3 +2,13 @@ DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1
PORT=8050
DEVELOPMENT=True
SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234"
# 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
# Formulaire de contact
SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP
LOGIN_PASSWORD="" # mot de passe du serveur
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)
TO_EMAIL="to@example.com" # adresse de destination des emails (To)
+34 -3
View File
@@ -1,6 +1,6 @@
# decp.info
> v2.0.1
> v2.1.4
Outil d'exploration et de téléchargement des données essentielles de la commande publique.
@@ -26,9 +26,11 @@ python run.py
## Déploiement
- **Production** (branche `main`, [decp.info](https://decp.info)) : déploiement manuel via un déclenchement de la Github Action [Déploiement](https://github.com/ColinMaudry/decp.info/actions/workflows/deploy.yaml).
- **Production** (branche `main`, [decp.info](https://decp.info)) : déploiement manuel via un déclenchement de la Github Action [Déploiement](https://github.com/ColinMaudry/decp.info/actions/workflows/deploy.yaml)
- **Test** (branche `dev`, [test.decp.info](https://test.decp.info)) : déploiement automatique à chaque push sur la branche `dev`, via la même Github Action.
Ne pas oublier de mettre à jour les fichier .env.
## Liens connexes
- [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données)
@@ -36,7 +38,36 @@ python run.py
## Notes de version
#### 2.0.1 (23 septembre 2025)
##### 2.1.4 (8 octobre 2025)
- possibilité de filtrer sur le champ "Source"
- création automatique d'une release Github quand je push un tag
##### 2.1.3 (4 octobre 2025)
- tentative d'auto-release à chaque création de tag git
- adaptation au format TableSchema
##### 2.1.2 (3 octobre 2025)
- dataframe global plutôt que lazyframe, pour plus de résilience et charger toutes les données en mémoire
##### 2.1.1 (1er octobre 2025)
- ajout d'une section dans À propos sur la qualité et l'exhaustivité des données ([#43](https://github.com/ColinMaudry/decp.info/issues/43))
- ajout du nombre de marchés en plus du nombre de lignes dans la vue Tableau
#### 2.1.0 (30 septembre 2025)
- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/decp.info/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786) ([#35](https://github.com/ColinMaudry/decp.info/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/decp.info/issues/40)) 🔎
- Ajout des balises HTML meta Open Graph et Twitter ([#39](https://github.com/ColinMaudry/decp.info/issues/39)) pour de beaux aperçus de liens 🖼️
- 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 (😁)
##### 2.0.1 (23 septembre 2025)
- Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/decp.info/issues/38)
- Amélioration du script de déploiement (deploy.sh)
+398
View File
@@ -0,0 +1,398 @@
{
"01": {
"departement": "Ain",
"region": "Auvergne-Rhône-Alpes"
},
"02": {
"departement": "Aisne",
"region": "Hauts-de-France"
},
"03": {
"departement": "Allier",
"region": "Auvergne-Rhône-Alpes"
},
"04": {
"departement": "Alpes-de-Haute-Provence",
"region": "Provence-Alpes-Côte d'Azur"
},
"05": {
"departement": "Hautes-Alpes",
"region": "Provence-Alpes-Côte d'Azur"
},
"06": {
"departement": "Alpes-Maritimes",
"region": "Provence-Alpes-Côte d'Azur"
},
"07": {
"departement": "Ardèche",
"region": "Auvergne-Rhône-Alpes"
},
"08": {
"departement": "Ardennes",
"region": "Grand Est"
},
"09": {
"departement": "Ariège",
"region": "Occitanie"
},
"10": {
"departement": "Aube",
"region": "Grand Est"
},
"11": {
"departement": "Aude",
"region": "Occitanie"
},
"12": {
"departement": "Aveyron",
"region": "Occitanie"
},
"13": {
"departement": "Bouches-du-Rhône",
"region": "Provence-Alpes-Côte d'Azur"
},
"14": {
"departement": "Calvados",
"region": "Normandie"
},
"15": {
"departement": "Cantal",
"region": "Auvergne-Rhône-Alpes"
},
"16": {
"departement": "Charente",
"region": "Nouvelle-Aquitaine"
},
"17": {
"departement": "Charente-Maritime",
"region": "Nouvelle-Aquitaine"
},
"18": {
"departement": "Cher",
"region": "Centre-Val de Loire"
},
"19": {
"departement": "Corrèze",
"region": "Nouvelle-Aquitaine"
},
"21": {
"departement": "Côte-d'Or",
"region": "Bourgogne-Franche-Comté"
},
"22": {
"departement": "Côtes-d'Armor",
"region": "Bretagne"
},
"23": {
"departement": "Creuse",
"region": "Nouvelle-Aquitaine"
},
"24": {
"departement": "Dordogne",
"region": "Nouvelle-Aquitaine"
},
"25": {
"departement": "Doubs",
"region": "Bourgogne-Franche-Comté"
},
"26": {
"departement": "Drôme",
"region": "Auvergne-Rhône-Alpes"
},
"27": {
"departement": "Eure",
"region": "Normandie"
},
"28": {
"departement": "Eure-et-Loir",
"region": "Centre-Val de Loire"
},
"29": {
"departement": "Finistère",
"region": "Bretagne"
},
"30": {
"departement": "Gard",
"region": "Occitanie"
},
"31": {
"departement": "Haute-Garonne",
"region": "Occitanie"
},
"32": {
"departement": "Gers",
"region": "Occitanie"
},
"33": {
"departement": "Gironde",
"region": "Nouvelle-Aquitaine"
},
"34": {
"departement": "Hérault",
"region": "Occitanie"
},
"35": {
"departement": "Ille-et-Vilaine",
"region": "Bretagne"
},
"36": {
"departement": "Indre",
"region": "Centre-Val de Loire"
},
"37": {
"departement": "Indre-et-Loire",
"region": "Centre-Val de Loire"
},
"38": {
"departement": "Isère",
"region": "Auvergne-Rhône-Alpes"
},
"39": {
"departement": "Jura",
"region": "Bourgogne-Franche-Comté"
},
"40": {
"departement": "Landes",
"region": "Nouvelle-Aquitaine"
},
"41": {
"departement": "Loir-et-Cher",
"region": "Centre-Val de Loire"
},
"42": {
"departement": "Loire",
"region": "Auvergne-Rhône-Alpes"
},
"43": {
"departement": "Haute-Loire",
"region": "Auvergne-Rhône-Alpes"
},
"44": {
"departement": "Loire-Atlantique",
"region": "Pays de la Loire"
},
"45": {
"departement": "Loiret",
"region": "Centre-Val de Loire"
},
"46": {
"departement": "Lot",
"region": "Occitanie"
},
"47": {
"departement": "Lot-et-Garonne",
"region": "Nouvelle-Aquitaine"
},
"48": {
"departement": "Lozère",
"region": "Occitanie"
},
"49": {
"departement": "Maine-et-Loire",
"region": "Pays de la Loire"
},
"50": {
"departement": "Manche",
"region": "Normandie"
},
"51": {
"departement": "Marne",
"region": "Grand Est"
},
"52": {
"departement": "Haute-Marne",
"region": "Grand Est"
},
"53": {
"departement": "Mayenne",
"region": "Pays de la Loire"
},
"54": {
"departement": "Meurthe-et-Moselle",
"region": "Grand Est"
},
"55": {
"departement": "Meuse",
"region": "Grand Est"
},
"56": {
"departement": "Morbihan",
"region": "Bretagne"
},
"57": {
"departement": "Moselle",
"region": "Grand Est"
},
"58": {
"departement": "Nièvre",
"region": "Bourgogne-Franche-Comté"
},
"59": {
"departement": "Nord",
"region": "Hauts-de-France"
},
"60": {
"departement": "Oise",
"region": "Hauts-de-France"
},
"61": {
"departement": "Orne",
"region": "Normandie"
},
"62": {
"departement": "Pas-de-Calais",
"region": "Hauts-de-France"
},
"63": {
"departement": "Puy-de-Dôme",
"region": "Auvergne-Rhône-Alpes"
},
"64": {
"departement": "Pyrénées-Atlantiques",
"region": "Nouvelle-Aquitaine"
},
"65": {
"departement": "Hautes-Pyrénées",
"region": "Occitanie"
},
"66": {
"departement": "Pyrénées-Orientales",
"region": "Occitanie"
},
"67": {
"departement": "Bas-Rhin",
"region": "Grand Est"
},
"68": {
"departement": "Haut-Rhin",
"region": "Grand Est"
},
"69": {
"departement": "Rhône",
"region": "Auvergne-Rhône-Alpes"
},
"70": {
"departement": "Haute-Saône",
"region": "Bourgogne-Franche-Comté"
},
"71": {
"departement": "Saône-et-Loire",
"region": "Bourgogne-Franche-Comté"
},
"72": {
"departement": "Sarthe",
"region": "Pays de la Loire"
},
"73": {
"departement": "Savoie",
"region": "Auvergne-Rhône-Alpes"
},
"74": {
"departement": "Haute-Savoie",
"region": "Auvergne-Rhône-Alpes"
},
"75": {
"departement": "Paris",
"region": "Île-de-France"
},
"76": {
"departement": "Seine-Maritime",
"region": "Normandie"
},
"77": {
"departement": "Seine-et-Marne",
"region": "Île-de-France"
},
"78": {
"departement": "Yvelines",
"region": "Île-de-France"
},
"79": {
"departement": "Deux-Sèvres",
"region": "Nouvelle-Aquitaine"
},
"80": {
"departement": "Somme",
"region": "Hauts-de-France"
},
"81": {
"departement": "Tarn",
"region": "Occitanie"
},
"82": {
"departement": "Tarn-et-Garonne",
"region": "Occitanie"
},
"83": {
"departement": "Var",
"region": "Provence-Alpes-Côte d'Azur"
},
"84": {
"departement": "Vaucluse",
"region": "Provence-Alpes-Côte d'Azur"
},
"85": {
"departement": "Vendée",
"region": "Pays de la Loire"
},
"86": {
"departement": "Vienne",
"region": "Nouvelle-Aquitaine"
},
"87": {
"departement": "Haute-Vienne",
"region": "Nouvelle-Aquitaine"
},
"88": {
"departement": "Vosges",
"region": "Grand Est"
},
"89": {
"departement": "Yonne",
"region": "Bourgogne-Franche-Comté"
},
"90": {
"departement": "Territoire de Belfort",
"region": "Bourgogne-Franche-Comté"
},
"91": {
"departement": "Essonne",
"region": "Île-de-France"
},
"92": {
"departement": "Hauts-de-Seine",
"region": "Île-de-France"
},
"93": {
"departement": "Seine-Saint-Denis",
"region": "Île-de-France"
},
"94": {
"departement": "Val-de-Marne",
"region": "Île-de-France"
},
"95": {
"departement": "Val-d'Oise",
"region": "Île-de-France"
},
"971": {
"departement": "Guadeloupe",
"region": "Guadeloupe"
},
"972": {
"departement": "Martinique",
"region": "Martinique"
},
"973": {
"departement": "Guyane",
"region": "Guyane"
},
"974": {
"departement": "La Réunion",
"region": "La Réunion"
},
"976": {
"departement": "Mayotte",
"region": "Mayotte"
}
}
-15
View File
@@ -1,15 +0,0 @@
#!bin/bash
# Utilisé principalement avec les Github Actions
# cf. .github/workflows/deploy.yaml
appname = "$1"
systemctl stop $appname
cd /var/www/$appname
git pull
source .venv/bin/activate
pip install .
deactivate
chown -R $appname:www-data *
systemctl start $appname
+4 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "decp.info"
description = ""
version = "2.0.1"
description = "Interface d'exploration et d'analyse des marchés publics français."
version = "2.1.4"
requires-python = ">= 3.10"
authors = [
{ name = "Colin Maudry", email = "colin+decp@maudry.com" }
@@ -15,6 +15,8 @@ dependencies = [
"python-dotenv",
"xlsxwriter",
"plotly[express]",
"httpx",
"pandas" # utilisé pour la création de certains graphiques
]
[project.optional-dependencies]
+25 -11
View File
@@ -1,9 +1,13 @@
import logging
import dash_bootstrap_components as dbc
import tomllib
from dash import Dash, dcc, html, page_container, page_registry
from dotenv import load_dotenv
from flask import send_from_directory
load_dotenv()
app = Dash(
external_stylesheets=[dbc.themes.SIMPLEX],
title="decp.info",
@@ -29,9 +33,14 @@ logging.basicConfig(
datefmt="%Y-%m-%d %H:%M:%S",
)
with open("./pyproject.toml", "rb") as f:
pyproject = tomllib.load(f)
version = "v" + pyproject["project"]["version"]
app.index_string = """
<!DOCTYPE html>
<html>
<html lang="fr">
<head>
{%metas%}
<title>{%title%}</title>
@@ -67,13 +76,27 @@ app.layout = html.Div(
[
html.Div(
[
html.H1("decp.info"),
html.Div(
[
html.A(children=html.H1("decp.info"), href="/"),
html.P(
children=html.A(
version,
href="https://github.com/ColinMaudry/decp.info?tab=readme-ov-file#notes-de-version",
target="_blank",
),
className="version",
),
],
className="logo",
),
html.Div(
[
dcc.Link(
page["name"], href=page["relative_path"], className="nav"
)
for page in page_registry.values()
if page["name"] not in ["Acheteur", "Titulaire", "Marché"]
]
),
],
@@ -82,15 +105,6 @@ app.layout = html.Div(
page_container,
]
)
# @callback(
# Output(component_id="table", component_property="data", allow_duplicate=True),
# Input(component_id="search", component_property="value"),
# prevent_initial_call=True,
# )
# def global_search(text):
# new_df = df
# new_df = new_df.filter(pl.col("objet").str.contains("(?i)" + text))
# return new_df.to_dicts()
if __name__ == "__main__":
app.run(debug=True)
Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

+136 -6
View File
@@ -18,6 +18,39 @@
margin-bottom: 25px;
}
#instructions {
max-width: 1000px;
}
details > div {
padding-top: 24px;
}
/* Logo et version */
div.logo {
width: 230px;
}
p.version {
float: left;
margin-top: 21px;
margin-left: 12px;
}
p.version > a {
text-decoration: none;
}
div.logo h1 {
float: left;
}
div.logo > a {
text-decoration: none;
color: black;
}
/* Réduire la taille du texte de la colonne Objet */
td[data-dash-column="objet"] {
@@ -31,6 +64,8 @@ td[data-dash-column="objet"] {
th.dash-header {
background-color: #b33821;
color: white;
font-family: sans-serif;
text-align: left;
}
.dash-table-container
@@ -48,10 +83,33 @@ td[data-dash-column="objet"] {
display: none;
}
.dash-tooltip,
.dash-table-tooltip {
color: #333;
width: 400px !important;
max-width: 400px !important;
height: 150px !important;
max-height: 150px !important;
overflow: hidden;
}
.dash-tooltip pre,
.dash-tooltip code {
overflow: hidden;
height: 150px;
text-wrap: wrap;
font-family: sans-serif;
}
/* Menu de masquage des colonnes */
.column-header--hide svg {
.column-actions {
margin-right: 8px;
}
.column-header--hide {
display: none;
}
.show-hide {
position: relative;
width: 180px;
@@ -71,8 +129,12 @@ td[data-dash-column="objet"] {
}
/* Alternance des couleurs pour les lignes */
#table tr:nth-child(even) td {
.marches_table {
font-family: sans-serif;
}
.marches_table .cell-table tr:nth-child(even) td {
background-color: #feeeee;
font-family: sans-serif;
}
#header > *,
@@ -96,15 +158,83 @@ a.nav {
font-size: 120%;
}
.navbar.h1 {
float: left;
width: 50%;
h3 {
margin: 36px 0 24px 0;
}
h3 {
summary > h3 {
margin: 0;
display: inline;
}
#_pages_content {
padding-top: 28px;
}
/* Vue acheteur/titulaire */
.wrapper {
display: grid;
grid-gap: 10px;
margin-bottom: 50px;
justify-content: space-between;
}
.wrapper > div {
}
.org_title {
grid-column: 1 / 3;
grid-row: 1;
}
.org_year {
grid-column: 3;
grid-row: 1;
}
.org_infos {
grid-column: 1;
grid-row: 2;
}
.org_infos > p {
margin: 8px 0;
}
.org_stats {
grid-column: 2;
grid-row: 2;
}
.org_map {
grid-column: 3;
grid-row: 2;
}
/* Vue marché */
.marche_infos p {
margin-top: 6px;
margin-bottom: 6px;
}
.marche_title {
grid-column: 1/3;
grid-row: 1;
}
.marche_map {
grid-column: 3;
grid-row: 2;
width: 400px;
}
.marche_infos_1 {
grid-column: 1;
grid-row: 2;
}
.marche_infos_2 {
grid-column: 2;
grid-row: 2;
}
+42 -7
View File
@@ -2,10 +2,11 @@ import json
import plotly.express as px
import polars as pl
from dash import dash_table, 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(
pl.col("lieuExecution_code").str.head(2).str.zfill(2).alias("Département")
)
@@ -26,7 +27,7 @@ def get_map_count_marches(lf: pl.LazyFrame):
for f in departements["features"]:
f["id"] = f["properties"]["code"]
df = lf.collect()
df = lf.collect(engine="streaming")
fig = px.choropleth(
df,
@@ -38,8 +39,8 @@ def get_map_count_marches(lf: pl.LazyFrame):
range_color=(df["uid"].min(), df["uid"].max()),
labels={"uid": "Marchés attribués"},
scope="europe",
width=1000,
height=800,
width=900,
height=700,
)
fig.update_geos(fitbounds="locations", visible=False)
@@ -54,7 +55,8 @@ def get_map_count_marches(lf: pl.LazyFrame):
return fig
def get_barchart_sources(lf: pl.LazyFrame, type_date: str):
def get_barchart_sources(df: pl.DataFrame, type_date: str):
lf = df.lazy()
labels = {
"dateNotification": "notification",
"datePublicationDonnees": "publication des données",
@@ -97,7 +99,7 @@ def get_barchart_sources(lf: pl.LazyFrame, type_date: str):
# )
lf = lf.sort(by=["sourceDataset"], descending=False)
df: pl.DataFrame = lf.collect()
df: pl.DataFrame = lf.collect(engine="streaming")
fig = px.bar(
df,
@@ -157,3 +159,36 @@ def get_sources_tables(source_path) -> html.Div:
datatable.data = df.to_dicts()
return html.Div(children=datatable)
def point_on_map(lat, lon):
lat = float(lat)
lon = float(lon)
# Create a scatter mapbox or choropleth map
fig = px.scatter_map(
lat=[lat], lon=[lon], height=300, width=400, color=[1], size=[1]
)
fig.update_coloraxes(showscale=False)
# Set map style (you can use 'open-street-map', 'carto-positron', etc.)
fig.update_layout(
mapbox_style="light", # Light, clean background
margin={"r": 0, "t": 0, "l": 0, "b": 0},
)
# Optionally, center the map on France
fig.update_geos(
center=dict(lat=46.603354, lon=1.888334), # Center of France
lataxis_range=[41, 51.5], # Latitude range for France
lonaxis_range=[-5, 10], # Longitude range for France
)
# But scatter_mapbox doesn't use geos, so better to control via zoom/center manually
# Let's reset and use proper centering in scatter_mapbox instead:
fig.update_layout(map_center={"lat": 46.6, "lon": 1.89}, map_zoom=4)
graph = dcc.Graph(id="map", figure=fig)
return graph
+27 -18
View File
@@ -1,23 +1,27 @@
import os
from dash import dcc, html, register_page
from dotenv import load_dotenv
from src.figures import get_sources_tables
from src.utils import meta_content
title = "À propos"
load_dotenv()
name = "À propos"
register_page(
__name__, path="/a-propos", title=f"decp.info - {title}", name=title, order=5
__name__,
path="/a-propos",
title=meta_content["title"],
name=name,
description=meta_content["description"],
image_url=meta_content["image_url"],
order=5,
)
layout = [
html.Div(
className="container",
children=[
html.H2(title),
html.H2(name),
dcc.Markdown(
"""Outil d'exploration libre et gratuit des données de marchés publics, développé par Colin Maudry.
@@ -27,7 +31,7 @@ j'aimerais beaucoup échanger avec vous pour comprendre vos cas d'usages et vos
En effet, le potentiel des données d'attribution de marchés et des données qui peuvent les enrichir est très loin d'être exploité par
les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
- de nombreuses visualisations de données (cartes, graphiques, tableaux) sur des thématiques variées (vivacité de la concurrence, secteurs d'activité, insertion par l'activité économique (IAE), distance acheteur-fournisseur...)
- de nombreuses visualisations de données (cartes, graphiques, tableaux) sur des thématiques variées (vivacité de la concurrence, secteurs d'activité, insertion par l'activité économique (IAE), distance acheteur-titulaire...)
- la sauvegarde de filtres pour les retrouver plus tard et les partager
- des alertes par email si des marchés correspondant à certains critères
- le développement d'une API pour alimenter d'autres logiciels
@@ -38,27 +42,32 @@ les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
dcc.Markdown("""
- via l'achat d'une prestation de service (devis, prestation, facture), vous pouvez financer le développement de [fonctionnalités prévues](https://github.com/ColinMaudry/decp.info/issues), ou d'autres !
- ma société accepte aussi les dons (pas de réduction d'impôt possible)
- envoyez un mail et on discute !
- [écrivez-moi](/contact) et on discute !
#### Pour explorer le projet
- [inscription à la liste de diffusion](https://6254d9a3.sibforms.com/serve/MUIFAEonUVkoSVrdgey18CTgLyI16xw4yeu-M-YOUzhWE_AgfQfbgkyT7GvA_RYLro9MfuRqkzQxSvu7-uzbMSv2a2ZQPsliM7wtiiqIL8kR2zOvl6m11fb5qjcOxMAYsLiY_YBi3P7NY95CTJ8vRY4CpsDclF2iLooOElKkTgIgi5nePe7zAIrgiYM5v2EuALlGJZMEG9vBP-Cu) (annonces des mises à jour et évènements, maximum une fois par mois)
- 💾 [données consolidées en Open Data](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire/)
- 🗞 [mon blog](https://colin.maudry.com), qui parle beaucoup de transparence des marchés publics
- 🗞 [mon blog](https://colin.maudry.com)
- 📔 [wiki du projet](https://github.com/ColinMaudry/decp-processing/wiki)
- 🚰 code source
- [de decp.info](https://github.com/ColinMaudry/decp.info)
- [du traitement des données](https://github.com/ColinMaudry/decp-processing)
"""),
html.H4("Contact", id="contact"),
dcc.Markdown("""
- Email : [colin+decp@maudry.com](mailto:colin+decp@maudry.com)
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
- venez discuter de la transparence de la commande publique [sur le forum teamopendata.org](https://teamopendata.org/c/commande-publique/101)
"""),
html.H4("Sources de données", id="sources"),
html.H4("Qualité et exhaustivité des données", id="qualite-exhausitivite"),
dcc.Markdown("""Les données visibles sur ce site proviennent exclusivement de la publication de données ouvertes par les acheteurs publics ou en leur nom, régie par [l'arrêté du 22 décembre 2022](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000046850496). Leur qualité est donc principalement liée à la qualité de leur saisie par les agents publics, parfois peu aidé·es par la qualité des outils à leur disposition. Je pense que l'analyse de marchés individuels et le comptage de marchés sur des critères autres que financiers sont plutôt fiables. En revanche, certains montants de marché estimés à des valeurs farfelues ([1 euro](https://decp.info/marches/432766947000192025S01301), [1 milliard](https://decp.info/marches/2459004280001320210000000271)) faussent les calculs par aggrégation (sommes, moyennes, médianes) et donc la production de statistiques financières fiables. Acheteurs, acheteuses : s'il vous plaît, essayez d'estimer les montants des marchés publics attribués de manière plus précise.
Quant à l'exhaustivité, je consolide toutes les sources de données exploitables que j'ai pu identifier (voir [statistiques](/statistiques)). Certains profils d'acheteurs ne publient pas leurs données malgré l'obligation réglementaire :
- marches-securises.fr
- klekoon.fr (ils y travaillent)
**marches-publics.gouv.fr** (PLACE, [AIFE](https://aife.economie.gouv.fr)), la plateforme des marchés de l'État, ne publie plus de données depuis le 1er janvier 2024 et l'entrée en vigueur de l'arrêté. Nous n'avons donc plus de données sur les marchés publics passés par les ministères, ainsi que sur ceux passés par les acheteurs clients d'[achatpublic.com](https://www.achatpublic.com), qui s'appuie sur les moyens de publication de l'AIFE.
**marches-publics.info** (AWS) publie ses données de manière assez sporadique depuis début 2023. Compte tenu de son poids dans le secteur, c'est assez dommageable pour la transparence des marchés publics.
Au milieu de ces mauvaises nouvelles, je tiens à souligner la belle continuité de la publication par la DGFiP des données des marchés publics remontées via le [protocole PES](https://www.collectivites-locales.gouv.fr/finances-locales/le-protocole-dechange-standard-pes). Merci à leurs équipes."""),
html.H4("Sources de données ", id="qualite-exhausitivite"),
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
html.H4("Mentions légales", id="mentions-legales"),
dcc.Markdown("""
+276
View File
@@ -0,0 +1,276 @@
import datetime
import polars as pl
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
from src.figures import point_on_map
from src.utils import (
add_links_in_dict,
df,
format_montant,
format_number,
get_annuaire_data,
get_departement_region,
meta_content,
setup_table_columns,
)
register_page(
__name__,
path_template="/acheteurs/<acheteur_id>",
title=meta_content["title"],
name="Acheteur",
description=meta_content["description"],
image_url=meta_content["image_url"],
order=5,
)
layout = [
dcc.Store(id="acheteur_data", storage_type="memory"),
dcc.Location(id="url", refresh="callback-nav"),
html.Div(
className="container",
children=[
html.Div(
className="wrapper",
children=[
html.H2(
className="org_title",
children=[
html.Span(id="acheteur_siret"),
" - ",
html.Span(id="acheteur_nom"),
],
),
html.Div(
className="org_year",
children=dcc.Dropdown(
id="acheteur_year",
options=["Toutes"]
+ [
str(year)
for year in range(
2018, int(datetime.date.today().year) + 1
)
],
placeholder="Année",
),
),
html.Div(
className="org_infos",
children=[
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
html.P(["Commune : ", html.Strong(id="acheteur_commune")]),
html.P(
[
"Département : ",
html.Strong(id="acheteur_departement"),
]
),
html.P(["Région : ", html.Strong(id="acheteur_region")]),
html.A(
id="acheteur_lien_annuaire",
children="Plus de détails sur l'Annuaire des entreprises",
target="_blank",
),
],
),
html.Div(
className="org_stats",
children=[
html.P(id="acheteur_titre_stats"),
html.P(id="acheteur_marches_attribues"),
html.P(id="acheteur_titulaires_differents"),
html.Button(
"Téléchargement au format Excel",
id="btn-download-acheteur-data",
),
dcc.Download(id="download-acheteur-data"),
],
),
html.Div(className="org_map", id="acheteur_map"),
],
),
# récupérer les données de l'acheteur sur l'api annuaire
html.H3("Derniers marchés publics attribués"),
html.Div(id="acheteur_last_marches", children=""),
],
),
]
@callback(
Output(component_id="acheteur_siret", component_property="children"),
Output(component_id="acheteur_nom", component_property="children"),
Output(component_id="acheteur_commune", component_property="children"),
Output(component_id="acheteur_map", component_property="children"),
Output(component_id="acheteur_departement", component_property="children"),
Output(component_id="acheteur_region", component_property="children"),
Output(component_id="acheteur_lien_annuaire", component_property="href"),
Input(component_id="url", component_property="pathname"),
)
def update_acheteur_infos(url):
acheteur_siret = url.split("/")[-1]
if len(acheteur_siret) != 14:
acheteur_siret = (
f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})"
)
data = get_annuaire_data(acheteur_siret)
data_etablissement = data["matching_etablissements"][0]
acheteur_map = point_on_map(
data_etablissement["latitude"], data_etablissement["longitude"]
)
code_departement, nom_departement, nom_region = get_departement_region(
data_etablissement["code_postal"]
)
departement = f"{nom_departement} ({code_departement})"
lien_annuaire = (
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
)
return (
acheteur_siret,
data["nom_raison_sociale"],
data_etablissement["libelle_commune"],
acheteur_map,
departement,
nom_region,
lien_annuaire,
)
@callback(
Output(component_id="acheteur_marches_attribues", component_property="children"),
Output(
component_id="acheteur_titulaires_differents", component_property="children"
),
Input(component_id="acheteur_data", component_property="data"),
)
def update_acheteur_stats(data):
df = pl.DataFrame(data)
if df.height == 0:
df = pl.DataFrame(schema=df.collect_schema())
df_marches = df.unique("id")
nb_marches = format_number(df_marches.height)
# somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item()))
marches_attribues = [html.Strong(nb_marches), " marchés et accord-cadres attribués"]
# + ", pour un total de ", html.Strong(somme_marches + " €")]
del df_marches
nb_titulaires = df.unique("titulaire_id").height
nb_titulaires = [
html.Strong(format_number(nb_titulaires)),
" titulaires (SIRET) différents",
]
del df
return marches_attribues, nb_titulaires
@callback(
Output(component_id="acheteur_data", component_property="data"),
Input(component_id="url", component_property="pathname"),
Input(component_id="acheteur_year", component_property="value"),
)
def get_acheteur_marches_data(url, acheteur_year: str) -> list[dict]:
acheteur_siret = url.split("/")[-1]
lff = df.lazy()
lff = lff.filter(pl.col("acheteur_id") == acheteur_siret)
lff = lff.fill_null("")
lff = lff.select(
"id",
"uid",
"objet",
"dateNotification",
"titulaire_id",
"titulaire_typeIdentifiant",
"titulaire_nom",
"montant",
"codeCPV",
"dureeMois",
)
if acheteur_year and acheteur_year != "Toutes":
lff = lff.filter(
pl.col("dateNotification").cast(pl.String).str.starts_with(acheteur_year)
)
lff = lff.sort(["dateNotification", "id"], descending=True, nulls_last=True)
data = lff.collect(engine="streaming").to_dicts()
return data
@callback(
Output(component_id="acheteur_last_marches", component_property="children"),
Input(component_id="acheteur_data", component_property="data"),
)
def get_last_marches_table(data) -> html.Div:
dff = pl.DataFrame(data)
dff = format_montant(dff)
columns, tooltip = setup_table_columns(
dff,
hideable=False,
exclude=["titulaire_id", "titulaire_typeIdentifiant", "uid"],
)
data = dff.to_dicts()
data = add_links_in_dict(data, "titulaire")
table = html.Div(
className="marches_table",
id="acheteur_datatable",
children=dash_table.DataTable(
data=data,
markdown_options={"html": True},
page_action="native",
filter_action="native",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
columns=columns,
tooltip_header=tooltip,
tooltip_duration=8000,
tooltip_delay=350,
cell_selectable=False,
page_size=10,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "300px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "200px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
),
)
return table
@callback(
Output("download-acheteur-data", "data"),
Input("btn-download-acheteur-data", "n_clicks"),
State(component_id="acheteur_data", component_property="data"),
State(component_id="acheteur_nom", component_property="children"),
State(component_id="acheteur_year", component_property="value"),
prevent_initial_call=True,
)
def download_acheteur_data(
n_clicks,
data: [dict],
acheteur_nom: str,
annee: str,
):
df_to_download = pl.DataFrame(data)
def to_bytes(buffer):
df_to_download.write_excel(
buffer, worksheet="DECP" if annee in ["Toutes", None] else annee
)
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{acheteur_nom}_{date}.xlsx")
+170
View File
@@ -0,0 +1,170 @@
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"}
)
-279
View File
@@ -1,279 +0,0 @@
import os
from datetime import datetime
import polars as pl
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
from dotenv import load_dotenv
from src.utils import (
add_annuaire_link,
add_resource_link,
booleans_to_strings,
format_number,
lf,
logger,
split_filter_part,
)
load_dotenv()
update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y")
df_filtered = pl.DataFrame()
# Unique les données actuelles, pas les anciennes versions de marchés
lf = lf.filter(pl.col("donneesActuelles"))
# Suppression des colonnes inutiles
lf = lf.drop(
[
"donneesActuelles",
]
)
# Convertir les colonnes booléennes en chaînes de caractères
lf = booleans_to_strings(lf)
# Remplacer les valeurs manquantes par des chaînes vides
lf = lf.fill_null("")
# Ajout des liens vers l'annuaire
lf = add_annuaire_link(lf)
# Ajout des liens open data
lf = add_resource_link(lf)
schema = lf.collect_schema()
title = "Tableau"
register_page(__name__, path="/", title="decp.info", name=title, order=1)
datatable = dash_table.DataTable(
cell_selectable=False,
id="table",
page_size=20,
page_current=0,
page_action="custom",
filter_action="custom",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
columns=[
{
"name": i,
"id": i,
"presentation": "markdown",
"type": "text",
"format": {"nully": "N/A"},
"hideable": True,
}
for i in lf.collect_schema().names()
],
sort_action="custom",
sort_mode="multi",
sort_by=[],
row_deletable=False,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_nom"},
"minWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
],
data_timestamp=0,
markdown_options={"html": True},
)
layout = [
html.Div(
html.Details(
children=[
html.Summary(
html.H3("Mode d'emploi", style={"text-decoration": "underline"}),
),
dcc.Markdown(
"""
**Filtres**
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`.
- Champs textuels : la recherche est insensible à la casse (majuscules/minuscules).
- Champs numériques : vous pouvez soit taper un nombre pour trouver les valeurs égales, soit le précéder de > ou < pour filtrer les valeurs supérieures ou inférieures.
Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page.
**Télécharger le résultat**
Vous pouvez télécharger le résultat de vos filtres et tris, pour les colonnes affichées, en cliquant sur Télécharger au format Excel.
Si vous téléchargez un volume important de données, il se peut que vous attendiez quelques minutes avant le début du téléchargement.
"""
),
],
id="instructions",
),
id="header",
),
# html.Div(
# [
# "Recherche dans objet : ",
# dcc.Input(id="search", value="", type="text"),
# ]
# )]),
dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-home",
type="default",
children=[
html.Div(
[
html.P("lignes", id="nb_rows"),
html.Button(
"Téléchargement désactivé au-delà de 65 000 lignes",
id="btn-download-data",
disabled=True,
),
dcc.Download(id="download-data"),
html.P("Données mises à jour le " + str(update_date)),
],
className="table-menu",
),
datatable,
],
),
]
@callback(
Output("table", "data"),
Output("table", "data_timestamp"),
Output("nb_rows", "children"),
Output("btn-download-data", "disabled"),
Output("btn-download-data", "children"),
Output("btn-download-data", "title"),
Input("table", "page_current"),
Input("table", "page_size"),
Input("table", "filter_query"),
Input("table", "sort_by"),
State("table", "data_timestamp"),
)
def update_table(page_current, page_size, filter_query, sort_by, data_timestamp):
print(" + + + + + + + + + + + + + + + + + + ")
global df_filtered
# Application des filtres
lff: pl.LazyFrame = lf # start from the original data
if filter_query:
filtering_expressions = filter_query.split(" && ")
for filter_part in filtering_expressions:
col_name, operator, filter_value = split_filter_part(filter_part)
col_type = str(schema[col_name])
print("filter_value:", filter_value)
print("filter_value_type:", type(filter_value))
print("col_type:", col_type)
if operator in ("<", "<=", ">", ">="):
filter_value = int(filter_value)
if operator == "<":
lff = lff.filter(pl.col(col_name) < filter_value)
elif operator == ">":
lff = lff.filter(pl.col(col_name) > filter_value)
elif operator == ">=":
lff = lff.filter(pl.col(col_name) >= filter_value)
elif operator == "<=":
lff = lff.filter(pl.col(col_name) <= filter_value)
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)
elif operator == "contains" and col_type == "String":
lff = lff.filter(pl.col(col_name).str.contains("(?i)" + filter_value))
# elif operator == 'datestartswith':
# lff = lff.filter(pl.col(col_name).str.startswith(filter_value)")
if len(sort_by) > 0:
lff = lff.sort(
[col["column_id"] for col in sort_by],
descending=[col["direction"] == "desc" for col in sort_by],
nulls_last=True,
)
print(sort_by)
dff: pl.DataFrame = lff.collect()
df_filtered = dff.clone()
height = dff.height
nb_rows = f"{format_number(height)} lignes"
# Pagination des données
start_row = page_current * page_size
# end_row = (page_current + 1) * page_size
dff = dff.slice(start_row, page_size)
dicts = dff.to_dicts()
if height > 65000:
download_disabled = True
download_text = "Téléchargement désactivé au-delà de 65 000 lignes"
download_title = "Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul. Contactez-moi pour me présenter votre besoin en téléchargement afin que je puisse adapter la solution."
else:
download_disabled = False
download_text = "Télécharger au format Excel"
download_title = ""
return (
dicts,
data_timestamp + 1,
nb_rows,
download_disabled,
download_text,
download_title,
)
@callback(
Output("download-data", "data"),
Input("btn-download-data", "n_clicks"),
State("table", "hidden_columns"),
prevent_initial_call=True,
)
def download_data(n_clicks, hidden_columns: list = None):
df_to_download = df_filtered.clone()
print(df_to_download.columns)
# Rétablissement des colonnes source et sourceOpenData (voir add_resource_link)
df_to_download = df_to_download.with_columns(
pl.col("source").str.extract(r'href="(.*?)"').alias("sourceFile"),
pl.col("source").str.extract(r'">(.*?)<').alias("sourceDataset"),
)
# Les colonnes masquées sont supprimées
if hidden_columns:
df_to_download = df_to_download.drop(hidden_columns)
def to_bytes(buffer):
df_to_download.write_excel(buffer, worksheet="DECP")
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx")
+190
View File
@@ -0,0 +1,190 @@
from datetime import datetime
import polars as pl
from dash import Input, Output, callback, dcc, html, register_page
from polars import selectors as cs
from src.utils import data_schema, df, format_montant, meta_content
register_page(
__name__,
path_template="/marches/<uid>",
title=meta_content["title"],
name="Marché",
description=meta_content["description"],
image_url=meta_content["image_url"],
order=7,
)
layout = [
dcc.Store(id="marche_data"),
dcc.Store(id="titulaires_data"),
dcc.Location(id="url", refresh="callback-nav"),
html.Div(
className="container marche_infos",
children=[
html.P("Vous consultez un résumé des données de ce marché public"),
html.Ul(
[
html.Li(
"après son attribution aux titulaires qui l'ont remporté à la suite d'un appel d'offres (ou sans appel d'offres via une attribution directe)"
),
html.Li(
"après avoir appliqué les éventuelles modifications de montant, durée ou titulaires renseignées par l'acheteur"
),
]
),
html.P(
"Le montant total payé aux titulaires, la durée du marché et la liste des titulaires peuvent cependant encore évoluer jusqu'à la fin de l'exécution du marché."
),
html.Div(
className="wrapper",
children=[
html.Div(
className="marche_map",
id="marche_map",
children=[
html.H4("Titulaires"),
html.Ul(id="marche_infos_titulaires"),
],
),
html.Div(className="marche_infos_1", id="marche_infos_1"),
html.Div(className="marche_infos_2", id="marche_infos_2"),
],
),
],
),
]
@callback(
Output("marche_data", "data"),
Output("titulaires_data", "data"),
Input(component_id="url", component_property="pathname"),
)
def get_marche_data(url) -> tuple[dict, list]:
marche_uid = url.split("/")[-1]
# Récupération des données du marché à partir du df global
lff = df.lazy()
lff = lff.filter(pl.col("uid") == pl.lit(marche_uid))
# Données des titulaires du marché
dff_titulaires = lff.select(cs.starts_with("titulaire")).collect(engine="streaming")
# Données du marché
dff_marche = (
lff.select(~cs.starts_with("titulaires")).unique().collect(engine="streaming")
)
dff_marche = format_montant(dff_marche)
assert dff_marche.height == 1
return dff_marche.to_dicts()[0], dff_titulaires.to_dicts()
@callback(
Output("marche_infos_1", "children"),
Output("marche_infos_2", "children"),
Output("marche_infos_titulaires", "children"),
Input("marche_data", "data"),
Input("titulaires_data", "data"),
)
def update_marche_info(marche, titulaires):
def make_parameter(col):
column_object = data_schema.get(col)
print(column_object)
column_name = column_object.get("title") if column_object else col
if marche[col]:
if col == "acheteur_nom":
value = html.A(
href=f"/acheteurs/{marche['acheteur_id']}",
children=marche["acheteur_nom"],
)
elif col == "sourceDataset":
value = html.A(
href=marche["sourceFile"], children=marche["sourceDataset"]
)
column_name = "Source des données"
# Dates
elif col in ["dateNotification", "datePublicationDonnees"]:
print(marche[col])
value = datetime.fromisoformat(marche[col]).strftime("%d/%m/%Y")
# Listes
elif (
col
in [
"techniques",
"typesPrix",
"considerationsSociales",
"considerationsEnvironnementales",
]
and "," in marche[col]
):
col_values = marche[col].split(", ")
lines = []
for val in col_values:
lines.append(html.Li(val))
_content = html.Div(
[html.P([column_name, " : "]), html.Ul(children=lines)]
)
return _content
else:
value = marche.get(col)
else:
value = ""
param_content = html.P([column_name, " : ", html.Strong(value)])
return param_content
marche_infos = [
make_parameter("id"),
make_parameter("objet"),
make_parameter("dateNotification"), # date
make_parameter("nature"),
make_parameter("acheteur_nom"), # lien
make_parameter("montant"),
make_parameter("codeCPV"),
make_parameter("procedure"),
make_parameter("techniques"), # list
make_parameter("dureeMois"),
make_parameter("offresRecues"),
make_parameter("datePublicationDonnees"), # date
make_parameter("formePrix"),
make_parameter("typesPrix"), # list
make_parameter("attributionAvance"),
make_parameter("tauxAvance"),
make_parameter("marcheInnovant"), # label
make_parameter("modalitesExecution"),
make_parameter("considerationsSociales"), # list
make_parameter("considerationsEnvironnementales"), # list
make_parameter("ccag"),
make_parameter("sousTraitanceDeclaree"),
make_parameter("typeGroupementOperateurs"),
make_parameter("origineFrance"),
make_parameter("origineUE"),
make_parameter("idAccordCadre"),
make_parameter("sourceDataset"), # lien
]
half = round(len(marche_infos) / 2)
# pas inclus pour l'instant : lieu d'exécution, modifications
titulaires_lines = []
for titulaire in titulaires:
if titulaire["titulaire_typeIdentifiant"] == "SIRET":
content = html.Li(
html.A(
href=f"/titulaires/{titulaire['titulaire_id']}",
children=titulaire["titulaire_nom"],
)
)
else:
content = html.Li(titulaire["titulaire_nom"])
titulaires_lines.append(content)
return marche_infos[:half], marche_infos[half:], titulaires_lines
+13 -7
View File
@@ -1,12 +1,18 @@
from dash import dcc, html, register_page
from src.figures import get_barchart_sources, get_map_count_marches
from src.utils import lf
from src.utils import df, meta_content
title = "Statistiques"
name = "Statistiques"
register_page(
__name__, path="/statistiques", title=f"decp.info - {title}", name=title, order=3
__name__,
path="/statistiques",
title=meta_content["title"],
name=name,
description=meta_content["description"],
image_url=meta_content["image_url"],
order=3,
)
@@ -14,7 +20,7 @@ layout = [
html.Div(
className="container",
children=[
html.H2(title),
html.H2(name),
dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-statistques",
@@ -31,13 +37,13 @@ layout = [
L'ajout de nouvelles plateformes [est en cours](https://github.com/ColinMaudry/decp-processing/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22source%20de%20donn%C3%A9es%22),
toutes les [contributions](/a-propos#contribuer) sont les bienvenues pour atteindre l'exhaustivité.
"""),
dcc.Graph(figure=get_map_count_marches(lf)),
dcc.Graph(figure=get_map_count_marches(df)),
dcc.Graph(
figure=get_barchart_sources(lf, "dateNotification")
figure=get_barchart_sources(df, "dateNotification")
),
dcc.Graph(
figure=get_barchart_sources(
lf, "datePublicationDonnees"
df, "datePublicationDonnees"
)
),
],
+258
View File
@@ -0,0 +1,258 @@
import os
from datetime import datetime
import polars as pl
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
from src.utils import (
add_links,
add_resource_link,
df,
filter_table_data,
format_montant,
format_number,
meta_content,
setup_table_columns,
sort_table_data,
)
update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y")
schema = df.collect_schema()
name = "Tableau"
register_page(
__name__,
path="/",
title=meta_content["title"],
name=name,
description=meta_content["description"],
image_url=meta_content["image_url"],
order=1,
)
datatable = html.Div(
className="marches_table",
children=dash_table.DataTable(
cell_selectable=False,
id="table",
page_size=20,
page_current=0,
page_action="custom",
filter_action="custom",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
sort_action="custom",
sort_mode="multi",
sort_by=[],
row_deletable=False,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_nom"},
"minWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
],
data_timestamp=0,
markdown_options={"html": True},
tooltip_duration=8000,
tooltip_delay=350,
),
)
layout = [
html.Div(
html.Details(
children=[
html.Summary(
html.H3("Mode d'emploi", style={"text-decoration": "underline"}),
),
dcc.Markdown(
"""
##### Définition des colonnes
Pour voir la définition d'une colonne, passez votre souris sur son en-tête.
##### Filtres
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`.
- Champs textuels : la recherche est insensible à la casse (majuscules/minuscules) et retourne les valeurs qui contiennent
le texte recherché. Exemple : `rennes` retourne "RENNES METROPOLE".
- Champs numériques : vous pouvez soit taper un nombre pour trouver les valeurs égales, soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres.
- Champs date : vous pouvez également utiliser **>** ou **<**. Exemples : `< 2024-01-31` pour "avant le 31 janvier 2024",
`2024` pour "en 2024", `> 2022` pour "à partir de 2022"
Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page.
##### Tri
Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre : tri ascendant, tri descendant, pas de tri.
##### Télécharger le résultat
Vous pouvez télécharger le résultat de vos filtres et tris, pour les colonnes affichées, en cliquant sur **Télécharger au format Excel**.
##### Liens
Les liens dans les colonnes Identifiant unique, Acheteur et Titulaire vous permettent de consulter une vue qui leur est dédiée
(informations, marchés attribués/remportés, etc.)
"""
),
],
id="instructions",
),
id="header",
),
# html.Div(
# [
# "Recherche dans objet : ",
# dcc.Input(id="search", value="", type="text"),
# ]
# )]),
dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-home",
type="default",
children=[
html.Div(
[
html.P("lignes", id="nb_rows"),
html.Button(
"Téléchargement désactivé au-delà de 65 000 lignes",
id="btn-download-data",
disabled=True,
),
dcc.Download(id="download-data"),
dcc.Store(id="filtered_data", storage_type="memory"),
html.P("Données mises à jour le " + str(update_date)),
],
className="table-menu",
),
datatable,
],
),
]
@callback(
Output("table", "data"),
Output("table", "columns"),
Output("table", "tooltip_header"),
Output("table", "data_timestamp"),
Output("nb_rows", "children"),
Output("btn-download-data", "disabled"),
Output("btn-download-data", "children"),
Output("btn-download-data", "title"),
Input("table", "page_current"),
Input("table", "page_size"),
Input("table", "filter_query"),
Input("table", "sort_by"),
State("table", "data_timestamp"),
)
def update_table(page_current, page_size, filter_query, sort_by, data_timestamp):
if os.getenv("DEVELOPMENT").lower() == "true":
print(" + + + + + + + + + + + + + + + + + + ")
# Application des filtres
lff: pl.LazyFrame = df.lazy() # start from the original data
if filter_query:
lff = filter_table_data(lff, filter_query)
if len(sort_by) > 0:
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
dff: pl.DataFrame = lff.collect()
height = dff.height
nb_rows = f"{format_number(height)} lignes ({format_number(dff.select('uid').unique().height)} marchés)"
# Pagination des données
start_row = page_current * page_size
# end_row = (page_current + 1) * page_size
dff = dff.slice(start_row, page_size)
# Ajout des liens vers l'annuaire des entreprises
dff = add_links(dff)
# Ajout des liens vers les fichiers Open Data
dff = add_resource_link(dff)
# Formatage des montants
dff = format_montant(dff)
columns, tooltip = setup_table_columns(dff)
dicts = dff.to_dicts()
if height > 65000:
download_disabled = True
download_text = "Téléchargement désactivé au-delà de 65 000 lignes"
download_title = "Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul. Contactez-moi pour me présenter votre besoin en téléchargement afin que je puisse adapter la solution."
else:
download_disabled = False
download_text = "Télécharger au format Excel"
download_title = ""
return (
dicts,
columns,
tooltip,
data_timestamp + 1,
nb_rows,
download_disabled,
download_text,
download_title,
)
@callback(
Output("download-data", "data"),
Input("btn-download-data", "n_clicks"),
State("table", "filter_query"),
State("table", "sort_by"),
State("table", "hidden_columns"),
prevent_initial_call=True,
)
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
lff: pl.LazyFrame = df # start from the original data
# Les colonnes masquées sont supprimées
if hidden_columns:
lff = lff.drop(hidden_columns)
if filter_query:
lff = filter_table_data(lff, filter_query)
if len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
def to_bytes(buffer):
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx")
+290
View File
@@ -0,0 +1,290 @@
import datetime
import polars as pl
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
from src.figures import point_on_map
from src.utils import (
add_links_in_dict,
df,
format_montant,
format_number,
get_annuaire_data,
get_departement_region,
meta_content,
setup_table_columns,
)
register_page(
__name__,
path_template="/titulaires/<titulaire_id>",
title=meta_content["title"],
name="Titulaire",
description=meta_content["description"],
image_url=meta_content["image_url"],
order=5,
)
# 21690123100011
layout = [
dcc.Store(id="titulaire_data", storage_type="memory"),
dcc.Location(id="url", refresh="callback-nav"),
html.Div(
className="container",
children=[
html.Div(
className="wrapper",
children=[
html.H2(
className="org_title",
children=[
html.Span(id="titulaire_siret"),
" - ",
html.Span(id="titulaire_nom"),
],
),
html.Div(
className="org_year",
children=dcc.Dropdown(
id="titulaire_year",
options=["Toutes"]
+ [
str(year)
for year in range(
2018, int(datetime.date.today().year) + 1
)
],
placeholder="Année",
),
),
html.Div(
className="org_infos",
children=[
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
html.P(["Commune : ", html.Strong(id="titulaire_commune")]),
html.P(
[
"Département : ",
html.Strong(id="titulaire_departement"),
]
),
html.P(["Région : ", html.Strong(id="titulaire_region")]),
html.A(
id="titulaire_lien_annuaire",
children="Plus de détails sur l'Annuaire des entreprises",
target="_blank",
),
],
),
html.Div(
className="org_stats",
children=[
html.P(id="titulaire_titre_stats"),
html.P(id="titulaire_marches_remportes"),
html.P(id="titulaire_acheteurs_differents"),
html.Button(
"Téléchargement au format Excel",
id="btn-download-titulaire-data",
),
dcc.Download(id="download-titulaire-data"),
],
),
html.Div(className="org_map", id="titulaire_map"),
],
),
# récupérer les données de l'acheteur sur l'api annuaire
html.H3("Derniers marchés publics remportés"),
html.Div(id="titulaire_last_marches", children=""),
],
),
]
@callback(
Output(component_id="titulaire_siret", component_property="children"),
Output(component_id="titulaire_nom", component_property="children"),
Output(component_id="titulaire_commune", component_property="children"),
Output(component_id="titulaire_map", component_property="children"),
Output(component_id="titulaire_departement", component_property="children"),
Output(component_id="titulaire_region", component_property="children"),
Output(component_id="titulaire_lien_annuaire", component_property="href"),
Input(component_id="url", component_property="pathname"),
)
def update_titulaire_infos(url):
titulaire_siret = url.split("/")[-1]
if len(titulaire_siret) != 14:
titulaire_siret = (
f"Le SIRET renseigné doit faire 14 caractères ({titulaire_siret})"
)
data = get_annuaire_data(titulaire_siret)
data_etablissement = data["matching_etablissements"][0]
titulaire_map = point_on_map(
data_etablissement["latitude"], data_etablissement["longitude"]
)
code_departement, nom_departement, nom_region = get_departement_region(
data_etablissement["code_postal"]
)
departement = f"{nom_departement} ({code_departement})"
lien_annuaire = (
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
)
return (
titulaire_siret,
data["nom_raison_sociale"],
data_etablissement["libelle_commune"],
titulaire_map,
departement,
nom_region,
lien_annuaire,
)
@callback(
Output(component_id="titulaire_marches_remportes", component_property="children"),
Output(
component_id="titulaire_acheteurs_differents", component_property="children"
),
Input(component_id="titulaire_data", component_property="data"),
)
def update_titulaire_stats(data):
dff = pl.DataFrame(data)
if dff.height == 0:
dff = pl.DataFrame(schema=dff.collect_schema())
df_marches = dff.unique("uid")
nb_marches = format_number(df_marches.height)
# somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item()))
marches_remportes = [html.Strong(nb_marches), " marchés et accord-cadres remportés"]
# + ", pour un total de ", html.Strong(somme_marches + " €")]
del df_marches
nb_acheteurs = dff.unique("acheteur_id").height
nb_acheteurs = [
html.Strong(format_number(nb_acheteurs)),
" titulaires (SIRET) différents",
]
del dff
return marches_remportes, nb_acheteurs
@callback(
Output(component_id="titulaire_data", component_property="data"),
Input(component_id="url", component_property="pathname"),
Input(component_id="titulaire_year", component_property="value"),
)
def get_titulaire_marches_data(url, titulaire_year: str) -> list[dict]:
titulaire_siret = url.split("/")[-1]
lff = df.lazy()
lff = lff.filter(
(pl.col("titulaire_id") == titulaire_siret)
& (pl.col("titulaire_typeIdentifiant") == "SIRET")
)
lff = lff.fill_null("")
lff = lff.select(
"id",
"uid",
"objet",
"dateNotification",
"acheteur_id",
"acheteur_nom",
"montant",
"codeCPV",
"dureeMois",
)
if titulaire_year and titulaire_year != "Toutes":
lff = lff.filter(
pl.col("dateNotification").cast(pl.String).str.starts_with(titulaire_year)
)
lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True)
data = lff.collect(engine="streaming").to_dicts()
return data
@callback(
Output(component_id="titulaire_last_marches", component_property="children"),
Input(component_id="titulaire_data", component_property="data"),
)
def get_last_marches_table(data) -> html.Div:
columns = [
"uid",
"objet",
"dateNotification",
"acheteur_nom",
"montant",
"codeCPV",
"dureeMois",
]
dff = pl.DataFrame(data)
dff = format_montant(dff)
columns, tooltip = setup_table_columns(
dff, hideable=False, exclude=["acheteur_id", "id"]
)
data = dff.to_dicts()
# Idéalement on utiliserait add_org_links(), mais le résultat attendu
# est différent de home.py (Tableau)
data = add_links_in_dict(data, "acheteur")
table = html.Div(
className="marches_table",
id="titulaire_datatable",
children=dash_table.DataTable(
data=data,
markdown_options={"html": True},
page_action="native",
filter_action="native",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
columns=columns,
tooltip_header=tooltip,
tooltip_duration=8000,
tooltip_delay=350,
cell_selectable=False,
page_size=10,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "300px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_nom"},
"maxWidth": "400px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
),
)
return table
@callback(
Output("download-titulaire-data", "data"),
Input("btn-download-titulaire-data", "n_clicks"),
State(component_id="titulaire_data", component_property="data"),
State(component_id="titulaire_nom", component_property="children"),
State(component_id="titulaire_year", component_property="value"),
prevent_initial_call=True,
)
def download_titulaire_data(
n_clicks,
data: [dict],
titulaire_nom: str,
annee: str,
):
df_to_download = pl.DataFrame(data)
def to_bytes(buffer):
df_to_download.write_excel(
buffer, worksheet="DECP" if annee in ["Toutes", None] else annee
)
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{titulaire_nom}_{date}.xlsx")
+236 -27
View File
@@ -1,14 +1,13 @@
import json
import logging
import os
from time import sleep
import polars as pl
import polars.selectors as cs
from dotenv import load_dotenv
from httpx import get
from polars.exceptions import ComputeError
load_dotenv()
operators = [
["s<", "<"],
["s>", ">"],
@@ -40,21 +39,21 @@ def split_filter_part(filter_part):
return [None] * 3
def add_resource_link(lff: pl.LazyFrame) -> pl.LazyFrame:
lff = lff.with_columns(
def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
dff = dff.with_columns(
(
'<a href="' + pl.col("sourceFile") + '">' + pl.col("sourceDataset") + "</a>"
).alias("source")
).alias("sourceDataset")
)
lff = lff.drop(["sourceFile", "sourceDataset"])
return lff
dff = dff.drop(["sourceFile"])
return dff
def add_annuaire_link(lff: pl.LazyFrame):
lff = lff.with_columns(
def add_links(dff: pl.DataFrame):
dff = dff.with_columns(
pl.when(pl.col("titulaire_typeIdentifiant") == "SIRET")
.then(
'<a href = "https://annuaire-entreprises.data.gouv.fr/etablissement/'
'<a href = "/titulaires/'
+ pl.col("titulaire_id")
+ '">'
+ pl.col("titulaire_id")
@@ -63,16 +62,31 @@ def add_annuaire_link(lff: pl.LazyFrame):
.otherwise(pl.col("titulaire_id"))
.alias("titulaire_id")
)
lff = lff.with_columns(
(
'<a href = "https://annuaire-entreprises.data.gouv.fr/etablissement/'
+ pl.col("acheteur_id")
+ '">'
+ pl.col("acheteur_id")
+ "</a>"
).alias("acheteur_id")
)
return lff
for column, path in [("acheteur_id", "acheteurs"), ("uid", "marches")]:
dff = dff.with_columns(
(
f'<a href = "/{path}/'
+ pl.col(column)
+ '" target="_blank">'
+ pl.col(column)
+ "</a>"
).alias(column)
)
return dff
def add_links_in_dict(data: list, org_type: str) -> list:
new_data = []
for marche in data:
org_id = marche[org_type + "_id"]
marche[org_type + "_nom"] = (
f'<a href="/{org_type}s/{org_id}">{marche[org_type + "_nom"]}</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)
return new_data
def booleans_to_strings(lff: pl.LazyFrame) -> pl.LazyFrame:
@@ -96,12 +110,54 @@ def numbers_to_strings(lff: pl.LazyFrame) -> pl.LazyFrame:
return lff
def dates_to_strings(lff: pl.LazyFrame, column: str) -> pl.LazyFrame:
"""
Convert a date column to string type.
"""
lff = lff.with_columns(pl.col(column).cast(pl.String).fill_null(""))
return lff
def format_number(number) -> str:
number = "{:,}".format(number).replace(",", " ")
return number
def get_decp_data() -> pl.LazyFrame:
def format_montant(dff: pl.DataFrame) -> pl.DataFrame:
def format_function(expr, scale=None):
# https://stackoverflow.com/a/78636786
expr = expr.cast(pl.String).str.splitn(".", 2)
num = expr.struct[0]
frac = expr.struct[1]
# Ajout des espaces
num = (
num.str.reverse()
.str.replace_all(r"\d{3}", "$0 ")
.str.reverse()
.str.replace(r"^ ", "")
)
frac: pl.Expr = (
pl.when(frac.is_not_null() & ~frac.is_in(["0"]))
.then("," + frac)
.otherwise(pl.lit(""))
)
return num + frac + pl.lit("")
dff = dff.with_columns(pl.col("montant").pipe(format_function).alias("montant"))
return dff
def get_annuaire_data(siret: str) -> dict:
url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}"
response = get(url)
return response.json()["results"][0]
def get_decp_data() -> pl.DataFrame:
# Chargement du fichier parquet
# Le fichier est chargé en mémoire, ce qui est plus rapide qu'une base de données pour le moment.
# On utilise polars pour la rapidité et la facilité de manipulation des données.
@@ -117,13 +173,166 @@ def get_decp_data() -> pl.LazyFrame:
sleep(10)
lff: pl.LazyFrame = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
# Remplacement des valeurs numériques par des chaînes de caractères
# lff = numbers_to_strings(lff)
# Tri des marchés par date de notification
lff = lff.sort(by=["datePublicationDonnees"], descending=True, nulls_last=True)
lff = lff.sort(by=["dateNotification", "uid"], descending=True, nulls_last=True)
# Uniquement les données actuelles, pas les anciennes versions de marchés
lff = lff.filter(pl.col("donneesActuelles")).drop("donneesActuelles")
# Convertir les colonnes booléennes en chaînes de caractères
lff = booleans_to_strings(lff)
# Bizarrement je ne peux pas faire lff = lff.fill_null("") ici
# ça génère une erreur dans la page acheteur (acheteur_data.table) :
# AttributeError: partially initialized module 'pandas' has no attribute 'NaT' (most likely due to a circular import)
return lff.collect()
def get_departements() -> dict:
with open("data/departements.json", "rb") as f:
data = json.load(f)
return data
def get_departement_region(code_postal):
if code_postal > "97000":
code_departement = code_postal[:3]
else:
code_departement = code_postal[:2]
nom_departement = departements[code_departement]["departement"]
nom_region = departements[code_departement]["region"]
return code_departement, nom_departement, nom_region
def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
debug = os.getenv("DEVELOPMENT", "False").lower() == "true"
schema = lff.collect_schema()
filtering_expressions = filter_query.split(" && ")
for filter_part in filtering_expressions:
col_name, operator, filter_value = split_filter_part(filter_part)
col_type = str(schema[col_name])
if debug:
print("filter_value:", filter_value)
print("filter_value_type:", type(filter_value))
print("col_type:", col_type)
if col_type == "Date":
# Convertir la colonne en chaînes de caractères
lff = dates_to_strings(lff, col_name)
if operator in ("<", "<=", ">", ">="):
lff = lff.filter(
pl.col(col_name).is_not_null() & (pl.col(col_name) != pl.lit(""))
)
if operator == "<":
lff = lff.filter(pl.col(col_name) < filter_value)
elif operator == ">":
lff = lff.filter(pl.col(col_name) > filter_value)
elif operator == ">=":
lff = lff.filter(pl.col(col_name) >= filter_value)
elif operator == "<=":
lff = lff.filter(pl.col(col_name) <= filter_value)
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)
elif operator == "contains" and col_type in ["String", "Date"]:
lff = lff.filter(pl.col(col_name).str.contains("(?i)" + filter_value))
# elif operator == 'datestartswith':
# lff = lff.filter(pl.col(col_name).str.startswith(filter_value)")
return lff
lf = get_decp_data()
def sort_table_data(lff: pl.LazyFrame, sort_by: list) -> pl.LazyFrame:
lff = lff.sort(
[col["column_id"] for col in sort_by],
descending=[col["direction"] == "desc" for col in sort_by],
nulls_last=True,
)
print(sort_by)
return lff
def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tuple:
# Liste finale de colonnes
columns = []
tooltip = {}
for column_id in dff.columns:
if exclude and column_id in exclude:
continue
column_object = data_schema.get(column_id)
if column_object:
column_name = column_object.get("title", column_id)
else:
column_name = column_id
column = {
"name": column_name,
"id": column_id,
"presentation": "markdown",
"type": "text",
"format": {"nully": "N/A"},
"hideable": hideable,
}
columns.append(column)
if column_object:
tooltip[column_id] = {
"value": f"""**{column_object.get("title")}** ({column_id})
"""
+ column_object["description"],
"type": "markdown",
}
return columns, tooltip
def get_data_schema() -> dict:
# Récupération du schéma des données tabulaires
path = os.getenv("DATA_SCHEMA_PATH")
if path.startswith("http"):
original_schema: dict = get(
os.getenv("DATA_SCHEMA_PATH"), follow_redirects=True
).json()
elif os.path.exists(path):
with open(path) as f:
original_schema: dict = json.load(f)
else:
raise Exception(f"Chemin vers le schéma invalide: {path}")
new_schema = {}
for col in original_schema["fields"]:
new_schema[col["name"]] = col
new_schema["sourceDataset"] = {
"description": "Code de la source des données, avec un lien vers le fichier Open Data dont proviennent les données de ce marché public.",
"title": "Source des données",
"short_name": "Source",
}
return new_schema
df: pl.DataFrame = get_decp_data()
departements = get_departements()
domain_name = (
"test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"
)
meta_content = {
"image_url": f"https://{domain_name}/assets/decp.info.png",
"title": "decp.info - exploration des marchés publics français",
"description": (
"Explorez et analysez les données des marchés publics français avec cet outil libre et gratuit. "
"Pour une commande publique accessible à toutes et tous."
),
}
data_schema = get_data_schema()