Compare commits
166 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 021361e2c1 | |||
| 04ec9438f1 | |||
| e6960dc16d | |||
| 2fb71e466c | |||
| b47463b638 | |||
| 84cf48182d | |||
| 0f11a575ff | |||
| c9459771ac | |||
| 99eef0eaa3 | |||
| 4719ef5754 | |||
| 032ca5554e | |||
| 90d66bcbb6 | |||
| 43104f611e | |||
| 24ef21761f | |||
| e0fa8464d2 | |||
| 0691329b04 | |||
| 359ba248ac | |||
| 19d7735306 | |||
| 43fd2df2c0 | |||
| 9ae6f29391 | |||
| 80a7e51ec4 | |||
| e216b42379 | |||
| 7f42ca67a2 | |||
| ec15852406 | |||
| 8c98a60c75 | |||
| 565a0cfd08 | |||
| 53919bff3e | |||
| 193cf5302c | |||
| fd2f87e382 | |||
| 59e363a1fc | |||
| fdeedde983 | |||
| 925d050c96 | |||
| 5d4ee692f3 | |||
| a51d2a83b4 | |||
| 6b133c21ab | |||
| 503ec33f43 | |||
| 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 | |||
| 442f1ded27 | |||
| e4dcc19e8b | |||
| 0b828c3d67 | |||
| bc930bf6c9 | |||
| 070e8c98b9 | |||
| 7c157d9d71 | |||
| 79d11b326e | |||
| 51c023a803 | |||
| 819f2e315f | |||
| 928f75f504 | |||
| 1d21cbb69d | |||
| ee2bae190d | |||
| ad6005259e | |||
| f01a991f4f | |||
| a44e1a37cb | |||
| 502f652791 | |||
| fdb38b3b9b | |||
| 4394f38e35 | |||
| ce9d23100d | |||
| 70c5b91e6d | |||
| 4895bfc5ac | |||
| d9db156626 | |||
| d00efcd500 | |||
| 41df3f6d8e | |||
| 0db814710b | |||
| c1955ac807 | |||
| 4081b369d9 | |||
| 8a63f72bee | |||
| cc05ac0d99 | |||
| 8d236ab8dc | |||
| 0e20b0012f | |||
| 5122bb7f3a | |||
| 4b8565dcf2 | |||
| 98b8699afb | |||
| 1c5868d75f | |||
| bfa181cefd | |||
| 944f207d9b | |||
| 8f335a52ea | |||
| e88992a470 | |||
| 9e5673c28f | |||
| 2fe92d8a14 | |||
| b88d1c9f93 | |||
| 59321fc80a | |||
| df04286a4a | |||
| 2fc319d1bc | |||
| 29428a8706 | |||
| dbc80ad94e | |||
| ec877669d4 | |||
| a41b70d5af | |||
| e41dbe772c | |||
| 5a3770027f | |||
| 5c90b5f350 | |||
| dd229a212f | |||
| 8bcf57ce83 | |||
| 0fea49109a | |||
| 8337f7cbf1 | |||
| 159d409237 | |||
| 1586f9b7e4 | |||
| 0c99f037b5 | |||
| 41d3ca7539 | |||
| 636e0b059d |
@@ -19,7 +19,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: ${{ github.ref_name }}
|
environment: ${{ github.ref_name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repositorypu
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up SSH key
|
- name: Set up SSH key
|
||||||
@@ -40,4 +40,12 @@ jobs:
|
|||||||
key: ${{ secrets.ARTIFACT_SSH_KEY }}
|
key: ${{ secrets.ARTIFACT_SSH_KEY }}
|
||||||
passphrase: ${{ secrets.SSH_PSWD }}
|
passphrase: ${{ secrets.SSH_PSWD }}
|
||||||
command_timeout: 5m
|
command_timeout: 5m
|
||||||
script: ${{ secrets.APP_PATH }}/deploy.sh
|
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 }}
|
||||||
|
|||||||
@@ -2,3 +2,24 @@ DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1
|
|||||||
PORT=8050
|
PORT=8050
|
||||||
DEVELOPMENT=True
|
DEVELOPMENT=True
|
||||||
SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234"
|
SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234"
|
||||||
|
|
||||||
|
# Annonce dans l'en-tête du site
|
||||||
|
ANNOUNCEMENTS=
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Matomo
|
||||||
|
MATOMO_ID_SITE=
|
||||||
|
MATOMO_BASE_URL=
|
||||||
|
MATOMO_TOKEN=
|
||||||
|
|||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
#### 2.3.0 (24 décembre 2025)
|
||||||
|
|
||||||
|
- Possibilité de filtrer, trier etc. dans les vues acheteur et titulaire
|
||||||
|
- Possibilité de partager les filtres, tris et choix de colonnes via une adresse Web ([exemple](https://decp.info/tableau?filtres=%7Bacheteur_nom%7D+icontains+centre+%26%26+%7BdureeMois%7D+icontains+24&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2Cacheteur_departement_code%2CsourceDataset))
|
||||||
|
- Possibilité de filtrer une colonne avec plusieurs mots
|
||||||
|
|
||||||
|
##### 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 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)
|
||||||
|
- Meilleures instructions d'installation et lancement
|
||||||
|
- Coquilles 🐚
|
||||||
|
|
||||||
|
### 2.0.0 (23 septembre 2025)
|
||||||
|
|
||||||
|
- détails des sources de données
|
||||||
|
- section "À propos" plus développée
|
||||||
|
- correction de bugs dans les filtres de la data table
|
||||||
|
|
||||||
|
#### 2.0.0-alpha
|
||||||
|
|
||||||
|
- Data table fonctionnelle
|
||||||
|
|
||||||
|
### 1.5.0 (28/01/2023
|
||||||
|
|
||||||
|
- fixation des dépendances Python pour plus de stabilité en cas de réinstallation (Pipfile)
|
||||||
|
|
||||||
|
#### 1.4.1 (14/06/2021)
|
||||||
|
|
||||||
|
- ajout des traductions des opérations de filtrage à toutes les vues, pas seulement /db/decp
|
||||||
|
|
||||||
|
### 1.4.0 (14/06/2021)
|
||||||
|
|
||||||
|
- traduction des opérations de filtrage (ex : contains => contient)
|
||||||
|
- élargissement des menus de filtrage
|
||||||
|
- correction du titre de la page des notes de versions
|
||||||
|
|
||||||
|
### 1.3.0 (03/06/2021)
|
||||||
|
|
||||||
|
- utilisation de noms de colonnes plus lisibles dans l'application
|
||||||
|
- suppression des références à la licence et aux données source sur la page d'accueil
|
||||||
|
- correction des liens vers le code source
|
||||||
|
- correction de l'indentation des puces dans les notes de version
|
||||||
|
|
||||||
|
### 1.2.0 (28/05/2021)
|
||||||
|
|
||||||
|
- ajout d'une page "Notes de version"
|
||||||
|
- meilleur lien pour la documentation des champs
|
||||||
|
- déplacement du code de decp.info depuis [ColinMaudry/decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) vers [ColinMaudry/decp.info](https://github.com/ColinMaudry/decp.info)
|
||||||
|
|
||||||
|
### 1.1.0 (25/05/2021)
|
||||||
|
|
||||||
|
- ajout de nouvelles vues :
|
||||||
|
- Marchés publics sans leurs titulaires : vue dédiée aux titulaires de marchés avec des données provenant du répertoire SIRENE
|
||||||
|
- Données sur les titulaires et géolocalisation : vue sans les titulaires pour analyser les nombres de marchés et les montants
|
||||||
|
- amélioration de la page d'accueil
|
||||||
|
- développement de la page "db" avec description des vues et liste des colonnes
|
||||||
|
- les codes APE sont cliquables
|
||||||
|
- ajout des mentions légales
|
||||||
|
- ajout d'un formulatire d'inscription à une lettre d'information
|
||||||
|
- correction de bugs :
|
||||||
|
- correction du format de certaines dates dans les données
|
||||||
|
|
||||||
|
### 1.0.0
|
||||||
|
|
||||||
|
- publication sur https://decp.info
|
||||||
|
- ajout d'une vue équivalente au format DECP réglementaire
|
||||||
|
- personnalisation de datasette
|
||||||
|
- script de conversion quotidien basé sur [dataflows](https://github.com/datahq/dataflows)
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
# decp.info
|
# decp.info
|
||||||
|
|
||||||
> v2.0.1
|
> v2.3.0
|
||||||
|
> Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
||||||
Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
|
||||||
|
|
||||||
=> [decp.info](https://decp.info)
|
=> [decp.info](https://decp.info)
|
||||||
|
|
||||||
@@ -24,6 +23,13 @@ gunicorn app:server
|
|||||||
python run.py
|
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)
|
||||||
|
- **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)
|
||||||
@@ -31,61 +37,4 @@ python run.py
|
|||||||
|
|
||||||
## Notes de version
|
## Notes de version
|
||||||
|
|
||||||
### 2.0.1 (23 septembre 2025)
|
Voir [CHANGELOG](https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md).
|
||||||
|
|
||||||
### 2.0.0 (23 septembre 2025)
|
|
||||||
|
|
||||||
- détails des sources de données
|
|
||||||
- section "À propos" plus développée
|
|
||||||
- correction de bugs dans les filtres de la data table
|
|
||||||
|
|
||||||
### 2.0.0-alpha
|
|
||||||
|
|
||||||
- Data table fonctionnelle
|
|
||||||
|
|
||||||
### 1.5.0 (28/01/2023
|
|
||||||
|
|
||||||
- fixation des dépendances Python pour plus de stabilité en cas de réinstallation (Pipfile)
|
|
||||||
|
|
||||||
#### 1.4.1 (14/06/2021)
|
|
||||||
|
|
||||||
- ajout des traductions des opérations de filtrage à toutes les vues, pas seulement /db/decp
|
|
||||||
|
|
||||||
### 1.4.0 (14/06/2021)
|
|
||||||
|
|
||||||
- traduction des opérations de filtrage (ex : contains => contient)
|
|
||||||
- élargissement des menus de filtrage
|
|
||||||
- correction du titre de la page des notes de versions
|
|
||||||
|
|
||||||
### 1.3.0 (03/06/2021)
|
|
||||||
|
|
||||||
- utilisation de noms de colonnes plus lisibles dans l'application
|
|
||||||
- suppression des références à la licence et aux données source sur la page d'accueil
|
|
||||||
- correction des liens vers le code source
|
|
||||||
- correction de l'indentation des puces dans les notes de version
|
|
||||||
|
|
||||||
### 1.2.0 (28/05/2021)
|
|
||||||
|
|
||||||
- ajout d'une page "Notes de version"
|
|
||||||
- meilleur lien pour la documentation des champs
|
|
||||||
- déplacement du code de decp.info depuis [ColinMaudry/decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) vers [ColinMaudry/decp.info](https://github.com/ColinMaudry/decp.info)
|
|
||||||
|
|
||||||
### 1.1.0 (25/05/2021)
|
|
||||||
|
|
||||||
- ajout de nouvelles vues :
|
|
||||||
- Marchés publics sans leurs titulaires : vue dédiée aux titulaires de marchés avec des données provenant du répertoire SIRENE
|
|
||||||
- Données sur les titulaires et géolocalisation : vue sans les titulaires pour analyser les nombres de marchés et les montants
|
|
||||||
- amélioration de la page d'accueil
|
|
||||||
- développement de la page "db" avec description des vues et liste des colonnes
|
|
||||||
- les codes APE sont cliquables
|
|
||||||
- ajout des mentions légales
|
|
||||||
- ajout d'un formulatire d'inscription à une lettre d'information
|
|
||||||
- correction de bugs :
|
|
||||||
- correction du format de certaines dates dans les données
|
|
||||||
|
|
||||||
### 1.0.0
|
|
||||||
|
|
||||||
- publication sur https://decp.info
|
|
||||||
- ajout d'une vue équivalente au format DECP réglementaire
|
|
||||||
- personnalisation de datasette
|
|
||||||
- script de conversion quotidien basé sur [dataflows](https://github.com/datahq/dataflows)
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-4
@@ -1,13 +1,13 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "decp.info"
|
name = "decp.info"
|
||||||
description = ""
|
description = "Interface d'exploration et d'analyse des marchés publics français."
|
||||||
version = "2.0.0"
|
version = "2.3.0"
|
||||||
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.3.0",
|
||||||
"dash[compress]",
|
"dash[compress]",
|
||||||
"polars",
|
"polars",
|
||||||
"gunicorn",
|
"gunicorn",
|
||||||
@@ -15,6 +15,9 @@ dependencies = [
|
|||||||
"python-dotenv",
|
"python-dotenv",
|
||||||
"xlsxwriter",
|
"xlsxwriter",
|
||||||
"plotly[express]",
|
"plotly[express]",
|
||||||
|
"httpx",
|
||||||
|
"pandas", # utilisé pour la création de certains graphiques
|
||||||
|
"unidecode"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
+30
-11
@@ -1,9 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
|
import tomllib
|
||||||
from dash import Dash, dcc, html, page_container, page_registry
|
from dash import Dash, dcc, html, page_container, page_registry
|
||||||
|
from dotenv import load_dotenv
|
||||||
from flask import send_from_directory
|
from flask import send_from_directory
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
app = Dash(
|
app = Dash(
|
||||||
external_stylesheets=[dbc.themes.SIMPLEX],
|
external_stylesheets=[dbc.themes.SIMPLEX],
|
||||||
title="decp.info",
|
title="decp.info",
|
||||||
@@ -29,9 +34,14 @@ logging.basicConfig(
|
|||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
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 = """
|
app.index_string = """
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
{%metas%}
|
{%metas%}
|
||||||
<title>{%title%}</title>
|
<title>{%title%}</title>
|
||||||
@@ -67,13 +77,31 @@ app.layout = html.Div(
|
|||||||
[
|
[
|
||||||
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/blob/main/CHANGELOG.md",
|
||||||
|
target="_blank",
|
||||||
|
),
|
||||||
|
className="version",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
className="logo",
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
id="announcements",
|
||||||
|
children=dcc.Markdown(os.getenv("ANNOUNCEMENTS")),
|
||||||
|
),
|
||||||
html.Div(
|
html.Div(
|
||||||
[
|
[
|
||||||
dcc.Link(
|
dcc.Link(
|
||||||
page["name"], href=page["relative_path"], className="nav"
|
page["name"], href=page["relative_path"], className="nav"
|
||||||
)
|
)
|
||||||
for page in page_registry.values()
|
for page in page_registry.values()
|
||||||
|
if page["name"] not in ["Acheteur", "Titulaire", "Marché"]
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -82,15 +110,6 @@ app.layout = html.Div(
|
|||||||
page_container,
|
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__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True)
|
app.run(debug=True)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="copy" class="svg-inline--fa fa-copy fa-w-14 " role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M433.941 65.941l-51.882-51.882A48 48 0 0 0 348.118 0H176c-26.51 0-48 21.49-48 48v48H48c-26.51 0-48 21.49-48 48v320c0 26.51 21.49 48 48 48h224c26.51 0 48-21.49 48-48v-48h80c26.51 0 48-21.49 48-48V99.882a48 48 0 0 0-14.059-33.941zM266 464H54a6 6 0 0 1-6-6V150a6 6 0 0 1 6-6h74v224c0 26.51 21.49 48 48 48h96v42a6 6 0 0 1-6 6zm128-96H182a6 6 0 0 1-6-6V54a6 6 0 0 1 6-6h106v88c0 13.255 10.745 24 24 24h88v202a6 6 0 0 1-6 6zm6-256h-64V48h9.632c1.591 0 3.117.632 4.243 1.757l48.368 48.368a6 6 0 0 1 1.757 4.243V112z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 738 B |
Binary file not shown.
|
After Width: | Height: | Size: 333 KiB |
+191
-9
@@ -18,11 +18,45 @@
|
|||||||
margin-bottom: 25px;
|
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 */
|
/* 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
|
||||||
@@ -31,6 +65,8 @@ td[data-dash-column="objet"] {
|
|||||||
th.dash-header {
|
th.dash-header {
|
||||||
background-color: #b33821;
|
background-color: #b33821;
|
||||||
color: white;
|
color: white;
|
||||||
|
font-family: "Open Sans", sans-serif;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-table-container
|
.dash-table-container
|
||||||
@@ -48,10 +84,33 @@ td[data-dash-column="objet"] {
|
|||||||
display: none;
|
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: "Open Sans", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
/* Menu de masquage des colonnes */
|
/* Menu de masquage des colonnes */
|
||||||
.column-header--hide svg {
|
.column-actions {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-header--hide {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-hide {
|
.show-hide {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 180px;
|
width: 180px;
|
||||||
@@ -71,8 +130,18 @@ td[data-dash-column="objet"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Alternance des couleurs pour les lignes */
|
/* Alternance des couleurs pour les lignes */
|
||||||
#table tr:nth-child(even) td {
|
.marches_table {
|
||||||
|
font-family: "Open Sans", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marches_table.stuck {
|
||||||
|
position: relative;
|
||||||
|
right: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marches_table .cell-table tr:nth-child(even) td {
|
||||||
background-color: #feeeee;
|
background-color: #feeeee;
|
||||||
|
font-family: "Open Sans", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header > *,
|
#header > *,
|
||||||
@@ -80,6 +149,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 {
|
||||||
@@ -96,15 +208,85 @@ a.nav {
|
|||||||
font-size: 120%;
|
font-size: 120%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar.h1 {
|
h3 {
|
||||||
float: left;
|
margin: 36px 0 24px 0;
|
||||||
width: 50%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
summary > h3 {
|
||||||
|
margin: 0;
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
#_pages_content {
|
#_pages_content {
|
||||||
padding-top: 28px;
|
padding: 28px 24px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vue acheteur/titulaire/recherche */
|
||||||
|
.wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-gap: 10px;
|
||||||
|
margin-bottom: 50px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org_top {
|
||||||
|
grid-column: 1/3;
|
||||||
|
grid-row: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import polars as pl
|
||||||
|
from dash import html
|
||||||
|
|
||||||
|
from src.figures import DataTable
|
||||||
|
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, strict=False, infer_schema_length=5000)
|
||||||
|
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, nulls_last=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 DataTable(
|
||||||
|
dtid=f"top10_{org_type}",
|
||||||
|
data=data,
|
||||||
|
page_action="native",
|
||||||
|
page_size=10,
|
||||||
|
columns=columns,
|
||||||
|
tooltip_header=tooltip,
|
||||||
|
)
|
||||||
+109
-7
@@ -1,11 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
import plotly.express as px
|
import plotly.express as px
|
||||||
import polars as pl
|
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(
|
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 +28,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 +40,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 +56,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 +100,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,
|
||||||
@@ -157,3 +160,102 @@ def get_sources_tables(source_path) -> html.Div:
|
|||||||
datatable.data = df.to_dicts()
|
datatable.data = df.to_dicts()
|
||||||
|
|
||||||
return html.Div(children=datatable)
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class DataTable(dash_table.DataTable):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dtid: str,
|
||||||
|
hidden_columns: list = None,
|
||||||
|
data=None,
|
||||||
|
columns: list = None,
|
||||||
|
page_size: int = 20,
|
||||||
|
page_action: Literal["native", "custom", "none"] = "native",
|
||||||
|
sort_action: Literal["native", "custom", "none"] = "native",
|
||||||
|
filter_action: Literal["native", "custom", "none"] = "native",
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
# Styles de base
|
||||||
|
style_cell_conditional = [
|
||||||
|
{
|
||||||
|
"if": {"column_id": "objet"},
|
||||||
|
"minWidth": "350px",
|
||||||
|
"textAlign": "left",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"lineHeight": "18px",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {"column_id": "acheteur_nom"},
|
||||||
|
"minWidth": "250px",
|
||||||
|
"textAlign": "left",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"lineHeight": "18px",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {"column_id": "titulaire_nom"},
|
||||||
|
"minWidth": "250px",
|
||||||
|
"textAlign": "left",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"lineHeight": "18px",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Initialisation de la classe parente avec les arguments
|
||||||
|
super().__init__(
|
||||||
|
id=dtid,
|
||||||
|
data=data,
|
||||||
|
columns=columns,
|
||||||
|
cell_selectable=False,
|
||||||
|
page_size=page_size,
|
||||||
|
filter_action=filter_action,
|
||||||
|
page_action=page_action,
|
||||||
|
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
|
||||||
|
sort_action=sort_action,
|
||||||
|
sort_mode="multi",
|
||||||
|
sort_by=[],
|
||||||
|
row_deletable=False,
|
||||||
|
page_current=0,
|
||||||
|
style_cell_conditional=style_cell_conditional,
|
||||||
|
data_timestamp=0,
|
||||||
|
markdown_options={"html": True},
|
||||||
|
tooltip_duration=8000,
|
||||||
|
tooltip_delay=350,
|
||||||
|
hidden_columns=hidden_columns,
|
||||||
|
**kwargs, # Possibilité de remplacer des arguments
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from dash import dcc, html, register_page
|
from dash import dcc, html, register_page
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
from src.figures import get_sources_tables
|
from src.figures import get_sources_tables
|
||||||
|
from src.utils import meta_content
|
||||||
|
|
||||||
title = "À propos"
|
name = "À propos"
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
register_page(
|
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 = [
|
layout = [
|
||||||
html.Div(
|
html.Div(
|
||||||
className="container",
|
className="container",
|
||||||
children=[
|
children=[
|
||||||
html.H2(title),
|
html.H2(name),
|
||||||
dcc.Markdown(
|
dcc.Markdown(
|
||||||
"""Outil d'exploration libre et gratuit des [Données Essentielles de la Commande Publique](), développé par Colin Maudry.
|
"""Outil d'exploration libre et gratuit des données de marchés publics, développé par Colin Maudry.
|
||||||
|
|
||||||
Ce projet vise à démocratiser l'accès aux données des marchés publics et à un outil performant et gratuit. Si vous le trouvez utile
|
Ce projet vise à démocratiser l'accès aux données des marchés publics et à un outil performant et gratuit. Si vous le trouvez utile
|
||||||
j'aimerais beaucoup échanger avec vous pour comprendre vos cas d'usages et vos besoins. Cet outil ne peut rester performant que si je comprends les problèmes qu'il peut aider à résoudre. Ce projet ne peut rester gratuit que grâce au financement du développement de nouvelles fonctionnalités.
|
j'aimerais beaucoup échanger avec vous pour comprendre vos cas d'usages et vos besoins. Cet outil ne peut rester performant que si je comprends les problèmes qu'il peut aider à résoudre. Ce projet ne peut rester gratuit que grâce au financement du développement de nouvelles fonctionnalités.
|
||||||
@@ -27,38 +31,53 @@ 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
|
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
|
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
|
- la sauvegarde de filtres pour les retrouver plus tard et les partager
|
||||||
- des alertes par email si des marchés correspondant à certains critères
|
- des alertes par email si des marchés correspondant à certains critères
|
||||||
- le développement d'une API pour alimenter d'autres logiciels
|
- 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)
|
||||||
- envoyez un mail et on discute !
|
- écrivez-moi et on discute !
|
||||||
|
|
||||||
#### Pour explorer le projet
|
#### 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)
|
- ✉️ [inscription à la liste de diffusion](https://6254d9a3.sibforms.com/serve/MUIFAEonUVkoSVrdgey18CTgLyI16xw4yeu-M-YOUzhWE_AgfQfbgkyT7GvA_RYLro9MfuRqkzQxSvu7-uzbMSv2a2ZQPsliM7wtiiqIL8kR2zOvl6m11fb5qjcOxMAYsLiY_YBi3P7NY95CTJ8vRY4CpsDclF2iLooOElKkTgIgi5nePe7zAIrgiYM5v2EuALlGJZMEG9vBP-Cu) (annonces des mises à jour et évènements, maximum une fois par mois)
|
||||||
- 💾 [données consolidées en Open Data](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire/)
|
- 💾 [données consolidées en Open Data](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire/)
|
||||||
- 🗞️ [mon blog](https://colin.maudry.com), 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)
|
- 📔 [wiki du projet](https://github.com/ColinMaudry/decp-processing/wiki)
|
||||||
- 🚰 code source
|
- 🚰 code source
|
||||||
- [de decp.info](https://github.com/ColinMaudry/decp.info)
|
- [de decp.info](https://github.com/ColinMaudry/decp.info)
|
||||||
- [du traitement des données](https://github.com/ColinMaudry/decp-processing)
|
- [du traitement des données](https://github.com/ColinMaudry/decp-processing)
|
||||||
"""),
|
"""),
|
||||||
html.H4("Contact", id="contact"),
|
html.H4("Qualité et exhaustivité des données", id="qualite-exhausitivite"),
|
||||||
dcc.Markdown("""
|
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.
|
||||||
- Email : [colin+decp@maudry.com](mailto:colin+decp@maudry.com)
|
|
||||||
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
|
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 :
|
||||||
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
|
|
||||||
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
|
- marches-securises.fr
|
||||||
- venez discuter de la transparence de la commande publique [sur le forum teamopendata.org](https://teamopendata.org/c/commande-publique/101)
|
- klekoon.fr (ils y travaillent)
|
||||||
"""),
|
|
||||||
html.H4("Sources de données", id="sources"),
|
**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("""
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
from dash import Input, Output, State, callback, dcc, html, register_page
|
||||||
|
|
||||||
|
from src.callbacks import get_top_org_table
|
||||||
|
from src.figures import DataTable, point_on_map
|
||||||
|
from src.utils import (
|
||||||
|
df,
|
||||||
|
filter_table_data,
|
||||||
|
format_number,
|
||||||
|
get_annuaire_data,
|
||||||
|
get_button_properties,
|
||||||
|
get_default_hidden_columns,
|
||||||
|
get_departement_region,
|
||||||
|
meta_content,
|
||||||
|
prepare_table_data,
|
||||||
|
sort_table_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
datatable = html.Div(
|
||||||
|
className="marches_table",
|
||||||
|
children=DataTable(
|
||||||
|
dtid="acheteur_datatable",
|
||||||
|
page_action="custom",
|
||||||
|
filter_action="custom",
|
||||||
|
sort_action="custom",
|
||||||
|
page_size=10,
|
||||||
|
hidden_columns=get_default_hidden_columns(page="acheteur"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = [
|
||||||
|
dcc.Store(id="acheteur_data", storage_type="memory"),
|
||||||
|
dcc.Location(id="url", refresh="callback-nav"),
|
||||||
|
html.Div(
|
||||||
|
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-data-acheteur",
|
||||||
|
),
|
||||||
|
dcc.Download(id="download-data-acheteur"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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
|
||||||
|
html.H3("Derniers marchés publics attribués"),
|
||||||
|
dcc.Loading(
|
||||||
|
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
||||||
|
id="loading-home",
|
||||||
|
type="default",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
[
|
||||||
|
html.P("lignes", id="acheteur_nb_rows"),
|
||||||
|
html.Button(
|
||||||
|
"Téléchargement désactivé au-delà de 65 000 lignes",
|
||||||
|
id="btn-download-filtered-data-acheteur",
|
||||||
|
disabled=True,
|
||||||
|
),
|
||||||
|
dcc.Download(id="acheteur-download-filtered-data"),
|
||||||
|
],
|
||||||
|
className="table-menu",
|
||||||
|
),
|
||||||
|
datatable,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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):
|
||||||
|
dff = pl.DataFrame(data, strict=False, infer_schema_length=5000)
|
||||||
|
if dff.height == 0:
|
||||||
|
dff = pl.DataFrame(schema=df.collect_schema())
|
||||||
|
df_marches = dff.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 = dff.unique("titulaire_id").height
|
||||||
|
nb_titulaires = [
|
||||||
|
html.Strong(format_number(nb_titulaires)),
|
||||||
|
" titulaires (SIRET) différents",
|
||||||
|
]
|
||||||
|
del dff
|
||||||
|
|
||||||
|
return marches_attribues, nb_titulaires
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="acheteur_data", component_property="data"),
|
||||||
|
Output("btn-download-data-acheteur", "disabled"),
|
||||||
|
Output("btn-download-data-acheteur", "children"),
|
||||||
|
Output("btn-download-data-acheteur", "title"),
|
||||||
|
Input(component_id="url", component_property="pathname"),
|
||||||
|
Input(component_id="acheteur_year", component_property="value"),
|
||||||
|
)
|
||||||
|
def get_acheteur_marches_data(url, acheteur_year: str) -> tuple:
|
||||||
|
acheteur_siret = url.split("/")[-1]
|
||||||
|
lff = df.lazy()
|
||||||
|
lff = lff.filter(pl.col("acheteur_id") == acheteur_siret)
|
||||||
|
if acheteur_year and acheteur_year != "Toutes":
|
||||||
|
acheteur_year = int(acheteur_year)
|
||||||
|
lff = lff.filter(pl.col("dateNotification").dt.year() == acheteur_year)
|
||||||
|
lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True)
|
||||||
|
dff: pl.DataFrame = lff.collect(engine="streaming")
|
||||||
|
download_disabled, download_text, download_title = get_button_properties(dff.height)
|
||||||
|
|
||||||
|
data = dff.to_dicts()
|
||||||
|
return data, download_disabled, download_text, download_title
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("acheteur_datatable", "data"),
|
||||||
|
Output("acheteur_datatable", "columns"),
|
||||||
|
Output("acheteur_datatable", "tooltip_header"),
|
||||||
|
Output("acheteur_datatable", "data_timestamp"),
|
||||||
|
Output("acheteur_nb_rows", "children"),
|
||||||
|
Output("btn-download-filtered-data-acheteur", "disabled"),
|
||||||
|
Output("btn-download-filtered-data-acheteur", "children"),
|
||||||
|
Output("btn-download-filtered-data-acheteur", "title"),
|
||||||
|
Input("acheteur_data", "data"),
|
||||||
|
Input("acheteur_datatable", "page_current"),
|
||||||
|
Input("acheteur_datatable", "page_size"),
|
||||||
|
Input("acheteur_datatable", "filter_query"),
|
||||||
|
Input("acheteur_datatable", "sort_by"),
|
||||||
|
State("acheteur_datatable", "data_timestamp"),
|
||||||
|
)
|
||||||
|
def get_last_marches_data(
|
||||||
|
data, page_current, page_size, filter_query, sort_by, data_timestamp
|
||||||
|
) -> tuple:
|
||||||
|
return prepare_table_data(
|
||||||
|
data, data_timestamp, filter_query, page_current, page_size, sort_by
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
||||||
|
Output("download-data-acheteur", "data"),
|
||||||
|
Input("btn-download-data-acheteur", "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")
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("acheteur-download-filtered-data", "data"),
|
||||||
|
State("acheteur_data", "data"),
|
||||||
|
Input("btn-download-filtered-data-acheteur", "n_clicks"),
|
||||||
|
State("acheteur_nom", "children"),
|
||||||
|
State("acheteur_datatable", "filter_query"),
|
||||||
|
State("acheteur_datatable", "sort_by"),
|
||||||
|
State("acheteur_datatable", "hidden_columns"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def download_filtered_acheteur_data(
|
||||||
|
data, n_clicks, acheteur_nom, filter_query, sort_by, hidden_columns: list = None
|
||||||
|
):
|
||||||
|
lff: pl.LazyFrame = pl.LazyFrame(
|
||||||
|
data
|
||||||
|
) # start from the full acheteur data, not from paginated table 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.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
||||||
|
return dcc.send_bytes(
|
||||||
|
to_bytes, filename=f"decp_filtrées_{acheteur_nom}_{date}.xlsx"
|
||||||
|
)
|
||||||
@@ -1,254 +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écharger au format Excel", id="btn-download-data"),
|
|
||||||
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"),
|
|
||||||
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()
|
|
||||||
|
|
||||||
nb_rows = f"{format_number(dff.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()
|
|
||||||
|
|
||||||
return dicts, data_timestamp + 1, nb_rows
|
|
||||||
|
|
||||||
|
|
||||||
@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")
|
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
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_values, 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.unique("uid").collect(engine="streaming")
|
||||||
|
dff_marche = format_values(dff_marche)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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"]:
|
||||||
|
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
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from dash import Input, Output, callback, dcc, html, register_page
|
||||||
|
|
||||||
|
from src.figures import DataTable
|
||||||
|
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}"),
|
||||||
|
DataTable(
|
||||||
|
dtid=f"results_{org_type}_datatable",
|
||||||
|
columns=columns,
|
||||||
|
data=results.to_dicts(),
|
||||||
|
page_size=10,
|
||||||
|
sort_action="none",
|
||||||
|
filter_action="none",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if count > 0
|
||||||
|
else html.P(f"Aucun {org_type} trouvé."),
|
||||||
|
]
|
||||||
|
content.extend(org_content)
|
||||||
|
|
||||||
|
return content
|
||||||
|
return html.P("")
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
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
|
from src.utils import df, meta_content
|
||||||
|
|
||||||
title = "Statistiques"
|
name = "Statistiques"
|
||||||
|
|
||||||
register_page(
|
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(
|
html.Div(
|
||||||
className="container",
|
className="container",
|
||||||
children=[
|
children=[
|
||||||
html.H2(title),
|
html.H2(name),
|
||||||
dcc.Loading(
|
dcc.Loading(
|
||||||
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
||||||
id="loading-statistques",
|
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),
|
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"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
from dash import Input, Output, State, callback, dcc, html, no_update, register_page
|
||||||
|
|
||||||
|
from src.figures import DataTable
|
||||||
|
from src.utils import (
|
||||||
|
df,
|
||||||
|
filter_table_data,
|
||||||
|
get_default_hidden_columns,
|
||||||
|
invert_columns,
|
||||||
|
meta_content,
|
||||||
|
schema,
|
||||||
|
sort_table_data,
|
||||||
|
)
|
||||||
|
from utils import prepare_table_data
|
||||||
|
|
||||||
|
update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
|
||||||
|
update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y")
|
||||||
|
|
||||||
|
|
||||||
|
name = "Tableau"
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path="/tableau",
|
||||||
|
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=DataTable(
|
||||||
|
dtid="table",
|
||||||
|
page_size=20,
|
||||||
|
page_action="custom",
|
||||||
|
filter_action="custom",
|
||||||
|
sort_action="custom",
|
||||||
|
hidden_columns=get_default_hidden_columns(None),
|
||||||
|
columns=[{"id": col, "name": col} for col in df.columns],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = [
|
||||||
|
dcc.Location(id="url", refresh=False),
|
||||||
|
html.Div(
|
||||||
|
html.Details(
|
||||||
|
children=[
|
||||||
|
html.Summary(
|
||||||
|
html.H3("Mode d'emploi", style={"textDecoration": "underline"}),
|
||||||
|
),
|
||||||
|
dcc.Markdown(
|
||||||
|
dangerously_allow_html=True,
|
||||||
|
children="""
|
||||||
|
##### 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". Lorsque vous ouvrez une URL de vue, le format équivalent `icontains rennes` est utilisé.
|
||||||
|
- Champs numériques : vous pouvez soit taper un nombre pour trouver les valeurs égales, soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres.
|
||||||
|
- Champs date : vous pouvez également utiliser **>** ou **<**. Exemples : `< 2024-01-31` pour "avant le 31 janvier 2024",
|
||||||
|
`2024` pour "en 2024", `> 2022` pour "à partir de 2022". Lorsque vous ouvrez une URL de vue, le format équivalent `i<` ou `i>` est utilisé.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
##### Partager une vue
|
||||||
|
|
||||||
|
Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqué. Vous pouvez copier une adresse Web qui reproduit la vue courante à l'identique en cliquant sur l'icône <img src="/assets/copy.svg" alt="drawing" width="20"/>. En la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres.
|
||||||
|
|
||||||
|
Pratique pour partager une vue avec un·e collègue, sur les réseaux sociaux, ou la sauvegarder pour plus tard.
|
||||||
|
|
||||||
|
##### 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.Div(id="copy-container"),
|
||||||
|
dcc.Input(id="share-url", readOnly=True, style={"display": "none"}),
|
||||||
|
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 ctx.triggered_id != "url":
|
||||||
|
# search_params = None
|
||||||
|
# else:
|
||||||
|
# search_params = urllib.parse.parse_qs(search_params.lstrip("?"))
|
||||||
|
return prepare_table_data(
|
||||||
|
None, data_timestamp, filter_query, page_current, page_size, sort_by
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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.lazy() # 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")
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("table", "filter_query"),
|
||||||
|
Output("table", "sort_by"),
|
||||||
|
Output("table", "hidden_columns"),
|
||||||
|
Output("url", "search", allow_duplicate=True),
|
||||||
|
Input("url", "search"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def restore_view_from_url(search):
|
||||||
|
if not search:
|
||||||
|
return no_update, no_update, no_update, no_update
|
||||||
|
|
||||||
|
params = urllib.parse.parse_qs(search.lstrip("?"))
|
||||||
|
print("params", params)
|
||||||
|
|
||||||
|
filter_query = no_update
|
||||||
|
sort_by = no_update
|
||||||
|
hidden_columns = no_update
|
||||||
|
|
||||||
|
if "filtres" in params:
|
||||||
|
filter_query = params["filtres"][0]
|
||||||
|
|
||||||
|
if "tris" in params:
|
||||||
|
try:
|
||||||
|
sort_by = json.loads(params["tris"][0])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if "colonnes" in params:
|
||||||
|
columns = params["colonnes"][0].split(",")
|
||||||
|
verified_columns = [column for column in columns if column in schema.names()]
|
||||||
|
hidden_columns = invert_columns(verified_columns)
|
||||||
|
|
||||||
|
return filter_query, sort_by, hidden_columns, ""
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("share-url", "value"),
|
||||||
|
Output("copy-container", "children"),
|
||||||
|
Input("table", "filter_query"),
|
||||||
|
Input("table", "sort_by"),
|
||||||
|
Input("table", "hidden_columns"),
|
||||||
|
State("url", "href"),
|
||||||
|
)
|
||||||
|
def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href):
|
||||||
|
if not href:
|
||||||
|
return no_update, no_update
|
||||||
|
|
||||||
|
# Extract base URL (remove existing query params)
|
||||||
|
base_url = href.split("?")[0]
|
||||||
|
|
||||||
|
params = {}
|
||||||
|
if filter_query:
|
||||||
|
params["filtres"] = filter_query
|
||||||
|
|
||||||
|
if sort_by:
|
||||||
|
params["tris"] = json.dumps(sort_by)
|
||||||
|
|
||||||
|
if hidden_columns:
|
||||||
|
columns = invert_columns(hidden_columns)
|
||||||
|
columns = ",".join(columns)
|
||||||
|
params["colonnes"] = columns
|
||||||
|
|
||||||
|
query_string = urllib.parse.urlencode(params)
|
||||||
|
full_url = f"{base_url}?{query_string}" if query_string else base_url
|
||||||
|
|
||||||
|
copy_button = dcc.Clipboard(
|
||||||
|
id="btn-copy-url",
|
||||||
|
target_id="share-url",
|
||||||
|
title="Copier l'URL de cette vue",
|
||||||
|
style={
|
||||||
|
"display": "inline-block",
|
||||||
|
"fontSize": 20,
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"cursor": "pointer",
|
||||||
|
},
|
||||||
|
className="fa fa-link",
|
||||||
|
)
|
||||||
|
|
||||||
|
return full_url, copy_button
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("copy-container", "children", allow_duplicate=True),
|
||||||
|
Input("btn-copy-url", "n_clicks", allow_optional=True),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def show_confirmation(n_clicks):
|
||||||
|
if n_clicks:
|
||||||
|
return html.Span(
|
||||||
|
"Adresse de la vue copiée",
|
||||||
|
style={"color": "green", "fontWeight": "bold", "marginLeft": "10px"},
|
||||||
|
)
|
||||||
|
return no_update
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
from dash import Input, Output, State, callback, dcc, html, register_page
|
||||||
|
|
||||||
|
from src.callbacks import get_top_org_table
|
||||||
|
from src.figures import DataTable, point_on_map
|
||||||
|
from src.utils import (
|
||||||
|
df,
|
||||||
|
filter_table_data,
|
||||||
|
format_number,
|
||||||
|
get_annuaire_data,
|
||||||
|
get_button_properties,
|
||||||
|
get_default_hidden_columns,
|
||||||
|
get_departement_region,
|
||||||
|
meta_content,
|
||||||
|
prepare_table_data,
|
||||||
|
sort_table_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
datatable = html.Div(
|
||||||
|
className="marches_table",
|
||||||
|
children=DataTable(
|
||||||
|
dtid="titulaire_datatable",
|
||||||
|
page_action="custom",
|
||||||
|
filter_action="custom",
|
||||||
|
sort_action="custom",
|
||||||
|
page_size=10,
|
||||||
|
hidden_columns=get_default_hidden_columns(page="titulaire"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = [
|
||||||
|
dcc.Store(id="titulaire_data", storage_type="memory"),
|
||||||
|
dcc.Location(id="url", refresh="callback-nav"),
|
||||||
|
html.Div(
|
||||||
|
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-data-titulaire",
|
||||||
|
),
|
||||||
|
dcc.Download(id="download-data-titulaire"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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
|
||||||
|
html.H3("Derniers marchés publics remportés"),
|
||||||
|
dcc.Loading(
|
||||||
|
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
||||||
|
id="loading-home",
|
||||||
|
type="default",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
[
|
||||||
|
html.P("lignes", id="titulaire_nb_rows"),
|
||||||
|
html.Button(
|
||||||
|
"Téléchargement désactivé au-delà de 65 000 lignes",
|
||||||
|
id="btn-download-filtered-data-titulaire",
|
||||||
|
disabled=True,
|
||||||
|
),
|
||||||
|
dcc.Download(id="titulaire-download-filtered-data"),
|
||||||
|
],
|
||||||
|
className="table-menu",
|
||||||
|
),
|
||||||
|
datatable,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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, strict=False, infer_schema_length=5000)
|
||||||
|
if dff.height == 0:
|
||||||
|
nb_marches = 0
|
||||||
|
nb_acheteurs = 0
|
||||||
|
else:
|
||||||
|
df_marches = dff.unique("uid")
|
||||||
|
nb_marches = format_number(df_marches.height)
|
||||||
|
nb_acheteurs = dff.unique("acheteur_id").height
|
||||||
|
|
||||||
|
texte_marches_remportes = [
|
||||||
|
html.Strong(nb_marches),
|
||||||
|
" marchés et accord-cadres remportés",
|
||||||
|
]
|
||||||
|
# + ", pour un total de ", html.Strong(somme_marches + " €")]
|
||||||
|
|
||||||
|
texte_nb_acheteurs = [
|
||||||
|
html.Strong(format_number(nb_acheteurs)),
|
||||||
|
" acheteurs (SIRET) différents",
|
||||||
|
]
|
||||||
|
|
||||||
|
return texte_marches_remportes, texte_nb_acheteurs
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="titulaire_data", component_property="data"),
|
||||||
|
Output("btn-download-data-titulaire", "disabled"),
|
||||||
|
Output("btn-download-data-titulaire", "children"),
|
||||||
|
Output("btn-download-data-titulaire", "title"),
|
||||||
|
Input(component_id="url", component_property="pathname"),
|
||||||
|
Input(component_id="titulaire_year", component_property="value"),
|
||||||
|
)
|
||||||
|
def get_titulaire_marches_data(url, titulaire_year: str) -> tuple:
|
||||||
|
titulaire_siret = url.split("/")[-1]
|
||||||
|
lff = df.lazy()
|
||||||
|
lff = lff.filter(
|
||||||
|
(pl.col("titulaire_id") == titulaire_siret)
|
||||||
|
& (pl.col("titulaire_typeIdentifiant") == "SIRET")
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
lff = lff.fill_null("")
|
||||||
|
|
||||||
|
dff: pl.DataFrame = lff.collect(engine="streaming")
|
||||||
|
download_disabled, download_text, download_title = get_button_properties(dff.height)
|
||||||
|
|
||||||
|
data = dff.to_dicts()
|
||||||
|
return data, download_disabled, download_text, download_title
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("titulaire_datatable", "data"),
|
||||||
|
Output("titulaire_datatable", "columns"),
|
||||||
|
Output("titulaire_datatable", "tooltip_header"),
|
||||||
|
Output("titulaire_datatable", "data_timestamp"),
|
||||||
|
Output("titulaire_nb_rows", "children"),
|
||||||
|
Output("btn-download-filtered-data-titulaire", "disabled"),
|
||||||
|
Output("btn-download-filtered-data-titulaire", "children"),
|
||||||
|
Output("btn-download-filtered-data-titulaire", "title"),
|
||||||
|
Input("titulaire_data", "data"),
|
||||||
|
Input("titulaire_datatable", "page_current"),
|
||||||
|
Input("titulaire_datatable", "page_size"),
|
||||||
|
Input("titulaire_datatable", "filter_query"),
|
||||||
|
Input("titulaire_datatable", "sort_by"),
|
||||||
|
State("titulaire_datatable", "data_timestamp"),
|
||||||
|
)
|
||||||
|
def get_last_marches_data(
|
||||||
|
data, page_current, page_size, filter_query, sort_by, data_timestamp
|
||||||
|
) -> list[dict]:
|
||||||
|
return prepare_table_data(
|
||||||
|
data, data_timestamp, filter_query, page_current, page_size, sort_by
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
||||||
|
Output("download-data-titulaire", "data"),
|
||||||
|
Input("btn-download-data-titulaire", "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")
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("titulaire-download-filtered-data", "data"),
|
||||||
|
State("titulaire_data", "data"),
|
||||||
|
Input("btn-download-filtered-data-titulaire", "n_clicks"),
|
||||||
|
State("titulaire_nom", "children"),
|
||||||
|
State("titulaire_datatable", "filter_query"),
|
||||||
|
State("titulaire_datatable", "sort_by"),
|
||||||
|
State("titulaire_datatable", "hidden_columns"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def download_filtered_titulaire_data(
|
||||||
|
data, n_clicks, titulaire_nom, filter_query, sort_by, hidden_columns: list = None
|
||||||
|
):
|
||||||
|
lff: pl.LazyFrame = pl.LazyFrame(
|
||||||
|
data
|
||||||
|
) # start from the full titulaire data, not from paginated table 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.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
||||||
|
return dcc.send_bytes(
|
||||||
|
to_bytes, filename=f"decp_filtrées_{titulaire_nom}_{date}.xlsx"
|
||||||
|
)
|
||||||
+602
-45
@@ -1,23 +1,18 @@
|
|||||||
|
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 dotenv import load_dotenv
|
from httpx import get, post
|
||||||
from polars.exceptions import ComputeError
|
from polars.exceptions import ComputeError
|
||||||
|
from unidecode import unidecode
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -26,6 +21,14 @@ logging.basicConfig(
|
|||||||
|
|
||||||
|
|
||||||
def split_filter_part(filter_part):
|
def split_filter_part(filter_part):
|
||||||
|
operators = [
|
||||||
|
["s<", "<"],
|
||||||
|
["s>", ">"],
|
||||||
|
["i<", "<"],
|
||||||
|
["i>", ">"],
|
||||||
|
["icontains", "contains"],
|
||||||
|
# [" ", "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:
|
||||||
@@ -40,39 +43,72 @@ def split_filter_part(filter_part):
|
|||||||
return [None] * 3
|
return [None] * 3
|
||||||
|
|
||||||
|
|
||||||
def add_resource_link(lff: pl.LazyFrame) -> pl.LazyFrame:
|
def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
|
||||||
lff = lff.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")
|
||||||
)
|
)
|
||||||
lff = lff.drop(["sourceFile", "sourceDataset"])
|
dff = dff.drop(["sourceFile"])
|
||||||
return lff
|
return dff
|
||||||
|
|
||||||
|
|
||||||
def add_annuaire_link(lff: pl.LazyFrame):
|
def add_links(dff: pl.DataFrame, target: str = "_blank"):
|
||||||
lff = lff.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 = "https://annuaire-entreprises.data.gouv.fr/etablissement/'
|
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",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
'<a href = "/titulaires/'
|
||||||
|
+ pl.col("titulaire_id")
|
||||||
|
+ f'" target="{target}">'
|
||||||
|
+ pl.col(col)
|
||||||
|
+ "</a>"
|
||||||
|
)
|
||||||
|
.otherwise(pl.col(col))
|
||||||
|
.alias(col)
|
||||||
|
)
|
||||||
|
if col.startswith("acheteur_"):
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def add_links_in_dict(data: list[dict], 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>'
|
||||||
)
|
)
|
||||||
.otherwise(pl.col("titulaire_id"))
|
if marche.get("uid"):
|
||||||
.alias("titulaire_id")
|
marche["id"] = f'<a href="/marches/{marche["uid"]}">{marche["id"]}</a>'
|
||||||
)
|
marche["uid"] = f'<a href="/marches/{marche["uid"]}">{marche["uid"]}</a>'
|
||||||
lff = lff.with_columns(
|
new_data.append(marche)
|
||||||
(
|
return new_data
|
||||||
'<a href = "https://annuaire-entreprises.data.gouv.fr/etablissement/'
|
|
||||||
+ pl.col("acheteur_id")
|
|
||||||
+ '">'
|
|
||||||
+ pl.col("acheteur_id")
|
|
||||||
+ "</a>"
|
|
||||||
).alias("acheteur_id")
|
|
||||||
)
|
|
||||||
return lff
|
|
||||||
|
|
||||||
|
|
||||||
def booleans_to_strings(lff: pl.LazyFrame) -> pl.LazyFrame:
|
def booleans_to_strings(lff: pl.LazyFrame) -> pl.LazyFrame:
|
||||||
@@ -96,12 +132,71 @@ def numbers_to_strings(lff: pl.LazyFrame) -> pl.LazyFrame:
|
|||||||
return lff
|
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:
|
def format_number(number) -> str:
|
||||||
number = "{:,}".format(number).replace(",", " ")
|
number = "{:,}".format(number).replace(",", " ")
|
||||||
return number
|
return number
|
||||||
|
|
||||||
|
|
||||||
def get_decp_data() -> pl.LazyFrame:
|
def format_values(dff: pl.DataFrame) -> pl.DataFrame:
|
||||||
|
def format_montant(expr, scale=None):
|
||||||
|
# https://stackoverflow.com/a/78636786
|
||||||
|
expr = expr.cast(pl.String)
|
||||||
|
expr = expr.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.str.head(2))
|
||||||
|
.otherwise(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")
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
# 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.
|
||||||
@@ -117,13 +212,475 @@ def get_decp_data() -> pl.LazyFrame:
|
|||||||
sleep(10)
|
sleep(10)
|
||||||
lff: pl.LazyFrame = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
|
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
|
# 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_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:
|
||||||
|
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("operator:", operator)
|
||||||
|
print("col_type:", col_type)
|
||||||
|
|
||||||
|
lff = lff.filter(pl.col(col_name).is_not_null())
|
||||||
|
|
||||||
|
if col_type == "Date":
|
||||||
|
# Convertir la colonne date en chaînes de caractères
|
||||||
|
lff = dates_to_strings(lff, col_name)
|
||||||
|
col_type = "String"
|
||||||
|
if col_type == "String":
|
||||||
|
lff = lff.filter(pl.col(col_name) != pl.lit(""))
|
||||||
|
|
||||||
|
elif col_type.startswith("Int") or col_type.startswith("Float"):
|
||||||
|
try:
|
||||||
|
filter_value = int(filter_value)
|
||||||
|
except ValueError:
|
||||||
|
logger.error(f"Invalid numeric filter value: {filter_value}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if operator in ("contains", "<", "<=", ">", ">="):
|
||||||
|
if operator == "<":
|
||||||
|
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 operator == "contains":
|
||||||
|
if col_type in ["String", "Date"]:
|
||||||
|
filter_value = filter_value.strip('"')
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col(col_name).str.contains("(?i)" + filter_value)
|
||||||
|
)
|
||||||
|
elif col_type.startswith("Int") or col_type.startswith("Float"):
|
||||||
|
lff = lff.filter(pl.col(col_name) == filter_value)
|
||||||
|
else:
|
||||||
|
logger.error(f"Invalid column type: {col_type}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Invalid operator: {operator}")
|
||||||
|
|
||||||
|
# elif operator == 'datestartswith':
|
||||||
|
# lff = lff.filter(pl.col(col_name).str.startswith(filter_value)")
|
||||||
|
|
||||||
return lff
|
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")
|
||||||
|
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_default_hidden_columns(page):
|
||||||
|
if page == "acheteur":
|
||||||
|
displayed_columns = [
|
||||||
|
"uid",
|
||||||
|
"objet",
|
||||||
|
"dateNotification",
|
||||||
|
"titulaire_id",
|
||||||
|
"titulaire_typeIdentifiant",
|
||||||
|
"titulaire_nom",
|
||||||
|
"distance",
|
||||||
|
"montant",
|
||||||
|
"codeCPV",
|
||||||
|
"dureeRestanteMois",
|
||||||
|
]
|
||||||
|
elif page == "titulaire":
|
||||||
|
displayed_columns = [
|
||||||
|
"uid",
|
||||||
|
"objet",
|
||||||
|
"dateNotification",
|
||||||
|
"acheteur_id",
|
||||||
|
"acheteur_nom",
|
||||||
|
"distance",
|
||||||
|
"montant",
|
||||||
|
"codeCPV",
|
||||||
|
"dureeRestanteMois",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
displayed_columns = os.getenv("DISPLAYED_COLUMNS")
|
||||||
|
if displayed_columns is None:
|
||||||
|
raise ValueError("DISPLAYED_COLUMNS n'est pas configuré")
|
||||||
|
else:
|
||||||
|
displayed_columns = displayed_columns.replace(" ", "").split(",")
|
||||||
|
|
||||||
|
hidden_columns = []
|
||||||
|
|
||||||
|
for col in schema.names():
|
||||||
|
if col in displayed_columns:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
hidden_columns.append(col)
|
||||||
|
return hidden_columns
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_table_data(
|
||||||
|
data, data_timestamp, filter_query, page_current, page_size, sort_by
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Fonction de préparation des données pour les datatables, afin de permettre une gestion fine des logiques,
|
||||||
|
notamment pour les filtres et les tris.
|
||||||
|
:param data
|
||||||
|
:param data_timestamp:
|
||||||
|
:param filter_query:
|
||||||
|
:param page_current:
|
||||||
|
:param page_size:
|
||||||
|
:param sort_by:
|
||||||
|
:param search_params:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
|
||||||
|
if os.getenv("DEVELOPMENT").lower() == "true":
|
||||||
|
print(" + + + + + + + + + + + + + + + + + + ")
|
||||||
|
|
||||||
|
# Récupération des données
|
||||||
|
if isinstance(data, list):
|
||||||
|
lff: pl.LazyFrame = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
|
||||||
|
else:
|
||||||
|
lff: pl.LazyFrame = df.lazy() # start from the original data
|
||||||
|
|
||||||
|
# if search_params:
|
||||||
|
# if "filtres" in search_params:
|
||||||
|
# filter_query = search_params["filtres"][0]
|
||||||
|
#
|
||||||
|
# if "tris" in search_params:
|
||||||
|
# try:
|
||||||
|
# sort_by = json.loads(search_params["tris"][0])
|
||||||
|
# except json.JSONDecodeError:
|
||||||
|
# pass
|
||||||
|
#
|
||||||
|
# if "colonnes" in search_params:
|
||||||
|
# try:
|
||||||
|
# hidden_columns = json.loads(search_params["colonnes"][0])
|
||||||
|
# print(hidden_columns)
|
||||||
|
# lff = lff.drop(hidden_columns)
|
||||||
|
# except json.JSONDecodeError:
|
||||||
|
# pass
|
||||||
|
|
||||||
|
# Application des filtres
|
||||||
|
if filter_query:
|
||||||
|
lff = filter_table_data(lff, filter_query)
|
||||||
|
|
||||||
|
# Application des tris
|
||||||
|
if len(sort_by) > 0:
|
||||||
|
lff = sort_table_data(lff, sort_by)
|
||||||
|
|
||||||
|
# Matérialisation des filtres
|
||||||
|
dff: pl.DataFrame = lff.collect()
|
||||||
|
height = dff.height
|
||||||
|
|
||||||
|
if height > 0:
|
||||||
|
nb_rows = f"{format_number(height)} lignes ({format_number(dff.select('uid').unique().height)} marchés)"
|
||||||
|
else:
|
||||||
|
nb_rows = "0 lignes (0 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)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
dff = add_links(dff)
|
||||||
|
|
||||||
|
# Ajout des liens vers les fichiers Open Data
|
||||||
|
if "sourceFile" in dff.columns:
|
||||||
|
dff = add_resource_link(dff)
|
||||||
|
|
||||||
|
# Formatage des montants
|
||||||
|
if height > 0:
|
||||||
|
dff = format_values(dff)
|
||||||
|
|
||||||
|
# Récupération des colonnes et tooltip
|
||||||
|
columns, tooltip = setup_table_columns(dff)
|
||||||
|
|
||||||
|
dicts = dff.to_dicts()
|
||||||
|
|
||||||
|
# Propriétés du bouton de téléchargement
|
||||||
|
download_disabled, download_text, download_title = get_button_properties(height)
|
||||||
|
|
||||||
|
return (
|
||||||
|
dicts,
|
||||||
|
columns,
|
||||||
|
tooltip,
|
||||||
|
data_timestamp + 1,
|
||||||
|
nb_rows,
|
||||||
|
download_disabled,
|
||||||
|
download_text,
|
||||||
|
download_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_button_properties(height):
|
||||||
|
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."
|
||||||
|
elif height == 0:
|
||||||
|
download_disabled = True
|
||||||
|
download_text = "Pas de données à télécharger"
|
||||||
|
download_title = ""
|
||||||
|
else:
|
||||||
|
download_disabled = False
|
||||||
|
download_text = "Télécharger au format Excel"
|
||||||
|
download_title = ""
|
||||||
|
return download_disabled, download_text, download_title
|
||||||
|
|
||||||
|
|
||||||
|
def invert_columns(columns):
|
||||||
|
"""
|
||||||
|
Renvoie les colonnes du schéma non spécifiées en paramètre. Utile pour passer d'une colonnes masquées à une liste de colonnes affichées, et vice versa.
|
||||||
|
|
||||||
|
:param columns:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
inverted_columns = []
|
||||||
|
for column in schema.names():
|
||||||
|
if column not in columns:
|
||||||
|
inverted_columns.append(column)
|
||||||
|
return inverted_columns
|
||||||
|
|
||||||
|
|
||||||
|
df: pl.DataFrame = get_decp_data()
|
||||||
|
schema = df.collect_schema()
|
||||||
|
|
||||||
|
df_acheteurs = get_org_data(df, "acheteur")
|
||||||
|
df_titulaires = get_org_data(df, "titulaire")
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user