Compare commits
392 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08fc4dcfdc | |||
| ad3f2cf654 | |||
| 24bce6e2d6 | |||
| 72da1a15e2 | |||
| f4514bf06c | |||
| a54f78875e | |||
| 7b78e0a0ea | |||
| eecd75ac42 | |||
| b06f3c91e0 | |||
| 139b820b6b | |||
| bb2cde2fc5 | |||
| 3957ca1662 | |||
| 547accd7be | |||
| ff6b5d0d41 | |||
| a3dce84cc5 | |||
| e6bf671f16 | |||
| b34f63f711 | |||
| d6e14e2564 | |||
| dd63feeeac | |||
| 3b2cb15935 | |||
| 91ea9eccea | |||
| a89677604b | |||
| e5419bab9c | |||
| 4f9f31c4c7 | |||
| 28fdca2a06 | |||
| 771dcf0ea1 | |||
| dac9efee88 | |||
| 50c3947c04 | |||
| 9852d55a3d | |||
| 144e714235 | |||
| a9d3d96a5e | |||
| dbfc20921a | |||
| d8f1a884a3 | |||
| 0ac6c55d9e | |||
| 6a6be51455 | |||
| 52958ccbaa | |||
| 8a61d3268a | |||
| 32aa877797 | |||
| 858ab6c61a | |||
| b08d517f36 | |||
| 44d2d7d2c1 | |||
| f2af09bb35 | |||
| 24db748b6a | |||
| 1ffa995a4a | |||
| 9da3d9ce34 | |||
| 125c520c19 | |||
| 78d528f75b | |||
| c77511d4e8 | |||
| f2046d6ba7 | |||
| 26dd2aaf1a | |||
| f8fffb6fa4 | |||
| b4a42449ad | |||
| 051e908bd1 | |||
| 1fdcb12dd2 | |||
| 5d4c0b8438 | |||
| 958c3956ea | |||
| acb8500dc0 | |||
| e804b6bca2 | |||
| 3745f6df74 | |||
| bf1791635f | |||
| 1d88682f85 | |||
| d1a876ba9c | |||
| 3a70bbd9ea | |||
| 79a06f996e | |||
| 2f90a754ab | |||
| d50ec5b01e | |||
| c02fb995c5 | |||
| 6c778882f9 | |||
| e754a3a217 | |||
| 9629231e29 | |||
| ab3377ef60 | |||
| f531ce7091 | |||
| adc8457abc | |||
| 302d253e6e | |||
| f0f9d8cb3d | |||
| 4771d14744 | |||
| d9f97cf8b3 | |||
| c7d1a5ec73 | |||
| 4f19085b75 | |||
| 0db800fdab | |||
| 4619dd2708 | |||
| 367e5f64ff | |||
| adda58bada | |||
| 2b0b048520 | |||
| 5c43bfea78 | |||
| 15c5a800ed | |||
| 83119b86e9 | |||
| af89bb0630 | |||
| eb5be1972e | |||
| b303a8bea6 | |||
| 472fbb7cbb | |||
| 6891aabd4d | |||
| 3beedbcfe4 | |||
| 37e893d642 | |||
| 64fc40e5aa | |||
| cb885eeb90 | |||
| 61dbf237cf | |||
| 39af4bafbe | |||
| f480439985 | |||
| ad9b4f0fdf | |||
| 0532f6b0a7 | |||
| 6fdf01e5e1 | |||
| 15de2aaf17 | |||
| 55e2992559 | |||
| f16cca036d | |||
| deba0a244e | |||
| ef11184286 | |||
| edd52d471e | |||
| 7b3ea9c580 | |||
| bfc4065cbb | |||
| 41a832d7be | |||
| 1561cb3c9a | |||
| f1725a95f8 | |||
| b59afd1cbc | |||
| 75b42f4baf | |||
| 8f5d38ff78 | |||
| bd149ff11f | |||
| cd81aa9532 | |||
| 2a381f6dbb | |||
| e609bc3e32 | |||
| c3ebdb0057 | |||
| 55bb92468e | |||
| 965aef06f9 | |||
| 3ce37784dc | |||
| cee51d929c | |||
| b11aa20743 | |||
| 674639cf6e | |||
| 68a87985c7 | |||
| 776aa271f1 | |||
| 427eb111bf | |||
| fe7277ce26 | |||
| b68fa3872f | |||
| b7ff04c69c | |||
| ccee27baee | |||
| 47e17c48e7 | |||
| e3b57608f4 | |||
| f4f0d70864 | |||
| 10c69013f0 | |||
| c08e2fa143 | |||
| afd812c50d | |||
| 536d37d67c | |||
| f43bb14e23 | |||
| 5422f95ea7 | |||
| 29f6e5c2c2 | |||
| c311ed5f23 | |||
| 31d0a626e6 | |||
| 307e3379e0 | |||
| 1fb18bcf6f | |||
| 30e822d435 | |||
| a44aaf6df6 | |||
| 8c11a018a0 | |||
| a0d6222424 | |||
| 25115c305c | |||
| 9d9533b8a2 | |||
| e4f06acc56 | |||
| e0dc2687c4 | |||
| 274e06cde4 | |||
| f9aadede8b | |||
| d35862beb0 | |||
| ed6811266d | |||
| 06f658ccf1 | |||
| e4c6659205 | |||
| 434a659778 | |||
| e573fcccae | |||
| 84739df7cb | |||
| 1d241ca9b7 | |||
| 0264aae3b9 | |||
| fb5c37b34b | |||
| 639b342178 | |||
| e8534c8108 | |||
| f5d8026061 | |||
| 1eecc45e29 | |||
| 07bdcf232d | |||
| 6e992e0a6e | |||
| a76a14b030 | |||
| b3bd488882 | |||
| a385e999d3 | |||
| a4bda0483f | |||
| 9a1e5bee63 | |||
| 1c4fccac6e | |||
| 187feee544 | |||
| 965f24ab92 | |||
| 3aee2667d0 | |||
| 3d96a5e1b8 | |||
| 35928d7245 | |||
| ac42dde488 | |||
| ae366bf014 | |||
| bd64c397dc | |||
| 937e8be356 | |||
| e880eaff12 | |||
| 07a97293cc | |||
| ef97cea679 | |||
| c9fc2a01fc | |||
| 8002bd711a | |||
| 1ba78fe8df | |||
| f1e24794e6 | |||
| 9110b78f2a | |||
| 2ce6005c88 | |||
| 4234736656 | |||
| 39faeca4d3 | |||
| 82bd82944a | |||
| 92f30a963f | |||
| 267e2e15f8 | |||
| dd52bd5158 | |||
| d499906bbe | |||
| 2fc501efbb | |||
| 945f7974c8 | |||
| 1044379425 | |||
| 421eaa0772 | |||
| 3776b18ff5 | |||
| 6005b2fcb5 | |||
| fc99723b2e | |||
| c3e801a15a | |||
| 38ea8834af | |||
| efac172ac8 | |||
| 337f68f2fc | |||
| 2d43654c81 | |||
| 5bcd17e8b0 | |||
| ec28e89788 | |||
| 338311fc62 | |||
| 86b445a007 | |||
| 16a914a98a | |||
| 0bc82fae1e | |||
| 3c68317f9d | |||
| d8dcb04fbf | |||
| edfe6dd65e | |||
| 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=
|
||||||
|
|||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
#### 2.7.0 (23 mars 2026)
|
||||||
|
|
||||||
|
- Remplacement de la page Statistiques par l'observatoire
|
||||||
|
- Généralisation de la grille dash (`dbc.Row`, `dbc.Col`)
|
||||||
|
- Ajout de l'histogramme de distances aux pages acheteur et titulaire
|
||||||
|
- Ajout de la colonne `acheteur_categorie` (commune, État, etc.)
|
||||||
|
|
||||||
|
##### 2.6.2 (22 février 2026)
|
||||||
|
|
||||||
|
- Correction du téléchargemnent buggé dans /tableau
|
||||||
|
|
||||||
|
##### 2.6.1 (17 février 2026)
|
||||||
|
|
||||||
|
- Corrections la création des liens canoniques (SEO)
|
||||||
|
|
||||||
|
#### 2.6.0 (5 février 2026)
|
||||||
|
|
||||||
|
- Suite de la refonte graphique
|
||||||
|
- Persistence des filtres, des tris et des choix de colonnes sur toutes les pages
|
||||||
|
- Joli tableau pour choisir les colonnes à afficher
|
||||||
|
- Meilleure gestion des acheteurs et titulaires absents de la base SIRENE
|
||||||
|
- Amélioration du SEO (liens canoniques)
|
||||||
|
|
||||||
|
##### 2.5.1 (29 janvier 2026)
|
||||||
|
|
||||||
|
- Mise en production un peu hâtive ([#67](https://github.com/ColinMaudry/decp.info/issues/67), [#68](https://github.com/ColinMaudry/decp.info/issues/68))
|
||||||
|
|
||||||
|
#### 2.5.0 (29 janvier 2026)
|
||||||
|
|
||||||
|
- Refonte graphique et amélioration des textes d'aide
|
||||||
|
- Amélioration du filtrage du tableau à partir d'une URL
|
||||||
|
- Renforcement du SEO avec une arborescence permettant l'accès aux marchés et des snippets JSON-LD
|
||||||
|
- Suppression de la dépendance à Google Fonts grâce à [Bunny Fonts](https://fonts.bunny.net) 🇪🇺 🇸🇮
|
||||||
|
|
||||||
|
##### 2.4.1 (22 janvier 2026)
|
||||||
|
|
||||||
|
- Meilleure gestion des colonnes absentes du schéma
|
||||||
|
|
||||||
|
#### 2.4.0 (22 janvier 2026)
|
||||||
|
|
||||||
|
- Site à peu près utilisable sur petit écran (smartphone) ([#63](https://github.com/ColinMaudry/decp.info/issues/63))
|
||||||
|
- Ajout de nouvelles statistiques dans [/statistiques](https://decp.info/statistiques) (stats par année, doublons par source)
|
||||||
|
- Amélioration du référencement Web (sitemap, titres, descriptions) ([#50](https://github.com/ColinMaudry/decp.info/issues/50))
|
||||||
|
- Possibilité dans les champs non-numériques de filtrer le texte selon son début ou sa fin (`text*` et `*text`)
|
||||||
|
- Ajout d'une table des matières dans la page [À propos](https://decp.infi/a-propos) ([#36](https://github.com/ColinMaudry/decp.info/issues/36))
|
||||||
|
- Désactivation du bloquage des robot d'agents de LLM (robots.txt)
|
||||||
|
|
||||||
|
##### 2.3.1 (16 janvier 2026)
|
||||||
|
|
||||||
|
- Les champs absents du [schéma](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire?resource_id=9a4144c0-ee44-4dec-bee5-bbef38191d9a) sont ignorés pour éviter les erreurs
|
||||||
|
|
||||||
|
#### 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=%7Bobjet%7D+icontains+%22d%C3%A9corations+de+no%C3%ABl%22+%26%26+%7BdateNotification%7D+icontains+2025&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdateNotification%2Cdistance%2Cacheteur_departement_code))
|
||||||
|
- 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)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**decp.info** is a French public procurement data explorer — a Dash (Python) web app for browsing, filtering, and visualizing _Données Essentielles de la Commande Publique_ (DECP). The UI is in French.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install ".[dev]"
|
||||||
|
cp template.env .env # then customize .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run run.py # starts Dash with debug=True and hot reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunicorn app:server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest # run all tests (Selenium-based integration tests)
|
||||||
|
uv run pytest tests/test_main.py::test_001_logo_and_search # run a single test
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests require a running Chrome/Chromium browser. They use `DashComposite` from `dash[testing]` with Selenium WebDriver.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Multi-page Dash app
|
||||||
|
|
||||||
|
- `src/app.py` — creates the Dash app instance, navbar, SEO endpoints (robots.txt, sitemap.xml), Matomo analytics
|
||||||
|
- `src/pages/*.py` — each page registers itself with `@register_page()` and owns its own layout and callbacks
|
||||||
|
- `run.py` — dev entry point; exports `server` (Flask) for gunicorn
|
||||||
|
|
||||||
|
### Module imports
|
||||||
|
|
||||||
|
- always import modules from the app starting with `src.` (e.g. `src.utils.`, `src.pages.recherche`, etc.)
|
||||||
|
|
||||||
|
### Key pages
|
||||||
|
|
||||||
|
| Page | URL | Purpose |
|
||||||
|
| ----------------- | --------------- | -------------------------------------- |
|
||||||
|
| `recherche.py` | `/` | Search homepage for buyers/contractors |
|
||||||
|
| `acheteur.py` | `/acheteur` | Buyer detail with stats, charts, maps |
|
||||||
|
| `titulaire.py` | `/titulaire` | Contractor detail |
|
||||||
|
| `tableau.py` | `/tableau` | Filterable data table with exports |
|
||||||
|
| `marche.py` | `/marche` | Individual contract detail |
|
||||||
|
| `observatoire.py` | `/observatoire` | An interactive analytics dashboard |
|
||||||
|
|
||||||
|
### Data layer
|
||||||
|
|
||||||
|
- Data is stored as **Parquet** and loaded with **Polars** (fast columnar operations)
|
||||||
|
- Path set via `DATA_FILE_PARQUET_PATH` env var; tests use `tests/test.parquet`
|
||||||
|
- `src/utils.py` — filtering helpers, search (`search_org`), link generation, geographic data loading
|
||||||
|
- `src/callbacks.py` — shared Dash callbacks (e.g. `get_top_org_table`)
|
||||||
|
- `src/figures.py` — chart and map components (Plotly Express, Dash Leaflet with marker clustering)
|
||||||
|
- a Parquet file with production data is located at `../decp-processing/decp_prod.parquet` (~ 1,5 million records)
|
||||||
|
- the TableSchema of the dataset with the list of field and their definition is located at `../decp-processing/reference/base_schema.json`
|
||||||
|
- `tests/test.parquet` is very small and may not contain all possible columns, only those necessary for testing
|
||||||
|
|
||||||
|
### UI stack
|
||||||
|
|
||||||
|
- **Dash 3.4** + **Dash Bootstrap Components** for layout
|
||||||
|
- **Plotly Express** for charts
|
||||||
|
- **Dash Leaflet** + **Dash Extensions** for interactive maps with clustering
|
||||||
|
- Custom CSS in `src/assets/css/`
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
|
- `DEVELOPMENT=true` enables debug logging and is set automatically during tests
|
||||||
|
- `.env` file is required at runtime (copy from `template.env`)
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
- `main` branch → manual deploy to decp.info via GitHub Actions
|
||||||
|
- `dev` branch → auto-deploy to test.decp.info via GitHub Actions
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
# decp.info
|
# decp.info
|
||||||
|
|
||||||
> v2.0.1
|
> v2.7.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,406 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"975": {
|
||||||
|
"departement": "Saint-Pierre-et-Miquelon",
|
||||||
|
"region": "Saint-Pierre-et-Miquelon"
|
||||||
|
},
|
||||||
|
"976": {
|
||||||
|
"departement": "Mayotte",
|
||||||
|
"region": "Mayotte"
|
||||||
|
},
|
||||||
|
"977": {
|
||||||
|
"departement": "Saint-Barthelemy",
|
||||||
|
"region": "Saint-Barthelemy"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
# Observatoire Link from Search & Tableau Results — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let users jump from search/tableau results to the observatoire page, pre-filtered for a given organization, via a 📊 link in the `_nom` columns.
|
||||||
|
|
||||||
|
**Architecture:** Modify `add_links()` in `src/utils.py` to append an observatoire link to `_nom` columns. Add two callbacks to `src/pages/observatoire.py` for bidirectional URL ↔ filter sync using the existing `dcc.Location(id="dashboard_url")`. Add a share URL input and clipboard button to the observatoire layout.
|
||||||
|
|
||||||
|
**Tech Stack:** Dash 3.4, Polars, `urllib.parse`, `dcc.Location`, `dcc.Clipboard`
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-18-observatoire-link-from-search-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add observatoire link to `acheteur_nom` in `add_links()`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/utils.py:82-91` (the `acheteur_` block inside `add_links()`)
|
||||||
|
- Test: `tests/test_main.py`
|
||||||
|
|
||||||
|
**Context:** The `add_links()` function loops over column names. The `if col.startswith("acheteur_")` block (lines 82-91) currently wraps both `acheteur_nom` and `acheteur_id` in a detail page link. We must only append the observatoire link when `col == "acheteur_nom"`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write a unit test for the observatoire link in acheteur_nom**
|
||||||
|
|
||||||
|
In `tests/test_main.py`, add a test that calls `add_links()` on a minimal DataFrame and checks the `acheteur_nom` column contains both the detail link and the observatoire link, while `acheteur_id` does NOT contain the observatoire link.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_004_add_links_observatoire_acheteur():
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from src.utils import add_links
|
||||||
|
|
||||||
|
dff = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"acheteur_id": ["a1"],
|
||||||
|
"acheteur_nom": ["ACHETEUR 1"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = add_links(dff)
|
||||||
|
nom_value = result["acheteur_nom"][0]
|
||||||
|
id_value = result["acheteur_id"][0]
|
||||||
|
|
||||||
|
# acheteur_nom should contain detail link + observatoire link
|
||||||
|
assert "/acheteurs/a1" in nom_value
|
||||||
|
assert "ACHETEUR 1" in nom_value
|
||||||
|
assert '/observatoire?acheteur_id=a1' in nom_value
|
||||||
|
assert "📊" in nom_value
|
||||||
|
|
||||||
|
# acheteur_id should NOT contain observatoire link
|
||||||
|
assert "/observatoire" not in id_value
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur -v`
|
||||||
|
Expected: FAIL — `'/observatoire?acheteur_id=a1'` not found in the output string.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the observatoire link for acheteur_nom**
|
||||||
|
|
||||||
|
In `src/utils.py`, modify the `if col.startswith("acheteur_")` block (lines 82-91). Gate the observatoire link append on `col == "acheteur_nom"`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if col.startswith("acheteur_"):
|
||||||
|
detail_link = (
|
||||||
|
'<a href = "/acheteurs/'
|
||||||
|
+ pl.col("acheteur_id")
|
||||||
|
+ '">'
|
||||||
|
+ pl.col(col)
|
||||||
|
+ "</a>"
|
||||||
|
)
|
||||||
|
if col == "acheteur_nom":
|
||||||
|
detail_link = (
|
||||||
|
detail_link
|
||||||
|
+ ' <a href="/observatoire?acheteur_id='
|
||||||
|
+ pl.col("acheteur_id")
|
||||||
|
+ '" title="Voir dans l\'observatoire">📊</a>'
|
||||||
|
)
|
||||||
|
dff = dff.with_columns(detail_link.alias(col))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update `test_001` to account for the new emoji in cell text**
|
||||||
|
|
||||||
|
The existing `test_001` asserts `result_table.find_element(...).text == name` for `acheteur_nom`. The cell text now includes "📊" from the observatoire link. Update the assertion in `tests/test_main.py` to use `startswith` instead of exact match:
|
||||||
|
|
||||||
|
```python
|
||||||
|
assert result_table.find_element(
|
||||||
|
by=By.CSS_SELECTOR, value=f'td[data-dash-column="{org_type}_nom"]'
|
||||||
|
).text.startswith(
|
||||||
|
name
|
||||||
|
), f"The search result should have the right {org_type} name"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run `test_001` to verify it still passes**
|
||||||
|
|
||||||
|
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_001_logo_and_search -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/utils.py tests/test_main.py
|
||||||
|
git commit -m "Ajout du lien observatoire dans acheteur_nom via add_links() #65"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add observatoire link to `titulaire_nom` in `add_links()`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/utils.py:64-81` (the `titulaire_` block inside `add_links()`)
|
||||||
|
- Test: `tests/test_main.py`
|
||||||
|
|
||||||
|
**Context:** The `titulaire_` block (lines 64-81) uses a `pl.when().then().otherwise()` pattern because it guards on `titulaire_typeIdentifiant` being SIRET or null. The observatoire link must be appended inside the `.then()` branch, and only when `col == "titulaire_nom"`. Note: this block requires `titulaire_typeIdentifiant` to be present in the DataFrame.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write a unit test for the observatoire link in titulaire_nom**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_005_add_links_observatoire_titulaire():
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from src.utils import add_links
|
||||||
|
|
||||||
|
dff = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"titulaire_id": ["t1"],
|
||||||
|
"titulaire_nom": ["TITULAIRE 1"],
|
||||||
|
"titulaire_typeIdentifiant": ["SIRET"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = add_links(dff)
|
||||||
|
nom_value = result["titulaire_nom"][0]
|
||||||
|
id_value = result["titulaire_id"][0]
|
||||||
|
|
||||||
|
# titulaire_nom should contain detail link + observatoire link
|
||||||
|
assert "/titulaires/t1" in nom_value
|
||||||
|
assert "TITULAIRE 1" in nom_value
|
||||||
|
assert '/observatoire?titulaire_id=t1' in nom_value
|
||||||
|
assert "📊" in nom_value
|
||||||
|
|
||||||
|
# titulaire_id should NOT contain observatoire link
|
||||||
|
assert "/observatoire" not in id_value
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
|
||||||
|
Expected: FAIL — `'/observatoire?titulaire_id=t1'` not found.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the observatoire link for titulaire_nom**
|
||||||
|
|
||||||
|
In `src/utils.py`, modify the `if col.startswith("titulaire_")` block (lines 64-81). The `.then()` branch must build the link differently when `col == "titulaire_nom"`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if col.startswith("titulaire_"):
|
||||||
|
detail_link = (
|
||||||
|
'<a href = "/titulaires/'
|
||||||
|
+ pl.col("titulaire_id")
|
||||||
|
+ '">'
|
||||||
|
+ pl.col(col)
|
||||||
|
+ "</a>"
|
||||||
|
)
|
||||||
|
if col == "titulaire_nom":
|
||||||
|
detail_link = (
|
||||||
|
detail_link
|
||||||
|
+ ' <a href="/observatoire?titulaire_id='
|
||||||
|
+ pl.col("titulaire_id")
|
||||||
|
+ '" title="Voir dans l\'observatoire">📊</a>'
|
||||||
|
)
|
||||||
|
dff = dff.with_columns(
|
||||||
|
pl.when(
|
||||||
|
pl.Expr.or_(
|
||||||
|
pl.col("titulaire_typeIdentifiant").is_null(),
|
||||||
|
pl.col("titulaire_typeIdentifiant") == "SIRET",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(detail_link)
|
||||||
|
.otherwise(pl.col(col))
|
||||||
|
.alias(col)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all tests so far to check for regressions**
|
||||||
|
|
||||||
|
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
|
||||||
|
Expected: both PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/utils.py tests/test_main.py
|
||||||
|
git commit -m "Ajout du lien observatoire dans titulaire_nom via add_links() #65"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Observatoire Callback A — URL → Inputs (page load)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/pages/observatoire.py` (add import + new callback after line 281)
|
||||||
|
- Test: `tests/test_main.py`
|
||||||
|
|
||||||
|
**Context:** The existing `dcc.Location(id="dashboard_url")` is in the observatoire layout. A new callback reads `dashboard_url.search` on page load, parses query params, and sets `dashboard_acheteur_id.value` and/or `dashboard_titulaire_id.value`. It also clears `dashboard_url.search` to `""` to prevent re-triggering. Two imports must be added: `import urllib.parse` at the top of the file, and `no_update` to the existing `from dash import ...` line (currently: `from dash import ALL, Input, Output, State, callback, ctx, dcc, html, register_page` — add `no_update` to this).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write a Selenium test for URL → Input sync**
|
||||||
|
|
||||||
|
This test navigates to `/observatoire?acheteur_id=a1` and verifies the SIRET input gets populated.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_006_observatoire_url_to_input(dash_duo: DashComposite):
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
dash_duo.start_server(app)
|
||||||
|
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||||
|
|
||||||
|
# Navigate to observatoire with acheteur_id query param
|
||||||
|
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=a1")
|
||||||
|
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||||
|
|
||||||
|
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||||
|
dash_duo.wait_for_text_to_equal(
|
||||||
|
"#dashboard_acheteur_id", "", timeout=4
|
||||||
|
) # Wait for callback
|
||||||
|
import time
|
||||||
|
time.sleep(1) # Allow callback chain to complete
|
||||||
|
|
||||||
|
assert acheteur_input.get_attribute("value") == "a1", (
|
||||||
|
"acheteur_id input should be populated from URL param"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_006_observatoire_url_to_input -v`
|
||||||
|
Expected: FAIL — the input value is empty because no callback reads URL params yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement Callback A**
|
||||||
|
|
||||||
|
Add `import urllib.parse` to the imports at the top of `src/pages/observatoire.py` (after line 1). Also add `no_update` to the existing dash import line:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dash import ALL, Input, Output, State, callback, ctx, dcc, html, no_update, register_page
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the callback after the `layout` list ends, before existing callbacks:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@callback(
|
||||||
|
Output("dashboard_acheteur_id", "value"),
|
||||||
|
Output("dashboard_titulaire_id", "value"),
|
||||||
|
Output("dashboard_url", "search"),
|
||||||
|
Input("dashboard_url", "search"),
|
||||||
|
)
|
||||||
|
def restore_filters_from_url(search):
|
||||||
|
if not search:
|
||||||
|
return no_update, no_update, no_update
|
||||||
|
|
||||||
|
params = urllib.parse.parse_qs(search.lstrip("?"))
|
||||||
|
|
||||||
|
acheteur_id = params.get("acheteur_id", [None])[0] or no_update
|
||||||
|
titulaire_id = params.get("titulaire_id", [None])[0] or no_update
|
||||||
|
|
||||||
|
return acheteur_id, titulaire_id, ""
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_006_observatoire_url_to_input -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/observatoire.py tests/test_main.py
|
||||||
|
git commit -m "Callback URL → filtres sur la page observatoire #65"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Observatoire Callback B — Inputs → shareable URL + layout
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/pages/observatoire.py` (add layout components + new callback)
|
||||||
|
- Test: `tests/test_main.py`
|
||||||
|
|
||||||
|
**Context:** Following the tableau.py pattern (lines 237-238 for layout, lines 399-450 for callback), add a hidden `share-url` input and a `copy-container` div to the observatoire layout. The callback listens to the ID inputs and builds a shareable URL. Component IDs must be unique across the app, so use `observatoire-share-url` and `observatoire-copy-container` to avoid collisions with tableau's `share-url` and `copy-container`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write a test for the shareable URL generation**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_007_observatoire_share_url(dash_duo: DashComposite):
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
dash_duo.start_server(app)
|
||||||
|
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||||
|
|
||||||
|
# Navigate to observatoire with acheteur_id query param
|
||||||
|
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=a1")
|
||||||
|
dash_duo.wait_for_element("#observatoire-share-url", timeout=4)
|
||||||
|
|
||||||
|
import time
|
||||||
|
time.sleep(1) # Allow callback chain to complete
|
||||||
|
|
||||||
|
share_url_input = dash_duo.find_element("#observatoire-share-url")
|
||||||
|
share_url_value = share_url_input.get_attribute("value")
|
||||||
|
|
||||||
|
assert "acheteur_id=a1" in share_url_value, (
|
||||||
|
f"Share URL should contain acheteur_id param, got: {share_url_value}"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_007_observatoire_share_url -v`
|
||||||
|
Expected: FAIL — `#observatoire-share-url` element does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add layout components to observatoire**
|
||||||
|
|
||||||
|
In `src/pages/observatoire.py`, add the share URL input and copy container inside the filters column (after the download button, before the closing `]` of the `id="filters"` children list, around line 264):
|
||||||
|
|
||||||
|
```python
|
||||||
|
dcc.Input(
|
||||||
|
id="observatoire-share-url",
|
||||||
|
readOnly=True,
|
||||||
|
style={"display": "none"},
|
||||||
|
),
|
||||||
|
html.Div(id="observatoire-copy-container"),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement Callback B**
|
||||||
|
|
||||||
|
Add after Callback A in `src/pages/observatoire.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@callback(
|
||||||
|
Output("observatoire-share-url", "value"),
|
||||||
|
Output("observatoire-copy-container", "children"),
|
||||||
|
Input("dashboard_acheteur_id", "value"),
|
||||||
|
Input("dashboard_titulaire_id", "value"),
|
||||||
|
State("dashboard_url", "href"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def sync_observatoire_share_url(acheteur_id, titulaire_id, href):
|
||||||
|
if not href:
|
||||||
|
return no_update, no_update
|
||||||
|
|
||||||
|
base_url = href.split("?")[0]
|
||||||
|
|
||||||
|
params = {}
|
||||||
|
if acheteur_id:
|
||||||
|
params["acheteur_id"] = acheteur_id
|
||||||
|
if titulaire_id:
|
||||||
|
params["titulaire_id"] = titulaire_id
|
||||||
|
|
||||||
|
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-observatoire-url",
|
||||||
|
target_id="observatoire-share-url",
|
||||||
|
title="Copier l'URL de cette vue",
|
||||||
|
style={
|
||||||
|
"display": "inline-block",
|
||||||
|
"fontSize": 20,
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"cursor": "pointer",
|
||||||
|
},
|
||||||
|
className="fa fa-link",
|
||||||
|
children=[
|
||||||
|
dbc.Button(
|
||||||
|
"Partager",
|
||||||
|
className="btn btn-primary mt-2",
|
||||||
|
title="Copier l'adresse de cette vue filtrée pour la partager.",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return full_url, copy_button
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_007_observatoire_share_url -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run all tests to check for regressions**
|
||||||
|
|
||||||
|
Run: `source .venv/bin/activate && pytest tests/test_main.py -v`
|
||||||
|
Expected: all tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/observatoire.py tests/test_main.py
|
||||||
|
git commit -m "URL partageable pour la page observatoire #65"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: End-to-end integration test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Test: `tests/test_main.py`
|
||||||
|
|
||||||
|
**Context:** Verify the full flow: search for an organization on the homepage, see the 📊 link in results, click it, arrive on the observatoire with the correct input populated.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write end-to-end test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_008_search_to_observatoire(dash_duo: DashComposite):
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
dash_duo.start_server(app)
|
||||||
|
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||||
|
|
||||||
|
# Search for an acheteur
|
||||||
|
search_bar = dash_duo.find_element("#search")
|
||||||
|
search_bar.send_keys("ACHETEUR 1")
|
||||||
|
search_bar.send_keys(Keys.ENTER)
|
||||||
|
|
||||||
|
dash_duo.wait_for_element("#results_acheteur_datatable", timeout=2)
|
||||||
|
|
||||||
|
# Find the observatoire link in acheteur_nom column
|
||||||
|
observatoire_link = dash_duo.find_element(
|
||||||
|
'#results_acheteur_datatable td[data-dash-column="acheteur_nom"] a[href*="observatoire"]'
|
||||||
|
)
|
||||||
|
assert "📊" in observatoire_link.text
|
||||||
|
|
||||||
|
# Click the observatoire link
|
||||||
|
observatoire_link.click()
|
||||||
|
|
||||||
|
# Wait for observatoire page to load
|
||||||
|
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||||
|
|
||||||
|
import time
|
||||||
|
time.sleep(1) # Allow callback chain to complete
|
||||||
|
|
||||||
|
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||||
|
assert acheteur_input.get_attribute("value") == "a1", (
|
||||||
|
"acheteur_id input should be populated after navigating from search"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run end-to-end test**
|
||||||
|
|
||||||
|
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_008_search_to_observatoire -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the full test suite**
|
||||||
|
|
||||||
|
Run: `source .venv/bin/activate && pytest tests/test_main.py -v`
|
||||||
|
Expected: all tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/test_main.py
|
||||||
|
git commit -m "Test e2e : recherche → observatoire #65"
|
||||||
|
```
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Distance Histogram — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-03-18
|
||||||
|
**Branch:** feature/65_observatoire
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Display the distribution of distances (in km) between buyers and winning contractors, to help users assess whether a buyer or contractor tends to deal locally or at a national scale.
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
- Column: `titulaire_distance` (`Int64`, km)
|
||||||
|
- Measured at address level — values are always > 0, no zero-handling needed
|
||||||
|
- Already selected in the observatoire LazyFrame via `cs.starts_with("titulaire")`
|
||||||
|
- Already available on acheteur and titulaire detail pages
|
||||||
|
|
||||||
|
## Figure Function
|
||||||
|
|
||||||
|
**Location:** `src/figures.py`
|
||||||
|
|
||||||
|
**Signature:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_distance_histogram(lff: pl.LazyFrame) -> dcc.Graph:
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behaviour:**
|
||||||
|
|
||||||
|
- Collects `titulaire_distance` from the LazyFrame, drops nulls
|
||||||
|
- If the resulting DataFrame is empty after dropping nulls, `px.histogram` produces a blank figure without errors — no guard logic needed. The order of operations must be: drop nulls → log-transform → histogram
|
||||||
|
- Drop nulls first, then pre-log-transform the column (`pl.col("titulaire_distance").log(10)`) so bins are truly equal-width on a log scale. Use `px.histogram` with `nbins=50` on the transformed values
|
||||||
|
- Set custom X-axis tick values at powers of 10 (1, 10, 100, 1000, 10000) with km labels, using `fig.update_xaxes(tickvals=[0,1,2,3,4], ticktext=["1","10","100","1 000","10 000"])`
|
||||||
|
- Y axis: count of contracts
|
||||||
|
- French axis labels: x = `"Distance (km)"`, y = `"Nombre de marchés"`
|
||||||
|
- Returns a `dcc.Graph`
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
### Observatoire (`src/pages/observatoire.py`)
|
||||||
|
|
||||||
|
- `get_distance_histogram` imported and called inside `udpate_dashboard_cards`
|
||||||
|
- Result wrapped in `make_card(title="Distance acheteur–titulaire", subtitle="en nombre de marchés, échelle logarithmique", fig=...)`
|
||||||
|
- Card appended to the `cards` list alongside existing donuts and charts
|
||||||
|
- No changes to the data pipeline — `titulaire_distance` is already in the LazyFrame
|
||||||
|
|
||||||
|
### Acheteur page (`src/pages/acheteur.py`)
|
||||||
|
|
||||||
|
The acheteur page uses a `dcc.Store` (`acheteur_data`) that holds serialised contract rows as a list of dicts. The integration follows the existing pattern used by other chart callbacks on this page:
|
||||||
|
|
||||||
|
- Add a new `html.Div(id="acheteur-distance-histogram")` placeholder in the layout
|
||||||
|
- Add a new callback with `Input("acheteur_data", "data")` that:
|
||||||
|
- Reconstructs `pl.LazyFrame(data)` from the store
|
||||||
|
- Calls `get_distance_histogram(lff)`
|
||||||
|
- Wraps the result in `make_card(...)` and returns it to the placeholder div
|
||||||
|
|
||||||
|
### Titulaire page (`src/pages/titulaire.py`)
|
||||||
|
|
||||||
|
Same pattern as acheteur: `dcc.Store` (`titulaire_data`) → new callback → `html.Div` placeholder.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Filtering by distance range (could be a future filter on the observatoire page)
|
||||||
|
- Showing distance on a map or as a trend over time
|
||||||
|
- Bucket-based (named zone) grouping
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Observatoire Link from Search & Tableau Results
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Users searching for an organization (acheteur or titulaire) on the search page or browsing the tableau cannot jump directly to the observatoire page filtered for that organization. They must manually navigate and re-enter the identifier.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Extend `add_links()` in `src/utils.py` to append an observatoire link (📊 emoji) to `_nom` columns, and add bidirectional URL parameter sync to the observatoire page.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. `src/utils.py` — `add_links()` modification
|
||||||
|
|
||||||
|
The existing `add_links()` loop iterates over `["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]`. The `if col.startswith("acheteur_")` and `if col.startswith("titulaire_")` blocks match both `_nom` and `_id` columns. The observatoire link must only be appended to `_nom` columns, so it must be gated on `col == "acheteur_nom"` or `col == "titulaire_nom"` explicitly.
|
||||||
|
|
||||||
|
For `acheteur_nom`, append an observatoire link after the existing detail page link:
|
||||||
|
|
||||||
|
```
|
||||||
|
Before: <a href="/acheteurs/12345678901234">Ville de Paris</a>
|
||||||
|
After: <a href="/acheteurs/12345678901234">Ville de Paris</a> <a href="/observatoire?acheteur_id=12345678901234" title="Voir dans l'observatoire">📊</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
For `titulaire_nom`, same pattern but only when the existing `typeIdentifiant` guard passes (SIRET or null):
|
||||||
|
|
||||||
|
```
|
||||||
|
Before: <a href="/titulaires/12345678901234">Entreprise X</a>
|
||||||
|
After: <a href="/titulaires/12345678901234">Entreprise X</a> <a href="/observatoire?titulaire_id=12345678901234" title="Voir dans l'observatoire">📊</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
The identifier used in the observatoire link (`acheteur_id` / `titulaire_id`) is the same `pl.col("acheteur_id")` / `pl.col("titulaire_id")` column value already used for the detail page link.
|
||||||
|
|
||||||
|
The `_id` and `uid` columns are unchanged.
|
||||||
|
|
||||||
|
### 2. `src/pages/observatoire.py` — URL parameter handling
|
||||||
|
|
||||||
|
#### Callback A: URL → Inputs (page load)
|
||||||
|
|
||||||
|
- Trigger: `Input("dashboard_url", "search")`
|
||||||
|
- Outputs: `Output("dashboard_acheteur_id", "value")`, `Output("dashboard_titulaire_id", "value")`, `Output("dashboard_url", "search")` (to clear it)
|
||||||
|
- `prevent_initial_call=False` (must fire on page load to read URL params)
|
||||||
|
- If `search` is empty or None: return `no_update` for all outputs
|
||||||
|
- Otherwise: parse query params with `urllib.parse.parse_qs`
|
||||||
|
- Set `dashboard_acheteur_id` from `?acheteur_id=` param, or `no_update` if absent
|
||||||
|
- Set `dashboard_titulaire_id` from `?titulaire_id=` param, or `no_update` if absent
|
||||||
|
- Return `""` for `dashboard_url.search` to clear the URL and prevent re-triggering
|
||||||
|
- No validation of param values — consistent with existing input handling in the observatoire callbacks
|
||||||
|
|
||||||
|
#### Callback B: Inputs → shareable URL
|
||||||
|
|
||||||
|
- Trigger: `Input("dashboard_acheteur_id", "value")`, `Input("dashboard_titulaire_id", "value")`
|
||||||
|
- State: `State("dashboard_url", "href")` for base URL
|
||||||
|
- `prevent_initial_call=True` (avoid generating URL on initial empty state)
|
||||||
|
- Build query string with `urllib.parse.urlencode`, omitting empty values
|
||||||
|
- Write full URL to a new `share-url` input component
|
||||||
|
- Render a `dcc.Clipboard` + share button (same pattern as tableau.py)
|
||||||
|
|
||||||
|
#### Callback chain
|
||||||
|
|
||||||
|
When navigating from search with `?acheteur_id=123`: Callback A fires on page load, sets input values, clears URL search. The input value changes then trigger both the existing `udpate_dashboard_cards` callback and Callback B. Dash handles this chaining deterministically — no race condition.
|
||||||
|
|
||||||
|
#### Layout additions
|
||||||
|
|
||||||
|
- A `dcc.Input(id="share-url", ...)` (hidden or read-only) to hold the shareable URL
|
||||||
|
- A `dcc.Clipboard` share/copy button near the filters
|
||||||
|
|
||||||
|
### 3. Reuse of existing `dcc.Location`
|
||||||
|
|
||||||
|
The existing `dcc.Location(id="dashboard_url")` component is reused — no new Location component needed.
|
||||||
|
|
||||||
|
## Future extension
|
||||||
|
|
||||||
|
The bidirectional URL sync pattern is designed to extend to all observatoire filters (year, categories, departments, market type, etc.) by adding more params to both callbacks.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
- `src/utils.py` — modify `add_links()`
|
||||||
|
- `src/pages/observatoire.py` — add 2 callbacks, add share-url + clipboard to layout
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Observatoire: Full URL Sharing for All Filters
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The "Partager" button on `/observatoire` currently only encodes `acheteur_id` and `titulaire_id` in the shareable URL. The other 15 filter parameters are lost, so a shared link does not reproduce the sender's filtered view.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Extend URL sharing so that **all 17 filter parameters** are encoded in the URL and restored when a recipient opens it. The recipient sees exactly what the sender intended — URL params replace all local filter state.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Flat query parameters with short, readable keys. Multi-value filters use repeated keys (native to `urllib.parse`). Only non-default values appear in the URL.
|
||||||
|
|
||||||
|
## URL Parameter Mapping
|
||||||
|
|
||||||
|
| Component ID | URL key | Type | Default (omitted) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `dashboard_year` | `annee` | single | `None` |
|
||||||
|
| `dashboard_acheteur_id` | `acheteur_id` | single | `None` |
|
||||||
|
| `dashboard_acheteur_categorie` | `acheteur_cat` | single | `None` |
|
||||||
|
| `dashboard_acheteur_departement_code` | `acheteur_dept` | multi | `[]`/`None` |
|
||||||
|
| `dashboard_titulaire_id` | `titulaire_id` | single | `None` |
|
||||||
|
| `dashboard_titulaire_categorie` | `titulaire_cat` | single | `None` |
|
||||||
|
| `dashboard_titulaire_departement_code` | `titulaire_dept` | multi | `[]`/`None` |
|
||||||
|
| `dashboard_marche_type` | `type` | single | `None` |
|
||||||
|
| `dashboard_marche_objet` | `objet` | single | `None` |
|
||||||
|
| `dashboard_marche_code_cpv` | `cpv` | single | `None` |
|
||||||
|
| `dashboard_montant_min` | `montant_min` | single (number) | `None` |
|
||||||
|
| `dashboard_montant_max` | `montant_max` | single (number) | `None` |
|
||||||
|
| `dashboard_marche_techniques` | `techniques` | multi | `[]`/`None` |
|
||||||
|
| `dashboard_marche_innovant` | `innovant` | single | `"all"` |
|
||||||
|
| `dashboard_marche_sousTraitanceDeclaree` | `sous_traitance` | single | `"all"` |
|
||||||
|
| `dashboard_marche_considerationsSociales` | `social` | multi | `[]`/`None` |
|
||||||
|
| `dashboard_marche_considerationsEnvironnementales` | `env` | multi | `[]`/`None` |
|
||||||
|
|
||||||
|
Example URL:
|
||||||
|
```
|
||||||
|
/observatoire?annee=2024&acheteur_id=12345678901234&acheteur_dept=75&acheteur_dept=13&montant_min=10000&innovant=oui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Structure
|
||||||
|
|
||||||
|
A list of tuples defines the mapping, used by both callbacks to avoid scattered string literals:
|
||||||
|
|
||||||
|
```python
|
||||||
|
FILTER_PARAMS = [
|
||||||
|
# (component_id, url_key, is_multi, default_value)
|
||||||
|
("dashboard_year", "annee", False, None),
|
||||||
|
("dashboard_acheteur_id", "acheteur_id", False, None),
|
||||||
|
("dashboard_acheteur_categorie", "acheteur_cat", False, None),
|
||||||
|
("dashboard_acheteur_departement_code", "acheteur_dept", True, None),
|
||||||
|
("dashboard_titulaire_id", "titulaire_id", False, None),
|
||||||
|
("dashboard_titulaire_categorie", "titulaire_cat", False, None),
|
||||||
|
("dashboard_titulaire_departement_code", "titulaire_dept", True, None),
|
||||||
|
("dashboard_marche_type", "type", False, None),
|
||||||
|
("dashboard_marche_objet", "objet", False, None),
|
||||||
|
("dashboard_marche_code_cpv", "cpv", False, None),
|
||||||
|
("dashboard_montant_min", "montant_min", False, None),
|
||||||
|
("dashboard_montant_max", "montant_max", False, None),
|
||||||
|
("dashboard_marche_techniques", "techniques", True, None),
|
||||||
|
("dashboard_marche_innovant", "innovant", False, "all"),
|
||||||
|
("dashboard_marche_sousTraitanceDeclaree", "sous_traitance", False, "all"),
|
||||||
|
("dashboard_marche_considerationsSociales", "social", True, None),
|
||||||
|
("dashboard_marche_considerationsEnvironnementales", "env", True, None),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Callback Changes
|
||||||
|
|
||||||
|
### 1. `sync_observatoire_share_url` (line 575)
|
||||||
|
|
||||||
|
**Current:** Takes `acheteur_id` and `titulaire_id` as Inputs.
|
||||||
|
|
||||||
|
**New:** Takes all 17 filter values as Inputs (same as `udpate_dashboard_cards`). Builds the URL using `FILTER_PARAMS`, skipping default values. Uses `urllib.parse.urlencode(params, doseq=True)` for multi-value params.
|
||||||
|
|
||||||
|
### 2. `restore_filters` (line 539)
|
||||||
|
|
||||||
|
**Current:** Extracts only `acheteur_id` and `titulaire_id` from URL.
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
- Iterates over `FILTER_PARAMS` to extract all values from `parse_qs`
|
||||||
|
- For multi-value params: reads the full list from `parse_qs` (returns lists natively)
|
||||||
|
- For number params (`montant_min`, `montant_max`): casts to `float`
|
||||||
|
- The guard condition changes from `if acheteur_id or titulaire_id` to "if any URL param is present" — this is necessary so URLs like `?annee=2024&montant_min=10000` (without an ID) work correctly
|
||||||
|
- When **any** URL param is present: returns explicit values for all 17 outputs — the URL value for params present, `None`/default for params absent. This ensures "URL replaces all" semantics.
|
||||||
|
- When **no** URL params are present: returns `(no_update,) * 17` (preserving local persistence)
|
||||||
|
- Radio buttons (`innovant`, `sous_traitance`): value from URL if present, otherwise `"all"` (their default)
|
||||||
|
|
||||||
|
### 3. Layout bug fix
|
||||||
|
|
||||||
|
Remove the duplicate `dcc.Input(id="observatoire-share-url")` (lines 413-422 — two identical elements).
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
Old URLs with only `?acheteur_id=...` or `?titulaire_id=...` continue to work — the new `restore_filters` will read those keys and reset all others to defaults, which is the same effective behavior as before.
|
||||||
|
|
||||||
|
Links generated by `add_links()` in `src/utils.py` (used on search results to link to `/observatoire?acheteur_id=...`) are unaffected.
|
||||||
|
|
||||||
|
## Test Changes
|
||||||
|
|
||||||
|
### Fix broken test `test_010_observatoire_montant_filter`
|
||||||
|
|
||||||
|
This test imports `_apply_filters` from `pages.observatoire`, which no longer exists (replaced by `prepare_dashboard_data` in `src/utils.py`). Fix:
|
||||||
|
- Replace import with `from src.utils import prepare_dashboard_data`
|
||||||
|
- Update the call to match `prepare_dashboard_data`'s signature: rename `marche_type` keyword to `type`, and add missing params `objet`, `code_cpv`, `techniques`, `marche_innovant`, `sous_traitance_declaree` (all as `None`)
|
||||||
|
|
||||||
|
### New test: multi-param URL round-trip
|
||||||
|
|
||||||
|
Add a test that navigates to `/observatoire?annee=2024&acheteur_id=<test_id>&montant_min=10000` and verifies that:
|
||||||
|
- `dashboard_year` dropdown shows "2024"
|
||||||
|
- `dashboard_acheteur_id` input contains the test ID
|
||||||
|
- `dashboard_montant_min` input contains "10000"
|
||||||
|
|
||||||
|
### Update existing tests
|
||||||
|
|
||||||
|
Tests `test_006` and `test_007` validate `acheteur_id` round-trip. These should continue to pass without changes since `acheteur_id` keeps the same URL key.
|
||||||
+29
-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.7.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.4.0",
|
||||||
"dash[compress]",
|
"dash[compress]",
|
||||||
"polars",
|
"polars",
|
||||||
"gunicorn",
|
"gunicorn",
|
||||||
@@ -15,9 +15,34 @@ dependencies = [
|
|||||||
"python-dotenv",
|
"python-dotenv",
|
||||||
"xlsxwriter",
|
"xlsxwriter",
|
||||||
"plotly[express]",
|
"plotly[express]",
|
||||||
|
"httpx",
|
||||||
|
"pandas", # utilisé pour la création de certains graphiques
|
||||||
|
"unidecode",
|
||||||
|
"dash-leaflet",
|
||||||
|
"dash-extensions"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
"pytest",
|
||||||
|
"pytest-env",
|
||||||
"pre-commit",
|
"pre-commit",
|
||||||
|
"selenium",
|
||||||
|
"webdriver-manager",
|
||||||
|
"dash[testing]",
|
||||||
|
"fastexcel"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
testpaths = [
|
||||||
|
"tests"
|
||||||
|
]
|
||||||
|
env = [
|
||||||
|
"DATA_FILE_PARQUET_PATH=tests/test.parquet",
|
||||||
|
"DEVELOPMENT=true",
|
||||||
|
"DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json"
|
||||||
|
]
|
||||||
|
addopts = "-p no:warnings"
|
||||||
|
|||||||
+133
-29
@@ -1,15 +1,38 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
from dash import Dash, dcc, html, page_container, page_registry
|
import tomllib
|
||||||
from flask import send_from_directory
|
from dash import Dash, Input, Output, State, dcc, html, page_container, page_registry
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from flask import Response
|
||||||
|
|
||||||
app = Dash(
|
load_dotenv()
|
||||||
external_stylesheets=[dbc.themes.SIMPLEX],
|
|
||||||
|
# if os.getenv("PYTEST_CURRENT_TEST"):
|
||||||
|
# os.environ["DATA_FILE_PARQUET_PATH"]
|
||||||
|
|
||||||
|
|
||||||
|
development = os.getenv("DEVELOPMENT").lower() == "true"
|
||||||
|
|
||||||
|
meta_tags = [
|
||||||
|
{"name": "viewport", "content": "width=device-width, initial-scale=1"},
|
||||||
|
{
|
||||||
|
"name": "keywords",
|
||||||
|
"content": "commande publique, decp, marchés publics, données essentielles",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if development:
|
||||||
|
meta_tags.append({"name": "robots", "content": "noindex"})
|
||||||
|
|
||||||
|
app: Dash = Dash(
|
||||||
title="decp.info",
|
title="decp.info",
|
||||||
use_pages=True,
|
use_pages=True,
|
||||||
compress=True,
|
compress=True,
|
||||||
|
meta_tags=meta_tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
# COSMO (belle font, blue),
|
# COSMO (belle font, blue),
|
||||||
# UNITED (rouge, ubuntu font),
|
# UNITED (rouge, ubuntu font),
|
||||||
# LUMEN (gros séparateur, blue clair),
|
# LUMEN (gros séparateur, blue clair),
|
||||||
@@ -19,7 +42,29 @@ app = Dash(
|
|||||||
# robots.txt
|
# robots.txt
|
||||||
@app.server.route("/robots.txt")
|
@app.server.route("/robots.txt")
|
||||||
def robots():
|
def robots():
|
||||||
return send_from_directory("./assets", "robots.txt", mimetype="text/plain")
|
text = """User-agent: *
|
||||||
|
Allow: /
|
||||||
|
"""
|
||||||
|
return Response(text, mimetype="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
@app.server.route("/sitemap.xml")
|
||||||
|
def sitemap():
|
||||||
|
base_url = "https://decp.info"
|
||||||
|
pages = [
|
||||||
|
"/",
|
||||||
|
"/observatoire",
|
||||||
|
"/tableau",
|
||||||
|
"/a-propos",
|
||||||
|
]
|
||||||
|
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||||
|
for page in pages:
|
||||||
|
xml += " <url>\n"
|
||||||
|
xml += f" <loc>{base_url}{page}</loc>\n"
|
||||||
|
xml += " </url>\n"
|
||||||
|
xml += "</urlset>"
|
||||||
|
return Response(xml, mimetype="text/xml")
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("decp.info")
|
logger = logging.getLogger("decp.info")
|
||||||
@@ -29,14 +74,20 @@ 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>
|
||||||
{%favicon%}
|
{%favicon%}
|
||||||
{%css%}
|
{%css%}
|
||||||
|
<!-- canonical link -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{%app_entry%}
|
{%app_entry%}
|
||||||
@@ -63,34 +114,87 @@ app.index_string = """
|
|||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
app.layout = html.Div(
|
navbar = dbc.Navbar(
|
||||||
[
|
dbc.Container(
|
||||||
html.Div(
|
fluid=True,
|
||||||
[
|
children=[
|
||||||
html.H1("decp.info"),
|
dbc.NavItem(
|
||||||
html.Div(
|
children=[
|
||||||
|
html.Div(
|
||||||
|
[
|
||||||
|
dcc.Link(html.H1("decp.info"), href="/", className="logo"),
|
||||||
|
html.P(
|
||||||
|
[
|
||||||
|
html.A(
|
||||||
|
version,
|
||||||
|
href="https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
className="version",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
className="logo-wrapper",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
style={"minWidth": "230px"},
|
||||||
|
),
|
||||||
|
dbc.Nav(
|
||||||
|
children=[dcc.Markdown(os.getenv("ANNOUNCEMENTS"), id="announcements")],
|
||||||
|
style={
|
||||||
|
"maxWidth": "1200px",
|
||||||
|
"display": "inline-block",
|
||||||
|
},
|
||||||
|
navbar=True,
|
||||||
|
id="announcements-nav",
|
||||||
|
),
|
||||||
|
dbc.NavbarToggler(id="navbar-toggler"),
|
||||||
|
dbc.Collapse(
|
||||||
|
dbc.Nav(
|
||||||
[
|
[
|
||||||
dcc.Link(
|
dbc.NavItem(
|
||||||
page["name"], href=page["relative_path"], className="nav"
|
dbc.NavLink(
|
||||||
|
page["name"].replace(" ", " "),
|
||||||
|
href=page["relative_path"],
|
||||||
|
active="exact",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for page in page_registry.values()
|
for page in page_registry.values()
|
||||||
]
|
if page["name"]
|
||||||
|
in ["Recherche", "À propos", "Tableau", "Observatoire"]
|
||||||
|
],
|
||||||
|
className="ms-auto",
|
||||||
|
navbar=True,
|
||||||
),
|
),
|
||||||
],
|
id="navbar-collapse",
|
||||||
className="navbar",
|
navbar=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
color="light",
|
||||||
|
dark=False,
|
||||||
|
className="mb-4",
|
||||||
|
expand="lg",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.layout = html.Div(
|
||||||
|
[
|
||||||
|
navbar,
|
||||||
|
dbc.Container(
|
||||||
|
page_container,
|
||||||
|
fluid=True,
|
||||||
|
id="page-content-container",
|
||||||
|
className="mb-4",
|
||||||
),
|
),
|
||||||
page_container,
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
# @callback(
|
|
||||||
# Output(component_id="table", component_property="data", allow_duplicate=True),
|
|
||||||
# Input(component_id="search", component_property="value"),
|
|
||||||
# prevent_initial_call=True,
|
|
||||||
# )
|
|
||||||
# def global_search(text):
|
|
||||||
# new_df = df
|
|
||||||
# new_df = new_df.filter(pl.col("objet").str.contains("(?i)" + text))
|
|
||||||
# return new_df.to_dicts()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app.run(debug=True)
|
@app.callback(
|
||||||
|
Output("navbar-collapse", "is_open"),
|
||||||
|
[Input("navbar-toggler", "n_clicks")],
|
||||||
|
[State("navbar-collapse", "is_open")],
|
||||||
|
)
|
||||||
|
def toggle_navbar_collapse(n, is_open):
|
||||||
|
if n:
|
||||||
|
return not is_open
|
||||||
|
return is_open
|
||||||
|
|||||||
@@ -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 |
Vendored
+11859
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,544 @@
|
|||||||
|
@import url(https://fonts.bunny.net/css?family=fira-code:400|inter:400,600);
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Variables
|
||||||
|
========================================================================== */
|
||||||
|
:root {
|
||||||
|
--bs-font-monospace: "Fira Code";
|
||||||
|
--primary-color: rgb(179, 56, 33);
|
||||||
|
--primary-color-text: #b33821;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Base & Reset
|
||||||
|
========================================================================== */
|
||||||
|
body {
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
background-color: rgb(255 240 240 / 40%);
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
font-smooth: always;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong,
|
||||||
|
b {
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5 {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 36px 0 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base Button Styles
|
||||||
|
button {
|
||||||
|
font-weight: 400;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
appearance: auto;
|
||||||
|
border: solid var(--primary-color) 1px;
|
||||||
|
} */
|
||||||
|
|
||||||
|
button.btn.btn-primary,
|
||||||
|
button.show-hide {
|
||||||
|
display: block;
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: 0;
|
||||||
|
color: #fff;
|
||||||
|
border: 0;
|
||||||
|
height: 30px;
|
||||||
|
padding-top: 2px;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
rgb(209, 96, 73),
|
||||||
|
rgb(179, 56, 33) 26%,
|
||||||
|
rgb(159, 36, 22)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.btn.btn-primary:hover,
|
||||||
|
button.show-hide:hover {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
rgb(239, 126, 103),
|
||||||
|
rgb(209, 86, 63) 26%,
|
||||||
|
rgb(189, 66, 52)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[disabled] {
|
||||||
|
border-color: #ccc;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not([disabled]) {
|
||||||
|
background-color: #fee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global Link Styles */
|
||||||
|
#_pages_content a {
|
||||||
|
color: #993333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Layout
|
||||||
|
========================================================================== */
|
||||||
|
#_pages_content {
|
||||||
|
padding: 28px 24px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#header > * {
|
||||||
|
margin: 0 0 20px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Components
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* --- Navigation & Header --- */
|
||||||
|
a.logo {
|
||||||
|
color: black;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.logo > h1 {
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.version {
|
||||||
|
margin: 0 0 0 12px;
|
||||||
|
font-family: "Fira Code";
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.version > a {
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link.active {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#announcements {
|
||||||
|
margin: 25px 40px 0 60px;
|
||||||
|
font-size: 90%;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seeBorder {
|
||||||
|
border: dotted 1px green;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Search Page --- */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Dashboard inputs --- */
|
||||||
|
|
||||||
|
.Select--multi .Select-value {
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
background-color: rgba(255, 240, 240, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filters .row > * {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filters input[type="text"],
|
||||||
|
#filters input[type="number"] {
|
||||||
|
border: 1px #ccc solid;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tables (Dash & Custom) --- */
|
||||||
|
|
||||||
|
/* Table Menu (Exports etc) */
|
||||||
|
.table-menu {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 12px 0 12px 0;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-menu > * {
|
||||||
|
margin: 8px 16px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#source_table {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#source_table p {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dash Table Overrides */
|
||||||
|
.column-header--sort {
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dash-table-container dash-spreadsheet-menu table.cell-table {
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-lef: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.cell-table,
|
||||||
|
table.cell-table tr {
|
||||||
|
border-color: #fff;
|
||||||
|
padding: 0;
|
||||||
|
border-collapse: separate !important;
|
||||||
|
/* Required for border-radius */
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.cell-table th {
|
||||||
|
border-collapse: separate !important;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table-container p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
th.dash-header,
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
th.dash-select-header {
|
||||||
|
margin: 0;
|
||||||
|
color: white;
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 12px 4px 2px;
|
||||||
|
border: 1px solid rgb(179, 56, 33) !important;
|
||||||
|
background-color: rgb(179, 56, 33);
|
||||||
|
border-bottom: none !important;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
table.cell-table
|
||||||
|
tr:first-of-type
|
||||||
|
th.dash-header:first-of-type,
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
table.cell-table
|
||||||
|
tr:first-of-type
|
||||||
|
th.dash-select-header:first-of-type {
|
||||||
|
border-top-left-radius: 3px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
table.cell-table
|
||||||
|
tr:first-of-type
|
||||||
|
th.dash-header:last-of-type {
|
||||||
|
border-top-right-radius: 3px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dash Filters */
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
.cell-table
|
||||||
|
.dash-filter
|
||||||
|
input[type="text"] {
|
||||||
|
border-color: #ccc;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 3px;
|
||||||
|
height: 28px;
|
||||||
|
font-family: "Fira Code";
|
||||||
|
caret-color: #000;
|
||||||
|
background-color: rgb(250 250 250);
|
||||||
|
text-align: left !important;
|
||||||
|
padding: 1px 2px 0 2px;
|
||||||
|
vertical-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
.cell-table
|
||||||
|
.dash-filter
|
||||||
|
input[type="text"]::placeholder {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-filter--case {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
.cell-table
|
||||||
|
th.dash-filter {
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Marches Table */
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
.cell-table
|
||||||
|
td {
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
td
|
||||||
|
div.dash-cell-value.cell-markdown {
|
||||||
|
font-family: "Inter", sans-serif !important;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marches_table.stuck {
|
||||||
|
position: relative;
|
||||||
|
right: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marches_table .cell-table tr:nth-child(even) td {
|
||||||
|
background-color: rgb(255 240 240 / 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column Visibility Menu */
|
||||||
|
.column-actions {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-header--hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.show-hide {
|
||||||
|
position: relative;
|
||||||
|
width: 180px;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
.show-hide::before {
|
||||||
|
background: inherit;
|
||||||
|
content: "Colonnes affichées";
|
||||||
|
position: absolute;
|
||||||
|
left: 5px;
|
||||||
|
right: 5px;
|
||||||
|
|
||||||
|
|
||||||
|
#column_list .show-hide,
|
||||||
|
#table .show-hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-hide-menu-item > input {
|
||||||
|
margin-right: 10px;
|
||||||
|
} */
|
||||||
|
|
||||||
|
#btn-copy-url:before {
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdowns */
|
||||||
|
.Select-placeholder {
|
||||||
|
color: #333 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkboxes */
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
height: 17px;
|
||||||
|
width: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltips */
|
||||||
|
.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: "Inter", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Organization Cards (Grid Items) --- */
|
||||||
|
|
||||||
|
#cards .card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org_infos > p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- About Page (A Propos) --- */
|
||||||
|
.a-propos-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
position: relative;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-propos-content {
|
||||||
|
flex: 1 1 70%;
|
||||||
|
max-width: 75%;
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-propos-toc {
|
||||||
|
flex: 0 0 25%;
|
||||||
|
max-width: 25%;
|
||||||
|
/* Keeps it from growing too large */
|
||||||
|
position: sticky;
|
||||||
|
top: 40px;
|
||||||
|
/* Sticks 40px from the top of the viewport */
|
||||||
|
border-left: 2px solid #333;
|
||||||
|
/* Dark vertical line like hedgedoc */
|
||||||
|
padding-left: 15px;
|
||||||
|
margin-top: 40px;
|
||||||
|
background-color: #fff;
|
||||||
|
/* Aligns visually with the first header */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TOC Links */
|
||||||
|
.toc-link {
|
||||||
|
display: block;
|
||||||
|
color: #666;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 2px 0;
|
||||||
|
transition: color 0.2s, font-weight 0.2s;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-link:hover {
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-active {
|
||||||
|
color: #000;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-level-2 {
|
||||||
|
margin-left: 15px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-header {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: block;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Misc & Utility --- */
|
||||||
|
#instructions {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
details > div {
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary > h4 {
|
||||||
|
margin: 0;
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Media Queries
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
/* Navigation */
|
||||||
|
#announcements-nav {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* About Page */
|
||||||
|
.a-propos-content {
|
||||||
|
max-width: 100%;
|
||||||
|
padding-right: 0;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-propos-toc {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"]::-webkit-outer-spin-button,
|
||||||
|
input[type="number"]::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
window.dash_clientside = Object.assign({}, window.dash_clientside, {
|
||||||
|
leaflet: {
|
||||||
|
pointToLayer: function (feature, latlng, context) {
|
||||||
|
return L.circleMarker(latlng, {
|
||||||
|
radius: 5,
|
||||||
|
fillColor: feature.properties.marker_color,
|
||||||
|
color: "white",
|
||||||
|
weight: 1,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.8,
|
||||||
|
}).bindTooltip(feature.properties.tooltip);
|
||||||
|
},
|
||||||
|
clusterToLayer: function (feature, latlng, index, context) {
|
||||||
|
console.log(feature);
|
||||||
|
console.log(index);
|
||||||
|
console.log(context);
|
||||||
|
|
||||||
|
const count = feature.properties.point_count;
|
||||||
|
const size = count < 100 ? 30 : count < 1000 ? 40 : 50;
|
||||||
|
const color = "#555"; // Default cluster color
|
||||||
|
const icon = L.divIcon({
|
||||||
|
html: `<div style="background-color: ${context.fillColor}; width: ${size}px; height: ${size}px; border-radius: 50%; display: flex; align-items:center; justify-content:center; color: white; border: 2px solid white; font-weight: bold;">${count}</div>`,
|
||||||
|
className: "marker-cluster",
|
||||||
|
iconSize: L.point(size, size),
|
||||||
|
});
|
||||||
|
return L.marker(latlng, { icon: icon });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clientside: {
|
||||||
|
clean_filters: function (trigger) {
|
||||||
|
if (!trigger) {
|
||||||
|
return window.dash_clientside.no_update;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to set value on a React text input
|
||||||
|
const setNativeValue = (element, value) => {
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
element,
|
||||||
|
"value"
|
||||||
|
).set;
|
||||||
|
const prototype = Object.getPrototypeOf(element);
|
||||||
|
const prototypeValueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
prototype,
|
||||||
|
"value"
|
||||||
|
).set;
|
||||||
|
|
||||||
|
if (valueSetter && valueSetter !== prototypeValueSetter) {
|
||||||
|
prototypeValueSetter.call(element, value);
|
||||||
|
} else {
|
||||||
|
valueSetter.call(element, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanInputs = () => {
|
||||||
|
const inputs = document.querySelectorAll(
|
||||||
|
'.dash-filter input[type="text"]'
|
||||||
|
);
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
let val = input.value;
|
||||||
|
let original = val;
|
||||||
|
|
||||||
|
// Remove "icontains " prefix
|
||||||
|
if (/^icontains\s+/i.test(val)) {
|
||||||
|
val = val.replace(/^icontains\s+/i, "");
|
||||||
|
// Check for surrounding quotes (single or double) and remove them
|
||||||
|
if (
|
||||||
|
(val.startsWith('"') && val.endsWith('"')) ||
|
||||||
|
(val.startsWith("'") && val.endsWith("'"))
|
||||||
|
) {
|
||||||
|
val = val.substring(1, val.length - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle relational operators (i<, s>, i<=, etc.)
|
||||||
|
else if (/^[is][<>]=?/i.test(val)) {
|
||||||
|
val = val.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (val !== original) {
|
||||||
|
try {
|
||||||
|
// Try setting it the React-friendly way
|
||||||
|
setNativeValue(input, val);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to direct assignment if fancy way fails
|
||||||
|
input.value = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use MutationObserver to wait for table to appear/update
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
cleanInputs();
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = document.querySelector(".dash-table-container");
|
||||||
|
if (target) {
|
||||||
|
observer.observe(target, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["value"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disconnect after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Also try immediately just in case
|
||||||
|
cleanInputs();
|
||||||
|
} else {
|
||||||
|
// Poll briefly if container not found yet
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
const t = document.querySelector(".dash-table-container");
|
||||||
|
if (t) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
observer.observe(t, { childList: true, subtree: true });
|
||||||
|
setTimeout(() => observer.disconnect(), 5000);
|
||||||
|
cleanInputs();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
// Stop polling after 2s if still nothing
|
||||||
|
setTimeout(() => clearInterval(checkInterval), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.dash_clientside.no_update;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 333 KiB |
@@ -1,41 +0,0 @@
|
|||||||
# START YOAST BLOCK
|
|
||||||
# Copié depuis https://next.ink/robots.txt
|
|
||||||
# ---------------------------
|
|
||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
# ---------------------------
|
|
||||||
# END YOAST BLOCK
|
|
||||||
|
|
||||||
User-agent: GPTBot
|
|
||||||
Disallow: /
|
|
||||||
User-agent: ChatGPT-User
|
|
||||||
Disallow: /
|
|
||||||
User-agent: Google-Extended
|
|
||||||
Disallow: /
|
|
||||||
User-agent: PerplexityBot
|
|
||||||
Disallow: /
|
|
||||||
User-agent: Amazonbot
|
|
||||||
Disallow: /
|
|
||||||
User-agent: ClaudeBot
|
|
||||||
Disallow: /
|
|
||||||
User-Agent: FacebookBot
|
|
||||||
Disallow: /
|
|
||||||
User-Agent: Applebot
|
|
||||||
Disallow: /
|
|
||||||
User-agent: anthropic-ai
|
|
||||||
Disallow: /
|
|
||||||
User-agent: Bytespider
|
|
||||||
Disallow: /
|
|
||||||
User-agent: Claude-Web
|
|
||||||
Disallow: /
|
|
||||||
User-agent: Diffbot
|
|
||||||
Disallow: /
|
|
||||||
User-agent: ImagesiftBot
|
|
||||||
Disallow: /
|
|
||||||
User-agent: Omgilibot
|
|
||||||
Disallow: /
|
|
||||||
User-agent: Omgili
|
|
||||||
Disallow: /
|
|
||||||
User-agent: YouBot
|
|
||||||
Disallow: /
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
/* Change la marge bout d'export */
|
|
||||||
.table-menu {
|
|
||||||
font-size: 16px;
|
|
||||||
margin: 12px;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-menu > * {
|
|
||||||
margin: 8px;
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
#source_table p {
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
#source_table {
|
|
||||||
margin-bottom: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Réduire la taille du texte de la colonne Objet */
|
|
||||||
|
|
||||||
td[data-dash-column="objet"] {
|
|
||||||
font-size: 85%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Couleur des en-têtes */
|
|
||||||
.dash-table-container
|
|
||||||
.dash-spreadsheet-container
|
|
||||||
.dash-spreadsheet-inner
|
|
||||||
th.dash-header {
|
|
||||||
background-color: #b33821;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-table-container
|
|
||||||
.dash-spreadsheet-container
|
|
||||||
.dash-spreadsheet-inner
|
|
||||||
th.dash-filter {
|
|
||||||
background-color: #f0afa3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-table-container p {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-filter--case {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Menu de masquage des colonnes */
|
|
||||||
.column-header--hide svg {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.show-hide {
|
|
||||||
position: relative;
|
|
||||||
width: 180px;
|
|
||||||
margin: 0 0 10px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-hide::before {
|
|
||||||
background: inherit;
|
|
||||||
content: "Colonnes affichées";
|
|
||||||
position: absolute;
|
|
||||||
left: 5px;
|
|
||||||
right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-hide-menu-item > input {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Alternance des couleurs pour les lignes */
|
|
||||||
#table tr:nth-child(even) td {
|
|
||||||
background-color: #feeeee;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header > *,
|
|
||||||
.dash-spreadsheet-menu button.export {
|
|
||||||
margin: 0 0 20px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Menu de navigation */
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
width: 100%;
|
|
||||||
height: 100px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 0;
|
|
||||||
border-bottom: solid 1px black;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.nav {
|
|
||||||
float: left;
|
|
||||||
margin-right: 40px;
|
|
||||||
font-size: 120%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar.h1 {
|
|
||||||
float: left;
|
|
||||||
width: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
#_pages_content {
|
|
||||||
padding-top: 28px;
|
|
||||||
}
|
|
||||||
+774
-72
@@ -1,71 +1,80 @@
|
|||||||
import json
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
|
||||||
|
import dash_bootstrap_components as dbc
|
||||||
|
import dash_leaflet as dl
|
||||||
|
import dash_leaflet.express as dlx
|
||||||
|
import numpy as np
|
||||||
import plotly.express as px
|
import plotly.express as px
|
||||||
|
import plotly.graph_objects as go
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from dash import dash_table, html
|
from dash import dash_table, dcc, html
|
||||||
|
from dash_extensions.javascript import Namespace
|
||||||
|
|
||||||
|
from src.utils import (
|
||||||
|
add_links,
|
||||||
|
data_schema,
|
||||||
|
departements_geojson,
|
||||||
|
df,
|
||||||
|
format_number,
|
||||||
|
setup_table_columns,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_map_count_marches(lf: pl.LazyFrame):
|
def get_yearly_statistics(statistics, today_str) -> html.Div:
|
||||||
lf = lf.with_columns(
|
# Build DataFrame from statistics
|
||||||
pl.col("lieuExecution_code").str.head(2).str.zfill(2).alias("Département")
|
years = list(reversed(range(2018, int(today_str.split("/")[-1]) + 1)))
|
||||||
)
|
data = []
|
||||||
lf = (
|
for year in years:
|
||||||
lf.select(["uid", "Département"])
|
year_str = str(year)
|
||||||
.drop_nulls()
|
stat = statistics[year_str]
|
||||||
.unique(subset="uid")
|
data.append(
|
||||||
.group_by("Département")
|
{
|
||||||
.len("uid")
|
"Année": year_str,
|
||||||
)
|
"Marchés et accord-cadres": format_number(
|
||||||
# Suppression des infos pour les DOM/TOM pour l'instant
|
stat["nb_notifications_marches"]
|
||||||
lf = lf.remove(pl.col("Département").is_in(["97", "98"]))
|
),
|
||||||
|
"Acheteurs": format_number(stat["nb_acheteurs_uniques"]),
|
||||||
|
"Titulaires": format_number(stat["nb_titulaires_uniques"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
with open("./data/departements-1000m.geojson") as f:
|
dff = pl.DataFrame(data)
|
||||||
departements = json.load(f)
|
|
||||||
|
|
||||||
# Ajout de feature.id
|
# Create Dash DataTable
|
||||||
for f in departements["features"]:
|
table = dash_table.DataTable(
|
||||||
f["id"] = f["properties"]["code"]
|
data=dff.to_dicts(),
|
||||||
|
columns=[
|
||||||
df = lf.collect()
|
{"name": "Année", "id": "Année"},
|
||||||
|
{"name": "Marchés et accord-cadres", "id": "Marchés et accord-cadres"},
|
||||||
fig = px.choropleth(
|
{"name": "Acheteurs", "id": "Acheteurs"},
|
||||||
df,
|
{"name": "Titulaires", "id": "Titulaires"},
|
||||||
geojson=departements,
|
],
|
||||||
locations="Département",
|
page_size=10,
|
||||||
color="uid",
|
sort_action="none",
|
||||||
color_continuous_scale="Reds",
|
filter_action="none",
|
||||||
title="Nombres de marchés attribués par département (lieu d'exécution)",
|
style_header={"fontFamily": "Inter", "fontSize": "16px"},
|
||||||
range_color=(df["uid"].min(), df["uid"].max()),
|
style_cell={"fontFamily": "Inter", "fontSize": "16px"},
|
||||||
labels={"uid": "Marchés attribués"},
|
|
||||||
scope="europe",
|
|
||||||
width=1000,
|
|
||||||
height=800,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fig.update_geos(fitbounds="locations", visible=False)
|
return html.Div(children=table, className="marches_table")
|
||||||
fig.update_layout(
|
|
||||||
mapbox={
|
|
||||||
"style": "carto-positron",
|
|
||||||
"center": {"lon": 10, "lat": 10},
|
|
||||||
"zoom": 1,
|
|
||||||
"domain": {"x": [0, 1], "y": [0, 1]},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return fig
|
|
||||||
|
|
||||||
|
|
||||||
def get_barchart_sources(lf: pl.LazyFrame, type_date: str):
|
def get_barchart_sources(lff: pl.LazyFrame, type_date: str):
|
||||||
labels = {
|
labels = {
|
||||||
"dateNotification": "notification",
|
"dateNotification": "notification",
|
||||||
"datePublicationDonnees": "publication des données",
|
"datePublicationDonnees": "publication des données",
|
||||||
}
|
}
|
||||||
|
|
||||||
lf = lf.select("uid", type_date, "sourceDataset")
|
now_year = datetime.now().year
|
||||||
|
|
||||||
lf = lf.unique("uid")
|
lff = lff.select("uid", type_date, "sourceDataset")
|
||||||
|
|
||||||
|
lff = lff.unique("uid")
|
||||||
|
|
||||||
# Rassemblement des datasets Atexo pour ne pas surcharger le graphique
|
# Rassemblement des datasets Atexo pour ne pas surcharger le graphique
|
||||||
lf = lf.with_columns(
|
lff = lff.with_columns(
|
||||||
pl.when(pl.col("sourceDataset").str.starts_with("atexo"))
|
pl.when(pl.col("sourceDataset").str.starts_with("atexo"))
|
||||||
.then(pl.lit("plateformes atexo"))
|
.then(pl.lit("plateformes atexo"))
|
||||||
.otherwise(pl.col("sourceDataset"))
|
.otherwise(pl.col("sourceDataset"))
|
||||||
@@ -73,38 +82,33 @@ def get_barchart_sources(lf: pl.LazyFrame, type_date: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Rassemblement des datasets AWS pour ne pas surcharger le graphique
|
# Rassemblement des datasets AWS pour ne pas surcharger le graphique
|
||||||
lf = lf.with_columns(
|
lff = lff.with_columns(
|
||||||
pl.when(pl.col("sourceDataset").str.contains(r"aws|marches\-publics.info"))
|
pl.when(pl.col("sourceDataset").str.contains(r"aws|marches\-publics.info"))
|
||||||
.then(pl.lit("aws"))
|
.then(pl.lit("aws"))
|
||||||
.otherwise(pl.col("sourceDataset"))
|
.otherwise(pl.col("sourceDataset"))
|
||||||
.alias("sourceDataset")
|
.alias("sourceDataset")
|
||||||
)
|
)
|
||||||
|
|
||||||
lf = lf.with_columns(pl.col(type_date).dt.year().alias("annee"))
|
lff = lff.with_columns(pl.col(type_date).dt.year().alias("annee"))
|
||||||
lf = lf.filter(
|
lff = lff.filter(
|
||||||
pl.col(type_date).is_not_null() & pl.col("annee").is_between(2019, 2025)
|
pl.col(type_date).is_not_null() & pl.col("annee").is_between(2019, now_year)
|
||||||
)
|
)
|
||||||
lf = lf.with_columns(pl.col(type_date).cast(pl.String).str.head(7))
|
lff = lff.with_columns(pl.col(type_date).cast(pl.String).str.head(7))
|
||||||
lf = (
|
lff = (
|
||||||
lf.group_by([type_date, "sourceDataset"])
|
lff.group_by([type_date, "sourceDataset"])
|
||||||
.len()
|
.len()
|
||||||
.sort(by=[type_date, "len"], descending=True)
|
.sort(by=[type_date, "len"], descending=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
# lf = lf.with_columns(
|
lff = lff.sort(by=["sourceDataset"], descending=False)
|
||||||
# pl.when(pl.col("sourceDataset").is_null()).then(
|
|
||||||
# pl.lit("Source inconnue")).alias("sourceDataset")
|
|
||||||
# )
|
|
||||||
|
|
||||||
lf = lf.sort(by=["sourceDataset"], descending=False)
|
dff: pl.DataFrame = lff.collect(engine="streaming")
|
||||||
df: pl.DataFrame = lf.collect()
|
|
||||||
|
|
||||||
fig = px.bar(
|
fig = px.bar(
|
||||||
df,
|
dff,
|
||||||
x=type_date,
|
x=type_date,
|
||||||
y="len",
|
y="len",
|
||||||
color="sourceDataset",
|
color="sourceDataset",
|
||||||
title=f"Nombre de marchés attribués par date de {labels[type_date]} et source de données",
|
|
||||||
labels={
|
labels={
|
||||||
"len": "Nombre de marchés",
|
"len": "Nombre de marchés",
|
||||||
type_date: f"Mois de {labels[type_date]}",
|
type_date: f"Mois de {labels[type_date]}",
|
||||||
@@ -112,12 +116,17 @@ def get_barchart_sources(lf: pl.LazyFrame, type_date: str):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return fig
|
graph = dcc.Graph(figure=fig)
|
||||||
|
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
def get_sources_tables(source_path) -> html.Div:
|
def get_sources_tables(source_path) -> html.Div:
|
||||||
df = pl.read_csv(source_path)
|
try:
|
||||||
df = df.with_columns(
|
dff = pl.read_csv(source_path)
|
||||||
|
except (URLError, HTTPError):
|
||||||
|
return html.Div("Erreur de connexion")
|
||||||
|
dff = dff.with_columns(
|
||||||
(
|
(
|
||||||
pl.lit('<a href = "')
|
pl.lit('<a href = "')
|
||||||
+ pl.col("url")
|
+ pl.col("url")
|
||||||
@@ -126,20 +135,29 @@ def get_sources_tables(source_path) -> html.Div:
|
|||||||
+ pl.lit("</a>")
|
+ pl.lit("</a>")
|
||||||
).alias("nom")
|
).alias("nom")
|
||||||
)
|
)
|
||||||
df = df.drop("url")
|
dff = dff.drop("url", "unique")
|
||||||
df = df.sort(by=["nb_marchés"], descending=True)
|
dff = dff.sort(by=["nb_marchés"], descending=True)
|
||||||
|
|
||||||
|
columns = {
|
||||||
|
"nom": "Nom de la source",
|
||||||
|
"organisation": "Responsable de publication",
|
||||||
|
"nb_marchés": "Nb de marchés",
|
||||||
|
"nb_acheteurs": "Nb d'acheteurs",
|
||||||
|
"code": "Code",
|
||||||
|
}
|
||||||
|
|
||||||
datatable = dash_table.DataTable(
|
datatable = dash_table.DataTable(
|
||||||
id="source_table",
|
id="source_table",
|
||||||
|
data=dff.to_dicts(),
|
||||||
columns=[
|
columns=[
|
||||||
{
|
{
|
||||||
"name": i,
|
"name": columns[i],
|
||||||
"id": i,
|
"id": i,
|
||||||
"presentation": "markdown",
|
"presentation": "markdown",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"format": {"nully": "N/A"},
|
"format": {"nully": "N/A"},
|
||||||
}
|
}
|
||||||
for i in df.schema.names()
|
for i in dff.schema.names()
|
||||||
],
|
],
|
||||||
style_cell_conditional=[
|
style_cell_conditional=[
|
||||||
{
|
{
|
||||||
@@ -153,7 +171,691 @@ def get_sources_tables(source_path) -> html.Div:
|
|||||||
],
|
],
|
||||||
sort_action="native",
|
sort_action="native",
|
||||||
markdown_options={"html": True},
|
markdown_options={"html": True},
|
||||||
|
style_header={"fontFamily": "Inter", "fontSize": "16px"},
|
||||||
|
style_cell={"fontFamily": "Inter", "fontSize": "16px"},
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
graph = html.Div(style={"width": "400px"})
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
class DataTable(dash_table.DataTable):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dtid: str,
|
||||||
|
hidden_columns: list[str] | None = None,
|
||||||
|
data: list[dict[str, str | int | float | bool]] | None = None,
|
||||||
|
columns: list[dict[str, str]] | None = 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",
|
||||||
|
style_cell_conditional: list | None = None,
|
||||||
|
style_cell: dict | None = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
# Styles de base
|
||||||
|
style_cell_conditional_common = [
|
||||||
|
{
|
||||||
|
"if": {"column_id": "objet"},
|
||||||
|
"minWidth": "350px",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"lineHeight": "18px",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {"column_id": "acheteur_id"},
|
||||||
|
"minWidth": "160px",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {"column_id": "acheteur_nom"},
|
||||||
|
"minWidth": "250px",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"lineHeight": "18px",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {"column_id": "titulaire_nom"},
|
||||||
|
"minWidth": "250px",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"lineHeight": "18px",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
style_cell_common = {"fontFamily": "Inter", "fontSize": "16px"}
|
||||||
|
|
||||||
|
for key in data_schema.keys():
|
||||||
|
field = data_schema[key]
|
||||||
|
if field["type"] in ["number", "integer"]:
|
||||||
|
rule = {
|
||||||
|
"if": {"column_id": field["name"]},
|
||||||
|
"textAlign": "right",
|
||||||
|
# "fontFamily": "Fira Code",
|
||||||
|
}
|
||||||
|
style_cell_conditional_common.append(rule)
|
||||||
|
|
||||||
|
style_cell_conditional = (
|
||||||
|
style_cell_conditional or []
|
||||||
|
) + style_cell_conditional_common
|
||||||
|
if style_cell:
|
||||||
|
style_cell.update(style_cell_common)
|
||||||
|
else:
|
||||||
|
style_cell = style_cell_common
|
||||||
|
style_header = style_cell
|
||||||
|
|
||||||
|
# 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": "Filtre de colonne...",
|
||||||
|
},
|
||||||
|
sort_action=sort_action,
|
||||||
|
sort_mode="multi",
|
||||||
|
row_deletable=False,
|
||||||
|
page_current=0,
|
||||||
|
style_cell_conditional=style_cell_conditional,
|
||||||
|
data_timestamp=0,
|
||||||
|
markdown_options={"html": True},
|
||||||
|
style_header=style_header,
|
||||||
|
style_cell=style_cell,
|
||||||
|
tooltip_duration=8000,
|
||||||
|
tooltip_delay=350,
|
||||||
|
hidden_columns=hidden_columns,
|
||||||
|
**kwargs, # Possibilité de remplacer des arguments
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_duplicate_matrix() -> dcc.Graph:
|
||||||
|
"""
|
||||||
|
Fonction développée avec l'aide de la LLM Euria d'Infomaniak.
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
lff = pl.scan_parquet(
|
||||||
|
"https://www.data.gouv.fr/api/1/datasets/r/a545bf6c-8b24-46ed-b49f-a32bf02eaffa"
|
||||||
|
).sort("sourceDataset")
|
||||||
|
lff = lff.select(
|
||||||
|
["sourceDataset", "unique"] + sorted(lff.collect_schema().names()[2:])
|
||||||
|
)
|
||||||
|
|
||||||
|
dff = lff.collect()
|
||||||
|
|
||||||
|
# Extract data
|
||||||
|
z_data = dff.select(pl.all().exclude("sourceDataset")).fill_null(0).to_numpy()
|
||||||
|
x_labels = dff.columns[1:] # columns after "sourceDataset"
|
||||||
|
y_labels = dff["sourceDataset"].to_list()
|
||||||
|
|
||||||
|
# Create heatmap
|
||||||
|
fig = go.Figure(
|
||||||
|
data=go.Heatmap(
|
||||||
|
z=z_data,
|
||||||
|
x=x_labels,
|
||||||
|
y=y_labels,
|
||||||
|
colorscale=[
|
||||||
|
[0.0, "white"], # 0% → white
|
||||||
|
[0.10, "lightsalmon"], # 10% → light warm tone
|
||||||
|
[1.0, "darkred"], # 100% → deep red
|
||||||
|
],
|
||||||
|
zmin=0,
|
||||||
|
zmax=1,
|
||||||
|
hoverongaps=False,
|
||||||
|
showscale=True,
|
||||||
|
hovertemplate=(
|
||||||
|
"<b>%{z:.0%}</b> des marchés présents dans <b>%{y}</b> sont également présents dans <b>%{x}</b>"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update layout: make it wider and taller
|
||||||
|
fig.update_layout(
|
||||||
|
title="",
|
||||||
|
xaxis_title="Sources de données",
|
||||||
|
yaxis_title="Sources de données",
|
||||||
|
yaxis=dict(autorange="reversed"),
|
||||||
|
xaxis=dict(tickangle=45, tickfont=dict(size=10)), # Smaller x-tick labels
|
||||||
|
coloraxis_colorbar=dict(title="Percentage", tickfont=dict(size=10)),
|
||||||
|
width=1000, # Wider
|
||||||
|
height=1000, # Taller
|
||||||
|
font=dict(size=11), # Overall font size
|
||||||
|
margin=dict(l=100, r=50, t=80, b=100), # Add margin for labels
|
||||||
|
)
|
||||||
|
|
||||||
|
return dcc.Graph(figure=fig)
|
||||||
|
|
||||||
|
|
||||||
|
def get_geographic_maps(dff: pl.DataFrame) -> list | None:
|
||||||
|
"""
|
||||||
|
Génère les cartes géographiques pour l'hexagone et les DOM-TOM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
regions: dict = {
|
||||||
|
"Hexagone": {
|
||||||
|
"coordinates": [46.6, 2.2],
|
||||||
|
"zoom_leaflet": 5,
|
||||||
|
"zoom_chloropleth": 1,
|
||||||
|
"name": "Hexagone",
|
||||||
|
},
|
||||||
|
"971": {
|
||||||
|
"coordinates": [16.23, -61.55],
|
||||||
|
"zoom_leaflet": 9,
|
||||||
|
"zoom_chloropleth": 1,
|
||||||
|
"name": "Guadeloupe",
|
||||||
|
},
|
||||||
|
"972": {
|
||||||
|
"coordinates": [14.64, -61.02],
|
||||||
|
"zoom_leaflet": 10,
|
||||||
|
"zoom_chloropleth": 1,
|
||||||
|
"name": "Martinique",
|
||||||
|
},
|
||||||
|
"973": {
|
||||||
|
"coordinates": [3.93, -53.12],
|
||||||
|
"zoom_leaflet": 7,
|
||||||
|
"zoom_chloropleth": 1,
|
||||||
|
"name": "Guyane",
|
||||||
|
},
|
||||||
|
"974": {
|
||||||
|
"coordinates": [-21.11, 55.53],
|
||||||
|
"zoom_leaflet": 9,
|
||||||
|
"zoom_chloropleth": 1,
|
||||||
|
"name": "La Réunion",
|
||||||
|
},
|
||||||
|
"976": {
|
||||||
|
"coordinates": [-12.82, 45.16],
|
||||||
|
"zoom_leaflet": 10,
|
||||||
|
"zoom_chloropleth": 1,
|
||||||
|
"name": "Mayotte",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def make_map_data(region_code: str) -> tuple[list, str or None]:
|
||||||
|
lff: pl.LazyFrame = dff.lazy()
|
||||||
|
if region_code == "Hexagone":
|
||||||
|
lff = lff.filter(
|
||||||
|
(pl.col("acheteur_departement_code").str.len_chars() == 2)
|
||||||
|
& (pl.col("titulaire_departement_code").str.len_chars() == 2)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lff = lff.filter(
|
||||||
|
(pl.col("acheteur_departement_code") == code)
|
||||||
|
| (pl.col("titulaire_departement_code") == code)
|
||||||
|
)
|
||||||
|
|
||||||
|
nb_marches = lff.select("uid").collect()["uid"].n_unique()
|
||||||
|
|
||||||
|
if nb_marches == 0:
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
dfs = []
|
||||||
|
|
||||||
|
if (code == "Hexagone" and nb_marches > 30000) or (
|
||||||
|
code != "Hexagone" and nb_marches > 10000
|
||||||
|
):
|
||||||
|
_map_type: str = "chloropleth"
|
||||||
|
|
||||||
|
lff = lff.rename({"acheteur_departement_code": "Département"})
|
||||||
|
lff = (
|
||||||
|
lff.select(["uid", "Département"])
|
||||||
|
.drop_nulls()
|
||||||
|
.group_by("uid")
|
||||||
|
.agg(pl.col("Département").first())
|
||||||
|
.group_by("Département")
|
||||||
|
.len("uid")
|
||||||
|
)
|
||||||
|
dfs.append(lff.collect())
|
||||||
|
else:
|
||||||
|
_map_type: str = "clusters"
|
||||||
|
for org_type in ["acheteur", "titulaire"]:
|
||||||
|
lff_org = (
|
||||||
|
lff.select(
|
||||||
|
"uid",
|
||||||
|
f"{org_type}_longitude",
|
||||||
|
f"{org_type}_latitude",
|
||||||
|
f"{org_type}_nom",
|
||||||
|
)
|
||||||
|
.group_by(
|
||||||
|
f"{org_type}_longitude",
|
||||||
|
f"{org_type}_latitude",
|
||||||
|
f"{org_type}_nom",
|
||||||
|
)
|
||||||
|
.len("nb_marches")
|
||||||
|
.filter(
|
||||||
|
pl.col(f"{org_type}_latitude").is_not_null()
|
||||||
|
& pl.col(f"{org_type}_longitude").is_not_null()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
markers = []
|
||||||
|
|
||||||
|
# Couleurs accessibles (Okabe-Ito)
|
||||||
|
colors = {
|
||||||
|
"acheteur": "#E69F00", # orange
|
||||||
|
"titulaire": "#56B4E9", # bleu ciel
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in lff_org.collect().to_dicts():
|
||||||
|
markers.append(
|
||||||
|
{
|
||||||
|
"lat": row[f"{org_type}_latitude"],
|
||||||
|
"lon": row[f"{org_type}_longitude"],
|
||||||
|
"tooltip": f"{row[f'{org_type}_nom']} ({row['nb_marches']} marchés)",
|
||||||
|
"marker_color": colors[org_type],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
dfs.append(markers)
|
||||||
|
|
||||||
|
return dfs, _map_type
|
||||||
|
|
||||||
|
cols = []
|
||||||
|
|
||||||
|
for code in regions.keys():
|
||||||
|
regions[code]["data"], map_type = make_map_data(code)
|
||||||
|
|
||||||
|
if map_type == "chloropleth":
|
||||||
|
map_graph = make_chloropleth_map(regions[code])
|
||||||
|
elif map_type == "clusters":
|
||||||
|
map_graph = make_clusters_map(regions[code])
|
||||||
|
elif map_type is None:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Map type '{map_type}' not recognised")
|
||||||
|
|
||||||
|
lg, xl = (12, 8) if code == "Hexagone" else (6, 4)
|
||||||
|
|
||||||
|
col = make_card(regions[code]["name"], fig=map_graph, lg=lg, xl=xl)
|
||||||
|
cols.append(col)
|
||||||
|
|
||||||
|
return cols
|
||||||
|
|
||||||
|
|
||||||
|
def make_chloropleth_map(region: dict) -> dcc.Graph:
|
||||||
|
df_map = region["data"][0]
|
||||||
|
|
||||||
|
fig = px.choropleth(
|
||||||
|
df_map,
|
||||||
|
geojson=departements_geojson,
|
||||||
|
locations="Département",
|
||||||
|
color="uid",
|
||||||
|
color_continuous_scale="Reds",
|
||||||
|
range_color=(df_map["uid"].min(), df_map["uid"].max()),
|
||||||
|
labels={"uid": "Marchés attribués"},
|
||||||
|
scope="europe",
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.update_geos(fitbounds="locations", visible=False)
|
||||||
|
fig.update_layout(
|
||||||
|
mapbox={
|
||||||
|
"style": "carto-positron",
|
||||||
|
"center": {"lon": 10, "lat": 10},
|
||||||
|
"zoom": 8,
|
||||||
|
"domain": {"x": [0, 1], "y": [0, 1]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
graph = dcc.Graph(figure=fig, config={"displayModeBar": False})
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
def make_clusters_map(region: dict) -> dl.Map:
|
||||||
|
# JavaScript functions for styling
|
||||||
|
ns = Namespace("dash_clientside", "leaflet")
|
||||||
|
point_to_layer = ns("pointToLayer")
|
||||||
|
cluster_to_layer = ns("clusterToLayer")
|
||||||
|
|
||||||
|
name = region["name"]
|
||||||
|
|
||||||
|
# Données de la région
|
||||||
|
region_acheteurs = region["data"][0]
|
||||||
|
region_titulaires = region["data"][1]
|
||||||
|
|
||||||
|
# Couleurs
|
||||||
|
color_acheteur = region_acheteurs[0]["marker_color"]
|
||||||
|
color_titulaire = region_titulaires[0]["marker_color"]
|
||||||
|
|
||||||
|
acheteurs_geojson_data = dlx.dicts_to_geojson(region_acheteurs)
|
||||||
|
titulaires_geojson_data = dlx.dicts_to_geojson(region_titulaires)
|
||||||
|
|
||||||
|
center, zoom = region["coordinates"], region["zoom_leaflet"]
|
||||||
|
region_id = name.lower().replace(" ", "-")
|
||||||
|
leaflet_map = dl.Map(
|
||||||
|
[
|
||||||
|
dl.TileLayer(),
|
||||||
|
dl.GeoJSON(
|
||||||
|
data=titulaires_geojson_data,
|
||||||
|
cluster=True,
|
||||||
|
zoomToBoundsOnClick=True,
|
||||||
|
pointToLayer=point_to_layer,
|
||||||
|
clusterToLayer=cluster_to_layer,
|
||||||
|
id=f"geojson-{region_id}-titulaires",
|
||||||
|
options={"fillColor": color_titulaire},
|
||||||
|
),
|
||||||
|
dl.GeoJSON(
|
||||||
|
data=acheteurs_geojson_data,
|
||||||
|
cluster=True,
|
||||||
|
zoomToBoundsOnClick=True,
|
||||||
|
pointToLayer=point_to_layer,
|
||||||
|
clusterToLayer=cluster_to_layer,
|
||||||
|
id=f"geojson-{region_id}-acheteurs",
|
||||||
|
options={"fillColor": color_acheteur},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
center=center,
|
||||||
|
zoom=zoom,
|
||||||
|
style={
|
||||||
|
"width": "100%",
|
||||||
|
"height": "400px" if name == "Hexagone" else "300px",
|
||||||
|
},
|
||||||
|
id=f"map-{region_id}",
|
||||||
|
)
|
||||||
|
return leaflet_map
|
||||||
|
|
||||||
|
|
||||||
|
def get_distance_histogram(lff: pl.LazyFrame) -> dcc.Graph:
|
||||||
|
if "titulaire_distance" not in lff.collect_schema().names():
|
||||||
|
dff = pl.DataFrame({"titulaire_distance": pl.Series([], dtype=pl.Float64)})
|
||||||
|
else:
|
||||||
|
dff = (
|
||||||
|
lff.select("titulaire_distance")
|
||||||
|
.drop_nulls()
|
||||||
|
.filter(pl.col("titulaire_distance") > 0)
|
||||||
|
.collect(engine="streaming")
|
||||||
|
)
|
||||||
|
log_distances = dff["titulaire_distance"].log(10).to_numpy()
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
if len(log_distances) > 0:
|
||||||
|
counts, bin_edges = np.histogram(log_distances, bins=25)
|
||||||
|
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
|
||||||
|
bin_widths = bin_edges[1:] - bin_edges[:-1]
|
||||||
|
bin_edges_km = 10.0**bin_edges
|
||||||
|
|
||||||
|
def fmt_km(km):
|
||||||
|
if km < 10:
|
||||||
|
return f"{km:.1f}"
|
||||||
|
elif km < 1000:
|
||||||
|
return f"{round(km)}"
|
||||||
|
else:
|
||||||
|
return f"{round(km):,}".replace(",", " ")
|
||||||
|
|
||||||
|
hover_texts = []
|
||||||
|
for i in range(len(counts)):
|
||||||
|
nb = f"{counts[i]:,}".replace(",", " ")
|
||||||
|
hover_texts.append(
|
||||||
|
f"Distance : {fmt_km(bin_edges_km[i])} – {fmt_km(bin_edges_km[i + 1])} km"
|
||||||
|
f"<br>Nombre de marchés : {nb}"
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.add_trace(
|
||||||
|
go.Bar(
|
||||||
|
x=bin_centers,
|
||||||
|
y=counts,
|
||||||
|
width=bin_widths,
|
||||||
|
hovertext=hover_texts,
|
||||||
|
hoverinfo="text",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fig.update_layout(bargap=0)
|
||||||
|
|
||||||
|
fig.update_layout(margin=dict(r=10, t=10))
|
||||||
|
fig.update_xaxes(
|
||||||
|
tickvals=[0, 1, 2, 3, 4],
|
||||||
|
ticktext=["1", "10", "100", "1 000", "10 000"],
|
||||||
|
title_text="Distance (km)",
|
||||||
|
)
|
||||||
|
fig.update_yaxes(title_text="Nombre de marchés")
|
||||||
|
return dcc.Graph(figure=fig)
|
||||||
|
|
||||||
|
|
||||||
|
def get_dashboard_summary_table(dff, dff_per_uid, nb_marches):
|
||||||
|
nb_acheteurs = dff.select("acheteur_id").n_unique()
|
||||||
|
nb_titulaires = dff.select("titulaire_id", "titulaire_typeIdentifiant").n_unique()
|
||||||
|
total_montant = int(dff_per_uid.select(pl.col("montant").sum()).item())
|
||||||
|
median_distance = dff.select(pl.median("titulaire_distance")).item()
|
||||||
|
|
||||||
|
summary_table = [
|
||||||
|
html.P(["Nombre de marchés : ", html.Strong(str(format_number(nb_marches)))]),
|
||||||
|
html.P(
|
||||||
|
[
|
||||||
|
"Nombre d'acheteurs uniques : ",
|
||||||
|
html.Strong(str(format_number(nb_acheteurs))),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
html.P(
|
||||||
|
[
|
||||||
|
"Nombre de titulaires uniques : ",
|
||||||
|
html.Strong(str(format_number(nb_titulaires))),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
html.P(
|
||||||
|
[
|
||||||
|
"Montant total (",
|
||||||
|
html.Span(
|
||||||
|
"?",
|
||||||
|
id={"type": "modal-trigger", "index": "montant"},
|
||||||
|
style={"cursor": "pointer", "textDecoration": "underline dotted"},
|
||||||
|
),
|
||||||
|
") : ",
|
||||||
|
html.Strong(format_number(total_montant) + " €"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
html.P(
|
||||||
|
[
|
||||||
|
"Distance acheteur-titulaire médiane : ",
|
||||||
|
html.Strong(format_number(median_distance) + " km"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
return summary_table
|
||||||
|
|
||||||
|
|
||||||
|
def make_card(
|
||||||
|
title: str, subtitle=None, fig=None, paragraphs=None, lg=6, xl=4
|
||||||
|
) -> dbc.Col:
|
||||||
|
children = []
|
||||||
|
if title:
|
||||||
|
children.append(html.H5(title, className="card-title"))
|
||||||
|
if subtitle:
|
||||||
|
children.append(html.H6(subtitle, className="card-subtitle mb-2 text-muted"))
|
||||||
|
if fig is not None:
|
||||||
|
children.append(fig)
|
||||||
|
if paragraphs:
|
||||||
|
for p in paragraphs:
|
||||||
|
p.className = "card-text"
|
||||||
|
children.append(p)
|
||||||
|
|
||||||
|
card = dbc.Col(
|
||||||
|
html.Div(html.Div(className="card-body", children=children), className="card"),
|
||||||
|
lg=lg,
|
||||||
|
xl=xl,
|
||||||
|
# width=width,
|
||||||
|
# className="mb-4",
|
||||||
|
)
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
def make_donut(
|
||||||
|
lff: pl.LazyFrame,
|
||||||
|
names_col,
|
||||||
|
per_uid: bool,
|
||||||
|
nulls="?",
|
||||||
|
potentially_many_names: bool = False,
|
||||||
|
):
|
||||||
|
title = data_schema[names_col]["title"]
|
||||||
|
lff = lff.rename({names_col: title})
|
||||||
|
lff = lff.select("uid", title)
|
||||||
|
|
||||||
|
if per_uid:
|
||||||
|
lff = lff.group_by("uid").first()
|
||||||
|
|
||||||
|
lff = lff.group_by(title).len("Nombre")
|
||||||
|
lff = lff.with_columns(pl.col(title).replace(None, pl.lit(nulls)))
|
||||||
|
dff = lff.collect(engine="streaming")
|
||||||
|
nb_names = dff[title].n_unique()
|
||||||
|
|
||||||
|
sum_values = dff["Nombre"].sum()
|
||||||
|
dff = dff.with_columns(
|
||||||
|
pl.when((pl.col("Nombre") / sum_values) < 0.01)
|
||||||
|
.then(pl.lit("Autres"))
|
||||||
|
.otherwise(pl.col(title))
|
||||||
|
.alias(title)
|
||||||
|
)
|
||||||
|
|
||||||
|
dff = dff.with_columns(
|
||||||
|
pl.col("Nombre")
|
||||||
|
.map_elements(format_number, return_dtype=pl.String)
|
||||||
|
.alias("Nombre_fmt")
|
||||||
|
)
|
||||||
|
fig = px.pie(
|
||||||
|
dff,
|
||||||
|
values="Nombre",
|
||||||
|
names=title,
|
||||||
|
hole=0.4,
|
||||||
|
color_discrete_sequence=px.colors.qualitative.Safe,
|
||||||
|
custom_data=["Nombre_fmt"],
|
||||||
|
)
|
||||||
|
fig = fig.update_traces(
|
||||||
|
texttemplate="<b>%{label}</b><br><b>%{percent}</b>",
|
||||||
|
hovertemplate="<b>%{label}</b><br>%{customdata[0]}<extra></extra>",
|
||||||
|
)
|
||||||
|
fig = fig.update_layout(showlegend=False, font=dict(size=14))
|
||||||
|
graph = dcc.Graph(figure=fig)
|
||||||
|
if potentially_many_names:
|
||||||
|
return graph, nb_names
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
def make_column_picker(page: str):
|
||||||
|
table_data = []
|
||||||
|
table_columns = [
|
||||||
|
{
|
||||||
|
"id": col,
|
||||||
|
"name": data_schema[col]["title"],
|
||||||
|
"description": data_schema[col]["description"],
|
||||||
|
}
|
||||||
|
for col in df.columns
|
||||||
|
]
|
||||||
|
for column in table_columns:
|
||||||
|
new_column = {
|
||||||
|
"id": column["id"],
|
||||||
|
"name": column["name"],
|
||||||
|
"description": data_schema[column["id"]]["description"],
|
||||||
|
}
|
||||||
|
table_data.append(new_column)
|
||||||
|
|
||||||
|
table = (
|
||||||
|
DataTable(
|
||||||
|
row_selectable="multi",
|
||||||
|
data=table_data,
|
||||||
|
filter_action="native",
|
||||||
|
sort_action="none",
|
||||||
|
style_cell={
|
||||||
|
"textAlign": "left",
|
||||||
|
},
|
||||||
|
columns=[
|
||||||
|
{
|
||||||
|
"name": "Nom",
|
||||||
|
"id": "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Description",
|
||||||
|
"id": "description",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
style_cell_conditional=[
|
||||||
|
{
|
||||||
|
"if": {"column_id": "description"},
|
||||||
|
"minWidth": "450px",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"lineHeight": "18px",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
page_action="none",
|
||||||
|
dtid=f"{page}_column_list",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool = True):
|
||||||
|
if isinstance(data, pl.LazyFrame):
|
||||||
|
lff = data
|
||||||
|
else:
|
||||||
|
lff = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
|
||||||
|
|
||||||
|
if org_type == "titulaire":
|
||||||
|
extra_columns.append("titulaire_typeIdentifiant")
|
||||||
|
columns = ["uid", f"{org_type}_id", f"{org_type}_nom"] + extra_columns
|
||||||
|
|
||||||
|
lff = lff.select(columns)
|
||||||
|
lff = lff.group_by([f"{org_type}_id", f"{org_type}_nom"] + extra_columns).agg(
|
||||||
|
pl.len().alias("Attributions")
|
||||||
|
)
|
||||||
|
lff = lff.sort(by="Attributions", descending=True, nulls_last=True)
|
||||||
|
lff = lff.cast(pl.String)
|
||||||
|
lff = lff.fill_null("")
|
||||||
|
|
||||||
|
dff: pl.DataFrame = lff.collect(engine="streaming")
|
||||||
|
|
||||||
|
if dff.height == 0:
|
||||||
|
return html.Div()
|
||||||
|
|
||||||
|
columns, tooltip = setup_table_columns(
|
||||||
|
dff, hideable=False, exclude=[f"{org_type}_id"], new_columns=["Attributions"]
|
||||||
|
)
|
||||||
|
dff = add_links(dff)
|
||||||
|
data = dff.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,
|
||||||
|
filter_action="native" if filters else "none",
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from dash import dcc, html, register_page
|
||||||
|
|
||||||
|
from src.figures import get_sources_tables
|
||||||
|
from src.utils import meta_content
|
||||||
|
|
||||||
|
name = "À propos"
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path="/a-propos",
|
||||||
|
title="À propos | decp.info",
|
||||||
|
name="À propos",
|
||||||
|
description="En savoir plus sur decp.info, l'outil d'exploration des données essentielles de la commande publique.",
|
||||||
|
image_url=meta_content["image_url"],
|
||||||
|
order=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = html.Div(
|
||||||
|
className="container",
|
||||||
|
children=[
|
||||||
|
html.H2(name),
|
||||||
|
html.Div(
|
||||||
|
className="a-propos-container",
|
||||||
|
children=[
|
||||||
|
# Main Content Column
|
||||||
|
html.Div(
|
||||||
|
className="a-propos-content",
|
||||||
|
children=[
|
||||||
|
dcc.Markdown(
|
||||||
|
"""Outil d'exploration libre et gratuit des données de marchés publics, développé par Colin Maudry.
|
||||||
|
|
||||||
|
Ce projet vise à démocratiser l'accès aux données des marchés publics et à un outil performant et gratuit. Si vous le trouvez utile
|
||||||
|
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.
|
||||||
|
|
||||||
|
En effet, le potentiel des données d'attribution de marchés et des données qui peuvent les enrichir est très loin d'être exploité par
|
||||||
|
les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
|
||||||
|
|
||||||
|
- de nombreuses visualisations de données (cartes, graphiques, tableaux) sur des thématiques variées (vivacité de la concurrence, secteurs d'activité, insertion par l'activité économique (IAE), distance acheteur-titulaire...)
|
||||||
|
- des alertes par email si des marchés correspondant à certains critères
|
||||||
|
- ...et toutes les fonctionnalités auxquelles vous pourrez penser
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
html.H4("Consommer les données brutes", id="donnees-brutes"),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
|
Vous pouvez consommer les données qui alimentent decp.info
|
||||||
|
|
||||||
|
- en les téléchargeant [sur data.gouv.fr](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire) (Parquet, CSV), pensez à lire la description du jeu de données
|
||||||
|
- en interrogeant l'[API REST ouverte](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire#user-content-api-rest)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
html.H4("Contact", id="contact"),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
|
- 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"),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
|
- via l'achat d'une prestation de service (devis, prestation, facture), vous pouvez financer le développement de [fonctionnalités prévues](https://github.com/ColinMaudry/decp.info/issues), ou d'autres !
|
||||||
|
- ma société accepte aussi les dons (pas de réduction d'impôt possible)
|
||||||
|
- écrivez-moi et on discute !
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
html.H4("Pour explorer le projet", id="explorer"),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
|
- ✉️ [inscription à la liste de diffusion](https://6254d9a3.sibforms.com/serve/MUIFAEonUVkoSVrdgey18CTgLyI16xw4yeu-M-YOUzhWE_AgfQfbgkyT7GvA_RYLro9MfuRqkzQxSvu7-uzbMSv2a2ZQPsliM7wtiiqIL8kR2zOvl6m11fb5qjcOxMAYsLiY_YBi3P7NY95CTJ8vRY4CpsDclF2iLooOElKkTgIgi5nePe7zAIrgiYM5v2EuALlGJZMEG9vBP-Cu) (annonces des mises à jour et évènements, maximum une fois par mois)
|
||||||
|
- 💾 [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)
|
||||||
|
- 📔 [wiki du projet](https://github.com/ColinMaudry/decp-processing/wiki)
|
||||||
|
- 🚰 code source
|
||||||
|
- [de decp.info](https://github.com/ColinMaudry/decp.info)
|
||||||
|
- [du traitement des données](https://github.com/ColinMaudry/decp-processing)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
html.H4(
|
||||||
|
"Qualité et exhaustivité des données",
|
||||||
|
id="qualite-exhausitivite",
|
||||||
|
),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""Les données visibles sur ce site proviennent exclusivement de la publication de données ouvertes par les acheteurs publics ou en leur nom, régie par [l'arrêté du 22 décembre 2022](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000046850496). Leur qualité est donc principalement liée à la qualité de leur saisie par les agents publics, parfois peu aidé·es par la qualité des outils à leur disposition. Je pense que l'analyse de marchés individuels et le comptage de marchés sur des critères autres que financiers sont plutôt fiables. En revanche, certains montants de marché estimés à des valeurs farfelues ([1 euro](https://decp.info/marches/432766947000192025S01301), [1 milliard](https://decp.info/marches/2459004280001320210000000271)) faussent les calculs par aggrégation (sommes, moyennes, médianes) et donc la production de statistiques financières fiables. Acheteurs, acheteuses : s'il vous plaît, essayez d'estimer les montants des marchés publics attribués de manière plus précise.
|
||||||
|
|
||||||
|
Quant à l'exhaustivité, je consolide toutes les sources de données exploitables que j'ai pu identifier (voir [ci-dessous](/a-propos#sources). Certains profils d'acheteurs ne publient pas leurs données malgré l'obligation réglementaire :
|
||||||
|
|
||||||
|
- klekoon.fr (ils y travaillent)
|
||||||
|
- safetender.com (Omnikles)
|
||||||
|
|
||||||
|
**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="sources"),
|
||||||
|
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
|
||||||
|
html.H4("Mentions légales", id="mentions-legales"),
|
||||||
|
html.H5("Publication", id="publication"),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
|
Site Web développé et édité par [SAS Colmo](https://annuaire-entreprises.data.gouv.fr/entreprise/colmo-989393350), 989 393 350 RCS Rennes au capital de 3 000 euros.
|
||||||
|
|
||||||
|
Siège social : 1 carrefour Jouaust, 35000 Rennes
|
||||||
|
|
||||||
|
Hébergement : serveur situé en France et administré par Scaleway, 8 rue de la Ville l’Evêque, 75008 Paris
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
html.H5("Suivi d'audience", id="audience"),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
|
Ce site dépose un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le consultez ([Wikipédia](https://fr.wikipedia.org/wiki/Cookie_(informatique))). Cela me permet de mesurer le nombre de visites, de distinguer les nouveaux visiteurs des utilisateurs réguliers et ainsi de communiquer sur l'impact de decp.info.
|
||||||
|
|
||||||
|
**Ce site n’affiche pas de bannière de consentement aux cookies, pourquoi ?**
|
||||||
|
|
||||||
|
C’est vrai, vous n’avez pas eu à cliquer sur un bloc qui recouvre la moitié de la page pour dire que vous êtes d’accord avec le dépôt de cookies.
|
||||||
|
|
||||||
|
Rien d’exceptionnel, je respecte simplement la loi, qui dit que certains outils de suivi d’audience, correctement configurés pour respecter la vie privée, sont exemptés d’autorisation préalable.
|
||||||
|
|
||||||
|
J’utilise pour cela [Matomo](https://matomo.org/), un outil [libre](https://matomo.org/free-software/), paramétré pour être en conformité avec [la recommandation « Cookies »](https://www.cnil.fr/fr/solutions-pour-les-cookies-de-mesure-daudience) de la CNIL. Cela signifie que votre adresse IP, par exemple, est anonymisée avant d’être enregistrée. Il m’est donc impossible d’associer vos visites sur ce site à votre personne.
|
||||||
|
|
||||||
|
J'enregistre également les données suivantes, de manière anonyme, afin de mieux comprendre comment vous utilisez le site et l'améliorer :
|
||||||
|
|
||||||
|
- recherches sur la page d'accueil
|
||||||
|
- filtres appliqués aux données
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
html.H5("Attributions", id="attributions"),
|
||||||
|
dcc.Markdown("""
|
||||||
|
Les polices de caractères sont distribuées par [Bunny fonts](https://fonts.bunny.net), une alternative européenne et qualitative à Google Fonts.
|
||||||
|
|
||||||
|
- la police de caractère [Inter](https://fonts.bunny.net/family/inter), principale police de ce site, a été créée par The Inter Project Authors ([source](https://github.com/rsms/inter))
|
||||||
|
- la police de caractère [Fira Code](https://fonts.bunny.net/family/fira-code), la police à largeure fixe, a été créée par The Fira Code Project Authors (https://github.com/tonsky/FiraCode)
|
||||||
|
"""),
|
||||||
|
html.H4(
|
||||||
|
"Liste des marchés par département", id="liste_marches"
|
||||||
|
),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
|
- [Marchés par département](/departements)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# Table of Contents Column
|
||||||
|
html.Div(
|
||||||
|
className="a-propos-toc",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
[
|
||||||
|
html.A(
|
||||||
|
"Consommer les données brutes",
|
||||||
|
href="#donnees-brutes",
|
||||||
|
className="toc-link",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Contact", href="#contact", className="toc-link"
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Pour contribuer",
|
||||||
|
href="#contribuer",
|
||||||
|
className="toc-link",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Pour explorer le projet",
|
||||||
|
href="#explorer",
|
||||||
|
className="toc-link",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Qualité et exhaustivité des données",
|
||||||
|
href="#qualite-exhausitivite",
|
||||||
|
className="toc-link",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Sources de données",
|
||||||
|
href="#sources",
|
||||||
|
className="toc-link",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Mentions légales",
|
||||||
|
href="#mentions-legales",
|
||||||
|
className="toc-link",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Publication",
|
||||||
|
href="#publication",
|
||||||
|
className="toc-link toc-level-2",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Suivi d'audience",
|
||||||
|
href="#audience",
|
||||||
|
className="toc-link toc-level-2",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Attributions",
|
||||||
|
href="#attributions",
|
||||||
|
className="toc-link toc-level-2",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from dash import dcc, html, register_page
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
from src.figures import get_sources_tables
|
|
||||||
|
|
||||||
title = "À propos"
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
register_page(
|
|
||||||
__name__, path="/a-propos", title=f"decp.info - {title}", name=title, order=5
|
|
||||||
)
|
|
||||||
|
|
||||||
layout = [
|
|
||||||
html.Div(
|
|
||||||
className="container",
|
|
||||||
children=[
|
|
||||||
html.H2(title),
|
|
||||||
dcc.Markdown(
|
|
||||||
"""Outil d'exploration libre et gratuit des [Données Essentielles de la Commande Publique](), 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
|
|
||||||
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.
|
|
||||||
|
|
||||||
En effet, le potentiel des données d'attribution de marchés et des données qui peuvent les enrichir est très loin d'être exploité par
|
|
||||||
les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
|
|
||||||
|
|
||||||
- de nombreuses visualisations de données (cartes, graphiques, tableaux) sur des thématiques variées (vivacité de la concurrence, secteurs d'activité, insertion par l'activité économique (IAE), distance acheteur-fournisseur...)
|
|
||||||
- la sauvegarde de filtres pour les retrouver plus tard et les partager
|
|
||||||
- des alertes par email si des marchés correspondant à certains critères
|
|
||||||
- le développement d'une API pour alimenter d'autres logiciels
|
|
||||||
- ...et toutes les fonctionnalités auxquelles vous pourrez penser
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
html.H4("Pour contribuer", id="contribuer"),
|
|
||||||
dcc.Markdown("""
|
|
||||||
- via l'achat d'une prestation de service (devis, prestation, facture), vous pouvez financer le développement de [fonctionnalités prévues](https://github.com/ColinMaudry/decp.info/issues), ou d'autres !
|
|
||||||
- ma société accepte aussi les dons (pas de réduction d'impôt possible)
|
|
||||||
- envoyez un mail et on discute !
|
|
||||||
|
|
||||||
#### Pour explorer le projet
|
|
||||||
|
|
||||||
- ✉️ [inscription à la liste de diffusion](https://6254d9a3.sibforms.com/serve/MUIFAEonUVkoSVrdgey18CTgLyI16xw4yeu-M-YOUzhWE_AgfQfbgkyT7GvA_RYLro9MfuRqkzQxSvu7-uzbMSv2a2ZQPsliM7wtiiqIL8kR2zOvl6m11fb5qjcOxMAYsLiY_YBi3P7NY95CTJ8vRY4CpsDclF2iLooOElKkTgIgi5nePe7zAIrgiYM5v2EuALlGJZMEG9vBP-Cu) (annonces des mises à jour et évènements, maximum une fois par mois)
|
|
||||||
- 💾 [données consolidées en Open Data](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire/)
|
|
||||||
- 🗞️ [mon blog](https://colin.maudry.com), qui parle beaucoup de transparence des marchés publics
|
|
||||||
- 📔 [wiki du projet](https://github.com/ColinMaudry/decp-processing/wiki)
|
|
||||||
- 🚰 code source
|
|
||||||
- [de decp.info](https://github.com/ColinMaudry/decp.info)
|
|
||||||
- [du traitement des données](https://github.com/ColinMaudry/decp-processing)
|
|
||||||
"""),
|
|
||||||
html.H4("Contact", id="contact"),
|
|
||||||
dcc.Markdown("""
|
|
||||||
- Email : [colin+decp@maudry.com](mailto:colin+decp@maudry.com)
|
|
||||||
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
|
|
||||||
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
|
|
||||||
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
|
|
||||||
- venez discuter de la transparence de la commande publique [sur le forum teamopendata.org](https://teamopendata.org/c/commande-publique/101)
|
|
||||||
"""),
|
|
||||||
html.H4("Sources de données", id="sources"),
|
|
||||||
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
|
|
||||||
html.H4("Mentions légales", id="mentions-legales"),
|
|
||||||
dcc.Markdown("""
|
|
||||||
##### Publication
|
|
||||||
|
|
||||||
Site Web développé et édité par [SAS Colmo](https://annuaire-entreprises.data.gouv.fr/entreprise/colmo-989393350), 989 393 350 RCS Rennes au capital de 3 000 euros.
|
|
||||||
|
|
||||||
Siège social : 1 carrefour Jouaust, 35000 Rennes
|
|
||||||
|
|
||||||
Hébergement : serveur situé en France et administré par Scaleway, 8 rue de la Ville l’Evêque, 75008 Paris
|
|
||||||
|
|
||||||
##### Suivi d'audience
|
|
||||||
|
|
||||||
Ce site dépose un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le consultez ([Wikipédia](https://fr.wikipedia.org/wiki/Cookie_(informatique))). Cela me permet de mesurer le nombre de visites, de distinguer les nouveaux visiteurs des utilisateurs réguliers et ainsi de communiquer sur l'impact de decp.info.
|
|
||||||
|
|
||||||
**Ce site n’affiche pas de bannière de consentement aux cookies, pourquoi ?**
|
|
||||||
|
|
||||||
C’est vrai, vous n’avez pas eu à cliquer sur un bloc qui recouvre la moitié de la page pour dire que vous êtes d’accord avec le dépôt de cookies.
|
|
||||||
|
|
||||||
Rien d’exceptionnel, je respecte simplement la loi, qui dit que certains outils de suivi d’audience, correctement configurés pour respecter la vie privée, sont exemptés d’autorisation préalable.
|
|
||||||
|
|
||||||
J’utilise pour cela [Matomo](https://matomo.org/), un outil [libre](https://matomo.org/free-software/), paramétré pour être en conformité avec [la recommandation « Cookies »](https://www.cnil.fr/fr/solutions-pour-les-cookies-de-mesure-daudience) de la CNIL. Cela signifie que votre adresse IP, par exemple, est anonymisée avant d’être enregistrée. Il m’est donc impossible d’associer vos visites sur ce site à votre personne."""),
|
|
||||||
# Matomo propose cependant ce formulaire si vous souhaitez totalement désactiver le suivi de vos sessions sur ce site :"""),
|
|
||||||
# html.Div(
|
|
||||||
# id="matomo-opt-out",
|
|
||||||
# style={
|
|
||||||
# "border": "1pt solid lightgrey",
|
|
||||||
# "padding": "12px",
|
|
||||||
# "margin": "auto 0 auto 12px",
|
|
||||||
# "width": "80%",
|
|
||||||
# },
|
|
||||||
# children=["Vous utilisez un bloqueur de suivi de trafic."],
|
|
||||||
# ),
|
|
||||||
# html.Script(
|
|
||||||
# src="https://analytics.maudry.com/index.php?module=CoreAdminHome&action=optOutJS&divId=matomo-opt-out&language=auto&showIntro=1"
|
|
||||||
# ),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import dash_bootstrap_components as dbc
|
||||||
|
import polars as pl
|
||||||
|
from dash import (
|
||||||
|
ClientsideFunction,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
State,
|
||||||
|
callback,
|
||||||
|
clientside_callback,
|
||||||
|
dcc,
|
||||||
|
html,
|
||||||
|
register_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
from src.figures import (
|
||||||
|
DataTable,
|
||||||
|
get_distance_histogram,
|
||||||
|
get_top_org_table,
|
||||||
|
make_card,
|
||||||
|
make_column_picker,
|
||||||
|
point_on_map,
|
||||||
|
)
|
||||||
|
from src.utils import (
|
||||||
|
columns,
|
||||||
|
df,
|
||||||
|
df_acheteurs,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_title(acheteur_id: str = None) -> str:
|
||||||
|
acheteur_nom = df_acheteurs.filter(pl.col("acheteur_id") == acheteur_id).select(
|
||||||
|
"acheteur_nom"
|
||||||
|
)
|
||||||
|
if acheteur_nom.height > 0:
|
||||||
|
return f"Marchés publics attribués par {acheteur_nom.item(0, 0)} | decp.info"
|
||||||
|
return "Marchés publics attribués | decp.info"
|
||||||
|
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path_template="/acheteurs/<acheteur_id>",
|
||||||
|
title=get_title,
|
||||||
|
name="Acheteur",
|
||||||
|
description="Consultez les marchés publics attribués par cet acheteur.",
|
||||||
|
image_url=meta_content["image_url"],
|
||||||
|
order=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
datatable = html.Div(
|
||||||
|
className="marches_table",
|
||||||
|
children=DataTable(
|
||||||
|
dtid="acheteur_datatable",
|
||||||
|
persistence=True,
|
||||||
|
persistence_type="local",
|
||||||
|
persisted_props=["filter_query", "sort_by"],
|
||||||
|
page_action="custom",
|
||||||
|
filter_action="custom",
|
||||||
|
sort_action="custom",
|
||||||
|
page_size=10,
|
||||||
|
hidden_columns=[],
|
||||||
|
columns=[{"id": col, "name": col} for col in df.columns],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = [
|
||||||
|
dcc.Store(id="acheteur_data", storage_type="memory"),
|
||||||
|
dcc.Store(id="acheteur-hidden-columns", storage_type="local"),
|
||||||
|
dcc.Store(id="filter-cleanup-trigger-acheteur"),
|
||||||
|
dcc.Location(id="acheteur_url", refresh="callback-nav"),
|
||||||
|
html.Div(
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
style={"marginBottom": "50px"},
|
||||||
|
children=[
|
||||||
|
dbc.Row(
|
||||||
|
className="mb-2",
|
||||||
|
children=[
|
||||||
|
dbc.Col(
|
||||||
|
html.H2(
|
||||||
|
children=[
|
||||||
|
html.Span(id="acheteur_siret"),
|
||||||
|
" - ",
|
||||||
|
html.Span(id="acheteur_nom"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
width=8,
|
||||||
|
),
|
||||||
|
dbc.Col(
|
||||||
|
dcc.Dropdown(
|
||||||
|
id="acheteur_year",
|
||||||
|
options=["Toutes les années"]
|
||||||
|
+ [
|
||||||
|
str(year)
|
||||||
|
for year in range(
|
||||||
|
2018, int(datetime.date.today().year) + 1
|
||||||
|
)
|
||||||
|
],
|
||||||
|
placeholder="Année",
|
||||||
|
),
|
||||||
|
width=4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
dbc.Row(
|
||||||
|
className="mb-2",
|
||||||
|
children=[
|
||||||
|
dbc.Col(
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
width=4,
|
||||||
|
),
|
||||||
|
dbc.Col(
|
||||||
|
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",
|
||||||
|
className="btn btn-primary",
|
||||||
|
),
|
||||||
|
dcc.Download(id="download-data-acheteur"),
|
||||||
|
],
|
||||||
|
width=4,
|
||||||
|
),
|
||||||
|
dbc.Col(
|
||||||
|
id="acheteur_map",
|
||||||
|
width=4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
dbc.Row(
|
||||||
|
children=[
|
||||||
|
dbc.Col(
|
||||||
|
className="marches_table",
|
||||||
|
id="top10_titulaires",
|
||||||
|
width=8,
|
||||||
|
),
|
||||||
|
dbc.Col(id="acheteur-distance-histogram", width=4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# 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(
|
||||||
|
[
|
||||||
|
# Bouton modal des colonnes affichées
|
||||||
|
dbc.Button(
|
||||||
|
"Colonnes affichées",
|
||||||
|
id="acheteur_columns_open",
|
||||||
|
className="column_list",
|
||||||
|
),
|
||||||
|
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",
|
||||||
|
className="btn btn-primary",
|
||||||
|
disabled=True,
|
||||||
|
),
|
||||||
|
dcc.Download(id="acheteur-download-filtered-data"),
|
||||||
|
dbc.Button(
|
||||||
|
"Remise à zéro",
|
||||||
|
title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
|
||||||
|
id="btn-acheteur-reset",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
className="table-menu",
|
||||||
|
),
|
||||||
|
dbc.Modal(
|
||||||
|
[
|
||||||
|
dbc.ModalHeader(
|
||||||
|
dbc.ModalTitle("Choix des colonnes à afficher")
|
||||||
|
),
|
||||||
|
dbc.ModalBody(
|
||||||
|
id="acheteur_columns_body",
|
||||||
|
children=make_column_picker("acheteur"),
|
||||||
|
),
|
||||||
|
dbc.ModalFooter(
|
||||||
|
dbc.Button(
|
||||||
|
"Fermer",
|
||||||
|
id="acheteur_columns_close",
|
||||||
|
className="ms-auto",
|
||||||
|
n_clicks=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
id="acheteur_columns",
|
||||||
|
is_open=False,
|
||||||
|
fullscreen="md-down",
|
||||||
|
scrollable=True,
|
||||||
|
size="xl",
|
||||||
|
),
|
||||||
|
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="acheteur_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.get("matching_etablissements") if data else None
|
||||||
|
if data_etablissement:
|
||||||
|
data_etablissement = data_etablissement[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}"
|
||||||
|
)
|
||||||
|
raison_sociale = data["nom_raison_sociale"]
|
||||||
|
libelle_commune = data_etablissement["libelle_commune"]
|
||||||
|
|
||||||
|
else:
|
||||||
|
acheteur_map = html.Div()
|
||||||
|
code_departement, nom_departement, nom_region = "", "", ""
|
||||||
|
departement = ""
|
||||||
|
lien_annuaire = ""
|
||||||
|
raison_sociale = ""
|
||||||
|
libelle_commune = ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
acheteur_siret,
|
||||||
|
raison_sociale,
|
||||||
|
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="acheteur_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 les années":
|
||||||
|
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"),
|
||||||
|
Output("filter-cleanup-trigger-acheteur", "data"),
|
||||||
|
Input("acheteur_url", "href"),
|
||||||
|
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(
|
||||||
|
href, 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, "acheteur"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="top10_titulaires", component_property="children"),
|
||||||
|
Input(component_id="acheteur_data", component_property="data"),
|
||||||
|
)
|
||||||
|
def get_top_titulaires(data):
|
||||||
|
table = get_top_org_table(data, "titulaire", ["titulaire_distance"])
|
||||||
|
return make_card(fig=table, title="Top titulaires", lg=12, xl=12)
|
||||||
|
|
||||||
|
|
||||||
|
@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: list[dict[str, Any]],
|
||||||
|
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 les années", 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, "ach download")
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Pour nettoyer les icontains et i< des filtres
|
||||||
|
# voir aussi src/assets/dash_clientside.js
|
||||||
|
clientside_callback(
|
||||||
|
ClientsideFunction(
|
||||||
|
namespace="clientside",
|
||||||
|
function_name="clean_filters",
|
||||||
|
),
|
||||||
|
Output("filter-cleanup-trigger-acheteur", "data", allow_duplicate=True),
|
||||||
|
Input("filter-cleanup-trigger-acheteur", "data"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("acheteur-hidden-columns", "data", allow_duplicate=True),
|
||||||
|
Input("acheteur_column_list", "selected_rows"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def update_hidden_columns_from_checkboxes(selected_columns):
|
||||||
|
if selected_columns:
|
||||||
|
selected_columns = [columns[i] for i in selected_columns]
|
||||||
|
hidden_columns = [col for col in columns if col not in selected_columns]
|
||||||
|
return hidden_columns
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("acheteur_datatable", "hidden_columns", allow_duplicate=True),
|
||||||
|
Input(
|
||||||
|
"acheteur-hidden-columns",
|
||||||
|
"data",
|
||||||
|
),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def store_hidden_columns(hidden_columns):
|
||||||
|
return hidden_columns
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("acheteur_column_list", "selected_rows"),
|
||||||
|
Input("acheteur_datatable", "hidden_columns"),
|
||||||
|
State("acheteur_column_list", "selected_rows"), # pour éviter la boucle infinie
|
||||||
|
)
|
||||||
|
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
|
||||||
|
hidden_cols = hidden_cols or get_default_hidden_columns("acheteur")
|
||||||
|
|
||||||
|
# Show all columns that are NOT hidden
|
||||||
|
visible_cols = [columns.index(col) for col in columns if col not in hidden_cols]
|
||||||
|
return visible_cols
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("acheteur_columns", "is_open"),
|
||||||
|
Input("acheteur_columns_open", "n_clicks"),
|
||||||
|
Input("acheteur_columns_close", "n_clicks"),
|
||||||
|
State("acheteur_columns", "is_open"),
|
||||||
|
)
|
||||||
|
def toggle_acheteur_columns(click_open, click_close, is_open):
|
||||||
|
if click_open or click_close:
|
||||||
|
return not is_open
|
||||||
|
return is_open
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("acheteur_datatable", "filter_query", allow_duplicate=True),
|
||||||
|
Output("acheteur_datatable", "sort_by"),
|
||||||
|
Input("btn-acheteur-reset", "n_clicks"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def reset_view(n_clicks):
|
||||||
|
return "", []
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("acheteur-distance-histogram", "children"),
|
||||||
|
Input("acheteur_data", "data"),
|
||||||
|
)
|
||||||
|
def update_acheteur_distance_histogram(data):
|
||||||
|
lff = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
|
||||||
|
fig = get_distance_histogram(lff)
|
||||||
|
return make_card(
|
||||||
|
title="Distance acheteur–titulaire",
|
||||||
|
subtitle="en nombre de marchés, échelle logarithmique",
|
||||||
|
fig=fig,
|
||||||
|
lg=12,
|
||||||
|
xl=12,
|
||||||
|
)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import polars as pl
|
||||||
|
from dash import Input, Output, callback, dcc, html, register_page
|
||||||
|
|
||||||
|
from src.utils import departements, df_acheteurs_departement, df_titulaires_departement
|
||||||
|
|
||||||
|
name = "Département"
|
||||||
|
|
||||||
|
|
||||||
|
def get_title(code):
|
||||||
|
return f"Marchés publics de {departements[code]['departement']} | decp.info"
|
||||||
|
|
||||||
|
|
||||||
|
def get_description(code):
|
||||||
|
return f"Marchés publics passés dans le département {departements[code]['departement']} | decp.info"
|
||||||
|
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path_template="/departements/<code>",
|
||||||
|
title=get_title,
|
||||||
|
description=get_description,
|
||||||
|
order=50,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = html.Div(
|
||||||
|
[
|
||||||
|
dcc.Location(id="departement_url", refresh="callback-nav"),
|
||||||
|
html.Div(id="departement_marches"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="departement_marches", component_property="children"),
|
||||||
|
Input(component_id="departement_url", component_property="pathname"),
|
||||||
|
)
|
||||||
|
def departement_marches(url):
|
||||||
|
departement = url.split("/")[-1]
|
||||||
|
|
||||||
|
def make_link_list(org_type) -> list:
|
||||||
|
link_list = []
|
||||||
|
if org_type == "acheteur":
|
||||||
|
df = df_acheteurs_departement
|
||||||
|
elif org_type == "titulaire":
|
||||||
|
df = df_titulaires_departement
|
||||||
|
else:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
df = df.filter(pl.col(f"{org_type}_departement_code") == departement)
|
||||||
|
|
||||||
|
for row in df.iter_rows(named=True):
|
||||||
|
li = html.Li(
|
||||||
|
[
|
||||||
|
dcc.Link(
|
||||||
|
row[f"{org_type}_nom"],
|
||||||
|
href=url + f"/{org_type}/{row[f'{org_type}_id']}",
|
||||||
|
title=f"Marchés publics de {row[f'{org_type}_nom']}",
|
||||||
|
),
|
||||||
|
" ",
|
||||||
|
dcc.Link(
|
||||||
|
"(page dédiée)",
|
||||||
|
href=f"/{org_type}s/{row[f'{org_type}_id']}",
|
||||||
|
title=f"Page dédiée aux marchés publics de {row[f'{org_type}_nom']}",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
link_list.append(li)
|
||||||
|
return link_list
|
||||||
|
|
||||||
|
content = [
|
||||||
|
html.H3("Acheteurs publics du département"),
|
||||||
|
html.Ul(make_link_list("acheteur")),
|
||||||
|
html.H3("Titulaires du département"),
|
||||||
|
html.Ul(make_link_list("titulaire")),
|
||||||
|
]
|
||||||
|
|
||||||
|
return content
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from dash import dcc, html, register_page
|
||||||
|
|
||||||
|
from src.utils import departements
|
||||||
|
|
||||||
|
name = "Départements"
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path="/departements",
|
||||||
|
title="Marchés par département | decp.info",
|
||||||
|
name="Départements",
|
||||||
|
description="Tous les marchés publics, classés par départements",
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = html.Div(
|
||||||
|
[
|
||||||
|
html.H3("Départements"),
|
||||||
|
html.Ul(
|
||||||
|
[
|
||||||
|
html.Li(dcc.Link(d["departement"], href=f"/departements/{k}"))
|
||||||
|
for k, d in departements.items()
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import polars as pl
|
||||||
|
from dash import Input, Output, callback, dcc, html, register_page
|
||||||
|
|
||||||
|
from src.utils import (
|
||||||
|
df_acheteurs,
|
||||||
|
df_acheteurs_marches,
|
||||||
|
df_titulaires,
|
||||||
|
df_titulaires_marches,
|
||||||
|
)
|
||||||
|
|
||||||
|
name = "Liste des marchés publics"
|
||||||
|
|
||||||
|
|
||||||
|
def make_org_nom_verbe(org_type, org_id) -> tuple:
|
||||||
|
if org_type == "titulaire":
|
||||||
|
df = df_titulaires
|
||||||
|
verbe = "remportés"
|
||||||
|
elif org_type == "acheteur":
|
||||||
|
df = df_acheteurs
|
||||||
|
verbe = "attribués"
|
||||||
|
else:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
org_nom = (
|
||||||
|
df.filter(pl.col(f"{org_type}_id") == org_id)
|
||||||
|
.select(f"{org_type}_nom")
|
||||||
|
.item(0, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
return org_nom, verbe
|
||||||
|
|
||||||
|
|
||||||
|
def get_title(code, org_type, org_id):
|
||||||
|
org_nom, verbe = make_org_nom_verbe(org_type, org_id)
|
||||||
|
|
||||||
|
return f"Marchés publics {verbe} par {org_nom} | decp.info"
|
||||||
|
|
||||||
|
|
||||||
|
def get_description(code, org_type, org_id):
|
||||||
|
org_nom, verbe = make_org_nom_verbe(org_type, org_id)
|
||||||
|
|
||||||
|
return f"Liste complète des marchés publics {verbe} par {org_nom} et publiés par decp.info. Cliquez sur les liens pour consulter les détails de chaque marché."
|
||||||
|
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path_template="/departements/<code>/<org_type>/<org_id>",
|
||||||
|
title=get_title,
|
||||||
|
description=get_description,
|
||||||
|
order=40,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = html.Div(
|
||||||
|
[
|
||||||
|
dcc.Location(id="liste_marches_url", refresh="callback-nav"),
|
||||||
|
html.Div(id="liste_marches"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="liste_marches", component_property="children"),
|
||||||
|
Input(component_id="liste_marches_url", component_property="pathname"),
|
||||||
|
)
|
||||||
|
def liste_marches(url):
|
||||||
|
org_type = url.split("/")[-2]
|
||||||
|
org_id = url.split("/")[-1]
|
||||||
|
|
||||||
|
def make_link_list() -> list:
|
||||||
|
link_list = []
|
||||||
|
if org_type == "acheteur":
|
||||||
|
df = df_acheteurs_marches
|
||||||
|
elif org_type == "titulaire":
|
||||||
|
df = df_titulaires_marches
|
||||||
|
else:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
df = df.filter(pl.col(f"{org_type}_id") == org_id)
|
||||||
|
|
||||||
|
for row in df.iter_rows(named=True):
|
||||||
|
li = html.Li(
|
||||||
|
[
|
||||||
|
dcc.Link(
|
||||||
|
row["objet"],
|
||||||
|
href=f"/marches/{row['uid']}",
|
||||||
|
title=f"Marchés public attribué : {row['objet']}",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
link_list.append(li)
|
||||||
|
return link_list
|
||||||
|
|
||||||
|
nom, verbe = make_org_nom_verbe(org_type, org_id)
|
||||||
|
|
||||||
|
content = [
|
||||||
|
html.H3(f"Marchés publics {verbe} par {nom}"),
|
||||||
|
html.Ul(make_link_list()),
|
||||||
|
]
|
||||||
|
|
||||||
|
return content
|
||||||
@@ -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,268 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import dash_bootstrap_components as dbc
|
||||||
|
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,
|
||||||
|
make_org_jsonld,
|
||||||
|
meta_content,
|
||||||
|
unformat_montant,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_title(uid: str = None) -> str:
|
||||||
|
return f"Marché {uid} | decp.info"
|
||||||
|
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path_template="/marches/<uid>",
|
||||||
|
title=get_title,
|
||||||
|
name="Marché",
|
||||||
|
description="Consultez les détails de ce marché public : montant, acheteur, titulaires, modifications, etc.",
|
||||||
|
image_url=meta_content["image_url"],
|
||||||
|
order=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = [
|
||||||
|
dcc.Store(id="marche_data"),
|
||||||
|
dcc.Store(id="titulaires_data"),
|
||||||
|
dcc.Location(id="marche_url", refresh="callback-nav"),
|
||||||
|
html.Script(type="application/ld+json", id="marche_jsonld"),
|
||||||
|
dbc.Container(
|
||||||
|
className="marche_infos",
|
||||||
|
children=[
|
||||||
|
dbc.Row(
|
||||||
|
dbc.Col(
|
||||||
|
[
|
||||||
|
html.H1(id="marche_objet", style={"fontSize": "1.5em"}),
|
||||||
|
html.P(
|
||||||
|
"Vous consultez un résumé des données de ce marché public"
|
||||||
|
),
|
||||||
|
html.Ul(
|
||||||
|
[
|
||||||
|
html.Li(
|
||||||
|
"après son attribution aux titulaires qui l'ont remporté à la suite d'un appel d'offres (ou sans appel d'offres via une attribution directe)"
|
||||||
|
),
|
||||||
|
html.Li(
|
||||||
|
"après avoir appliqué les éventuelles modifications de montant, durée ou titulaires renseignées par l'acheteur"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
html.P(
|
||||||
|
"Le montant total payé aux titulaires, la durée du marché et la liste des titulaires peuvent cependant encore évoluer jusqu'à la fin de l'exécution du marché."
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
dbc.Row(
|
||||||
|
[
|
||||||
|
dbc.Col(id="marche_infos_1", width=12, md=4),
|
||||||
|
dbc.Col(id="marche_infos_2", width=12, md=4),
|
||||||
|
dbc.Col(
|
||||||
|
width=12,
|
||||||
|
md=4,
|
||||||
|
children=[
|
||||||
|
html.H4("Titulaires"),
|
||||||
|
html.Ul(id="marche_infos_titulaires"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("marche_data", "data"),
|
||||||
|
Output("titulaires_data", "data"),
|
||||||
|
Input(component_id="marche_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_objet", "children"),
|
||||||
|
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, bold=True):
|
||||||
|
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 = ""
|
||||||
|
|
||||||
|
value = html.Strong(value) if bold else value
|
||||||
|
param_content = html.P([column_name, " : ", value])
|
||||||
|
return param_content
|
||||||
|
|
||||||
|
marche_objet = make_parameter("objet", bold=False)
|
||||||
|
|
||||||
|
marche_infos = [
|
||||||
|
make_parameter("id"),
|
||||||
|
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("dureeRestanteMois"),
|
||||||
|
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":
|
||||||
|
categorie = titulaire.get("titulaire_categorie", "")
|
||||||
|
if titulaire.get("titulaire_distance"):
|
||||||
|
distance = str(titulaire.get("titulaire_distance")) + " km"
|
||||||
|
else:
|
||||||
|
distance = ""
|
||||||
|
|
||||||
|
content = html.Li(
|
||||||
|
[
|
||||||
|
html.A(
|
||||||
|
href=f"/titulaires/{titulaire['titulaire_id']}",
|
||||||
|
children=titulaire["titulaire_nom"],
|
||||||
|
),
|
||||||
|
f" ({categorie}, {distance})",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content = html.Li(titulaire["titulaire_nom"])
|
||||||
|
titulaires_lines.append(content)
|
||||||
|
|
||||||
|
return marche_objet, marche_infos[:half], marche_infos[half:], titulaires_lines
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="marche_jsonld", component_property="children"),
|
||||||
|
Input("marche_data", "data"),
|
||||||
|
Input("titulaires_data", "data"),
|
||||||
|
)
|
||||||
|
def get_marche_jsonld(marche, titulaires) -> str:
|
||||||
|
acheteur_id = marche.get("acheteur_id")
|
||||||
|
type_order = (
|
||||||
|
"Service" if marche.get("categorie") in ["Services", "Travaux"] else "Product"
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for titulaire in titulaires:
|
||||||
|
jsonld = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Order",
|
||||||
|
"@id": f"https://decp.info/marches/{marche.get('uid')}",
|
||||||
|
"name": f"{marche.get('nature')} conclu par {marche.get('acheteur_nom')} le {marche.get('dateNotification')}",
|
||||||
|
"description": marche.get("objet"),
|
||||||
|
"orderNumber": marche.get("uid"),
|
||||||
|
"orderDate": marche.get("dateNotification"),
|
||||||
|
"price": unformat_montant(marche.get("montant")),
|
||||||
|
"priceCurrency": "EUR",
|
||||||
|
"customer": make_org_jsonld(
|
||||||
|
acheteur_id, org_name=marche.get("acheteur_nom"), org_type="acheteur"
|
||||||
|
),
|
||||||
|
"seller": make_org_jsonld(
|
||||||
|
titulaire.get("titulaire_id"),
|
||||||
|
org_name=titulaire.get("titulaire_nom"),
|
||||||
|
org_type="titulaire",
|
||||||
|
type_org_id=titulaire.get("titulaire_typeIdentifiant"),
|
||||||
|
),
|
||||||
|
"orderedItem": {
|
||||||
|
"@type": type_order,
|
||||||
|
"name": marche.get("objet"),
|
||||||
|
"category": {
|
||||||
|
"@type": "CategoryCode",
|
||||||
|
"propertyID": "cpv",
|
||||||
|
"codeValue": marche.get("codeCPV"),
|
||||||
|
# "description": "Description du code CPV"
|
||||||
|
},
|
||||||
|
# "serviceType": "Description du code CPV"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result.append(jsonld)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
|||||||
|
import dash_bootstrap_components as dbc
|
||||||
|
from dash import Input, Output, State, 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="Recherche de marchés publics | decp.info",
|
||||||
|
name=name,
|
||||||
|
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.",
|
||||||
|
image_url=meta_content["image_url"],
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = html.Div(
|
||||||
|
className="container",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
className="tagline",
|
||||||
|
children=html.P("Recherchez un acheteur ou un titulaire de marché public"),
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
style={
|
||||||
|
"display": "flex",
|
||||||
|
"justifyContent": "center",
|
||||||
|
"marginTop": "30px",
|
||||||
|
"marginBottom": "30px",
|
||||||
|
},
|
||||||
|
children=[
|
||||||
|
dcc.Input(
|
||||||
|
id="search",
|
||||||
|
type="text",
|
||||||
|
placeholder="Nom d'acheteur/entreprise, SIREN/SIRET, code département",
|
||||||
|
autoFocus=True,
|
||||||
|
style={
|
||||||
|
"margin": "0",
|
||||||
|
"width": "500px",
|
||||||
|
"border": "1px solid #ccc",
|
||||||
|
"borderRight": "none",
|
||||||
|
"borderRadius": "3px 0 0 3px",
|
||||||
|
"padding": "5px 10px",
|
||||||
|
"outline": "none",
|
||||||
|
"height": "34px",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
html.Button(
|
||||||
|
"=>",
|
||||||
|
id="search-button",
|
||||||
|
className="btn btn-primary",
|
||||||
|
style={
|
||||||
|
"border": "1px solid #ccc",
|
||||||
|
"borderRadius": "0 3px 3px 0",
|
||||||
|
"marginLeft": "0",
|
||||||
|
"height": "auto", # Ensure it matches input height if necessary, often relying on padding/line-height
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
html.P(
|
||||||
|
[
|
||||||
|
"...ou bien filtrez les marchés publics dans la vue ",
|
||||||
|
dcc.Link("Tableau", href="/tableau"),
|
||||||
|
],
|
||||||
|
style={"textAlign": "center"},
|
||||||
|
id="mention_tableau",
|
||||||
|
),
|
||||||
|
# html.Div(
|
||||||
|
# className="search_options",
|
||||||
|
# children=[dcc.RadioItems(options=["Acheteur(s)"])],
|
||||||
|
# ),
|
||||||
|
dbc.Row(id="search_results"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("search_results", "children"),
|
||||||
|
Output("mention_tableau", "style"),
|
||||||
|
Input("search", "n_submit"),
|
||||||
|
Input("search-button", "n_clicks"),
|
||||||
|
State("search", "value"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def update_search_results(n_submit, n_clicks, query):
|
||||||
|
if query and len(query) >= 1:
|
||||||
|
cols = []
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
col = (
|
||||||
|
dbc.Col(
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
md=6,
|
||||||
|
)
|
||||||
|
if count > 0
|
||||||
|
else html.P(f"Aucun {org_type} trouvé.")
|
||||||
|
)
|
||||||
|
cols.append(col)
|
||||||
|
|
||||||
|
style = {"textAlign": "center", "display": "none"}
|
||||||
|
return cols, style
|
||||||
|
return html.P(""), {"textAlign": "center"}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
from dash import dcc, html, register_page
|
|
||||||
|
|
||||||
from src.figures import get_barchart_sources, get_map_count_marches
|
|
||||||
from src.utils import lf
|
|
||||||
|
|
||||||
title = "Statistiques"
|
|
||||||
|
|
||||||
register_page(
|
|
||||||
__name__, path="/statistiques", title=f"decp.info - {title}", name=title, order=3
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
layout = [
|
|
||||||
html.Div(
|
|
||||||
className="container",
|
|
||||||
children=[
|
|
||||||
html.H2(title),
|
|
||||||
dcc.Loading(
|
|
||||||
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
|
||||||
id="loading-statistques",
|
|
||||||
type="default",
|
|
||||||
children=[
|
|
||||||
html.Div(
|
|
||||||
children=[
|
|
||||||
dcc.Markdown("""
|
|
||||||
La publication de données essentielles de marchés publics (DECP) est souvent effectuée par
|
|
||||||
les plateformes de marchés publics (profils d'acheteurs). Cependant, certaines plateformes ne publient pas,
|
|
||||||
ou publient d'une manière qui rend la récupération des données compliquée. Les données présentées sur ce site
|
|
||||||
ne représentent donc pas tous les marchés attribués en France, seulement une partie significative.
|
|
||||||
|
|
||||||
L'ajout de nouvelles plateformes [est en cours](https://github.com/ColinMaudry/decp-processing/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22source%20de%20donn%C3%A9es%22),
|
|
||||||
toutes les [contributions](/a-propos#contribuer) sont les bienvenues pour atteindre l'exhaustivité.
|
|
||||||
"""),
|
|
||||||
dcc.Graph(figure=get_map_count_marches(lf)),
|
|
||||||
dcc.Graph(
|
|
||||||
figure=get_barchart_sources(lf, "dateNotification")
|
|
||||||
),
|
|
||||||
dcc.Graph(
|
|
||||||
figure=get_barchart_sources(
|
|
||||||
lf, "datePublicationDonnees"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.parse
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import dash_bootstrap_components as dbc
|
||||||
|
import polars as pl
|
||||||
|
from dash import (
|
||||||
|
ClientsideFunction,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
State,
|
||||||
|
callback,
|
||||||
|
clientside_callback,
|
||||||
|
dcc,
|
||||||
|
html,
|
||||||
|
no_update,
|
||||||
|
register_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
from src.figures import DataTable, make_column_picker
|
||||||
|
from src.utils import (
|
||||||
|
columns,
|
||||||
|
df,
|
||||||
|
filter_table_data,
|
||||||
|
get_default_hidden_columns,
|
||||||
|
invert_columns,
|
||||||
|
logger,
|
||||||
|
meta_content,
|
||||||
|
prepare_table_data,
|
||||||
|
schema,
|
||||||
|
sort_table_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
update_date_timestamp = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
|
||||||
|
update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y")
|
||||||
|
update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
name = "Tableau"
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path="/tableau",
|
||||||
|
title="Tableau des marchés publics | decp.info",
|
||||||
|
name=name,
|
||||||
|
description="Consultez, filtrez et exportez les données essentielles de la commande publique sous forme de tableau.",
|
||||||
|
image_url=meta_content["image_url"],
|
||||||
|
order=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
datatable = html.Div(
|
||||||
|
className="marches_table",
|
||||||
|
children=DataTable(
|
||||||
|
dtid="tableau_datatable",
|
||||||
|
persisted_props=["filter_query", "sort_by"],
|
||||||
|
persistence_type="local",
|
||||||
|
persistence=True,
|
||||||
|
page_size=20,
|
||||||
|
page_action="custom",
|
||||||
|
filter_action="custom",
|
||||||
|
sort_action="custom",
|
||||||
|
hidden_columns=[],
|
||||||
|
columns=[{"id": col, "name": col} for col in df.columns],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = [
|
||||||
|
dcc.Location(id="tableau_url", refresh=False),
|
||||||
|
dcc.Store(id="filter-cleanup-trigger-tableau"),
|
||||||
|
dcc.Store(id="tableau-hidden-columns", storage_type="local"),
|
||||||
|
dcc.Store(id="tableau-table"),
|
||||||
|
html.Script(
|
||||||
|
type="application/ld+json",
|
||||||
|
id="dataset_jsonld",
|
||||||
|
children=[
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org/",
|
||||||
|
"@type": "Dataset",
|
||||||
|
"name": "Données essentielles des marchés publics français (DECP)",
|
||||||
|
"description": "Données de marchés publics exhaustives décrivant les marchés publics attribués en France depuis 2018.",
|
||||||
|
"url": "https://decp.info",
|
||||||
|
"sameAs": "https://www.data.gouv.fr/datasets/608c055b35eb4e6ee20eb325",
|
||||||
|
"keywords": [
|
||||||
|
"marchés publics",
|
||||||
|
"commande publique",
|
||||||
|
"decp",
|
||||||
|
"public procurement",
|
||||||
|
],
|
||||||
|
"license": "https://www.etalab.gouv.fr/licence-ouverte-open-licence",
|
||||||
|
"isAccessibleForFree": True,
|
||||||
|
"creator": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"url": "https://colmo.tech",
|
||||||
|
"name": "Colmo",
|
||||||
|
"sameAs": "https://annuaire-entreprises.data.gouv.fr/entreprise/colmo-989393350",
|
||||||
|
"contactPoint": {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
"contactType": "Support et contact commercial",
|
||||||
|
"email": "colin@colmo.tech",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"includedInDataCatalog": {
|
||||||
|
"@type": "DataCatalog",
|
||||||
|
"name": "data.gouv.fr",
|
||||||
|
},
|
||||||
|
"distribution": [
|
||||||
|
{
|
||||||
|
"@type": "DataDownload",
|
||||||
|
"encodingFormat": "CSV",
|
||||||
|
"contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/22847056-61df-452d-837d-8b8ceadbfc52",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "DataDownload",
|
||||||
|
"encodingFormat": "Parquet",
|
||||||
|
"contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"temporalCoverage": f"2018-01-01/{update_date_iso[:10]}",
|
||||||
|
"spatialCoverage": {
|
||||||
|
"@type": "Place",
|
||||||
|
"address": {"countryCode": "FR"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
dcc.Markdown(
|
||||||
|
f"Ce tableau contient tous les marchés attribués en France. Il vous permet d'appliquer un filtre sur une ou plusieurs colonnes, et ainsi produire la liste de marchés dont vous avez besoin (exemples : [marchés de voirie < 40 k€ en 2025](/tableau?filtres=%7Bacheteur_id%7D+icontains+24350013900189+%26%26+%7BdateNotification%7D+icontains+2025%2A+%26%26+%7Bmontant%7D+i%3C+40000+%26%26+%7Bobjet%7D+icontains+voirie&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2Cacheteur_departement_code%2CsourceDataset), [marchés > 500 k€ avec clause sociale attribués à des PME à plus de 100 km dans le Var](/tableau?filtres=%7Btitulaire_categorie%7D+icontains+PME+%26%26+%7Btitulaire_distance%7D+i%3E+100+%26%26+%7Bmontant%7D+i%3E+500000+%26%26+%7Bacheteur_departement_code%7D+icontains+83+%26%26+%7BconsiderationsSociales%7D+icontains+clause&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2CconsiderationsSociales%2Ctitulaire_distance%2Cacheteur_departement_code%2Ctitulaire_categorie%2CsourceDataset)). Par défaut seules quelques colonnes sont affichées, mais vous pouvez en afficher jusqu'à {str(df.width)} en cliquant sur le bouton **Choisir les colonnes**. Cet outil est assez puissant, je vous recommande de lire le mode d'emploi pour en tirer pleinement partie.",
|
||||||
|
style={"maxWidth": "1000px"},
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
[],
|
||||||
|
id="header",
|
||||||
|
),
|
||||||
|
dcc.Loading(
|
||||||
|
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
||||||
|
id="loading-home",
|
||||||
|
type="default",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
[
|
||||||
|
# Modal du mode d'emploi
|
||||||
|
dbc.Button("Mode d'emploi", id="tableau_help_open"),
|
||||||
|
dbc.Modal(
|
||||||
|
[
|
||||||
|
dbc.ModalHeader(dbc.ModalTitle("Mode d'emploi")),
|
||||||
|
dbc.ModalBody(
|
||||||
|
dcc.Markdown(
|
||||||
|
dangerously_allow_html=True,
|
||||||
|
children=f"""
|
||||||
|
##### Définition des colonnes
|
||||||
|
|
||||||
|
Pour voir la définition d'une colonne, passez votre souris sur son en-tête.
|
||||||
|
|
||||||
|
##### Vos réglages sont persistents
|
||||||
|
|
||||||
|
Les filtres, les tris et le choix de colonnes sont automatiquement enregistrés dans votre navigateur et persistent même si vous changez de page ou si vous fermez votre navigateur. À votre retour, vous retrouverez cette page comme vous l'avez laissée.
|
||||||
|
|
||||||
|
##### Appliquer des 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 retourne les valeurs qui contiennent le texte recherché et n'est pas sensible à la casse (majuscules/minuscules).
|
||||||
|
- Exemple : `rennes` retourne "RENNES METROPOLE".
|
||||||
|
- Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances`
|
||||||
|
- Champs numériques (Durée en mois, Montant, ...) : vous pouvez...
|
||||||
|
- soit taper un nombre pour trouver les valeurs strictement égales. Exemple : `12` ne retourne que des 12
|
||||||
|
- 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 (Date de notification, ...) : vous pouvez également utiliser **>** ou **<**. Exemples :
|
||||||
|
- `< 2024-01-31` pour "avant le 31 janvier 2024"
|
||||||
|
- `2024` pour "en 2024", `> 2022` pour "à partir de 2022".
|
||||||
|
- Pour les champs textuels et les champs dates :
|
||||||
|
- pour chercher du texte qui **commence par** votre texte, entrez `texte*`. C'est par exemple utile pour filtrer des acheteurs ou titulaires par numéro SIREN (`123456789*`) ou les marchés sur une année en particulier (`2024*`)
|
||||||
|
- pour chercher du texte qui **finit par** votre texte, entrez `*texte`
|
||||||
|
|
||||||
|
Vous pouvez filtrer plusieurs colonnes à la fois.
|
||||||
|
|
||||||
|
##### Trier les données
|
||||||
|
|
||||||
|
Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre :
|
||||||
|
|
||||||
|
1. tri croissant
|
||||||
|
2. tri décroissant
|
||||||
|
3. pas de tri
|
||||||
|
|
||||||
|
##### Afficher plus de colonnes
|
||||||
|
|
||||||
|
Par défaut, un nombre réduit de colonnes est affiché pour ne pas surcharger la page. Mais vous avez le choix parmi {str(df.width)} colonnes, ce serait dommage de vous limiter !
|
||||||
|
|
||||||
|
Pour afficher plus de colonnes, cliquez sur le bouton **Choisir les colonnes** et cochez les colonnes pour les afficher.
|
||||||
|
|
||||||
|
##### Partager une vue
|
||||||
|
|
||||||
|
Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqués. Cliquez sur **Partager** pour copier une adresse Web qui reproduit la vue courante à l'identique : 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.)
|
||||||
|
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dbc.ModalFooter(
|
||||||
|
dbc.Button(
|
||||||
|
"Fermer",
|
||||||
|
id="tableau_help_close",
|
||||||
|
className="ms-auto",
|
||||||
|
n_clicks=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
id="tableau_help",
|
||||||
|
is_open=False,
|
||||||
|
fullscreen="md-down",
|
||||||
|
scrollable=True,
|
||||||
|
size="lg",
|
||||||
|
),
|
||||||
|
# Bouton modal des colonnes affichées
|
||||||
|
dbc.Button(
|
||||||
|
"Choisir les colonnes",
|
||||||
|
id="tableau_columns_open",
|
||||||
|
className="column_list",
|
||||||
|
title="Choisir les colonnes à afficher et masquer",
|
||||||
|
),
|
||||||
|
html.P("lignes", id="nb_rows"),
|
||||||
|
html.Div(id="copy-container"),
|
||||||
|
dcc.Input(id="share-url", readOnly=True, style={"display": "none"}),
|
||||||
|
dbc.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)),
|
||||||
|
dbc.Button(
|
||||||
|
"Remettre à zéro",
|
||||||
|
title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
|
||||||
|
id="btn-tableau-reset",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
className="table-menu",
|
||||||
|
),
|
||||||
|
dbc.Modal(
|
||||||
|
[
|
||||||
|
dbc.ModalHeader(dbc.ModalTitle("Choix des colonnes à afficher")),
|
||||||
|
dbc.ModalBody(
|
||||||
|
id="tableau_columns_body",
|
||||||
|
children=make_column_picker("tableau"),
|
||||||
|
),
|
||||||
|
dbc.ModalFooter(
|
||||||
|
dbc.Button(
|
||||||
|
"Fermer",
|
||||||
|
id="tableau_columns_close",
|
||||||
|
className="ms-auto",
|
||||||
|
n_clicks=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
id="tableau_columns",
|
||||||
|
is_open=False,
|
||||||
|
fullscreen="md-down",
|
||||||
|
scrollable=True,
|
||||||
|
size="xl",
|
||||||
|
),
|
||||||
|
datatable,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("tableau_datatable", "data"),
|
||||||
|
Output("tableau_datatable", "columns"),
|
||||||
|
Output("tableau_datatable", "tooltip_header"),
|
||||||
|
Output("tableau_datatable", "data_timestamp"),
|
||||||
|
Output("nb_rows", "children"),
|
||||||
|
Output("btn-download-data", "disabled"),
|
||||||
|
Output("btn-download-data", "children"),
|
||||||
|
Output("btn-download-data", "title"),
|
||||||
|
Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True),
|
||||||
|
Input("tableau_url", "href"),
|
||||||
|
Input("tableau_datatable", "page_current"),
|
||||||
|
Input("tableau_datatable", "page_size"),
|
||||||
|
Input("tableau_datatable", "filter_query"),
|
||||||
|
Input("tableau_datatable", "sort_by"),
|
||||||
|
State("tableau_datatable", "data_timestamp"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def update_table(href, 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, "tableau"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("download-data", "data"),
|
||||||
|
Input("btn-download-data", "n_clicks"),
|
||||||
|
State("tableau_datatable", "filter_query"),
|
||||||
|
State("tableau_datatable", "sort_by"),
|
||||||
|
State("tableau_datatable", "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, "tab download")
|
||||||
|
|
||||||
|
if sort_by and 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("tableau_datatable", "filter_query"),
|
||||||
|
Output("tableau_datatable", "sort_by"),
|
||||||
|
Output("tableau-hidden-columns", "data"),
|
||||||
|
Output("tableau_url", "search"),
|
||||||
|
Output("filter-cleanup-trigger-tableau", "data"),
|
||||||
|
Input("tableau_url", "search"),
|
||||||
|
State("tableau_datatable", "filter_query"),
|
||||||
|
State("tableau_datatable", "sort_by"),
|
||||||
|
)
|
||||||
|
def restore_view_from_url(search, stored_filters, stored_sort):
|
||||||
|
if not search and not stored_filters:
|
||||||
|
return no_update, no_update, no_update, no_update, no_update
|
||||||
|
|
||||||
|
params = urllib.parse.parse_qs(search.lstrip("?")) if search else {}
|
||||||
|
logger.debug("params " + json.dumps(params, indent=2))
|
||||||
|
|
||||||
|
filter_query = no_update
|
||||||
|
sort_by = no_update
|
||||||
|
hidden_columns = no_update
|
||||||
|
trigger_cleanup = no_update
|
||||||
|
|
||||||
|
if "filtres" in params:
|
||||||
|
filter_query = params["filtres"][0]
|
||||||
|
trigger_cleanup = str(uuid.uuid4())
|
||||||
|
elif stored_filters:
|
||||||
|
filter_query = stored_filters
|
||||||
|
trigger_cleanup = str(uuid.uuid4())
|
||||||
|
|
||||||
|
if "tris" in params:
|
||||||
|
try:
|
||||||
|
sort_by = json.loads(params["tris"][0])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
elif stored_sort:
|
||||||
|
sort_by = stored_sort
|
||||||
|
|
||||||
|
if "colonnes" in params:
|
||||||
|
table_columns = params["colonnes"][0].split(",")
|
||||||
|
verified_columns = [
|
||||||
|
column for column in table_columns if column in schema.names()
|
||||||
|
]
|
||||||
|
hidden_columns = invert_columns(verified_columns)
|
||||||
|
|
||||||
|
return filter_query, sort_by, hidden_columns, "", trigger_cleanup
|
||||||
|
|
||||||
|
|
||||||
|
# Pour nettoyer les icontains et i< des filtres
|
||||||
|
# voir aussi src/assets/dash_clientside.js
|
||||||
|
clientside_callback(
|
||||||
|
ClientsideFunction(
|
||||||
|
namespace="clientside",
|
||||||
|
function_name="clean_filters",
|
||||||
|
),
|
||||||
|
Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True),
|
||||||
|
Input("filter-cleanup-trigger-tableau", "data"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("share-url", "value"),
|
||||||
|
Output("copy-container", "children"),
|
||||||
|
Input("tableau_datatable", "filter_query"),
|
||||||
|
Input("tableau_datatable", "sort_by"),
|
||||||
|
Input("tableau_datatable", "hidden_columns"),
|
||||||
|
State("tableau_url", "href"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
table_columns = invert_columns(hidden_columns)
|
||||||
|
table_columns = ",".join(table_columns)
|
||||||
|
params["colonnes"] = table_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",
|
||||||
|
children=[
|
||||||
|
dbc.Button(
|
||||||
|
"Partager la vue",
|
||||||
|
className="btn btn-primary",
|
||||||
|
title="Copier l'adresse de cette vue (filtres, tris, choix de colonnes) pour la partager.",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("tableau_help", "is_open"),
|
||||||
|
[Input("tableau_help_open", "n_clicks"), Input("tableau_help_close", "n_clicks")],
|
||||||
|
[State("tableau_help", "is_open")],
|
||||||
|
)
|
||||||
|
def toggle_tableau_help(click_open, click_close, is_open):
|
||||||
|
if click_open or click_close:
|
||||||
|
return not is_open
|
||||||
|
return is_open
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("tableau-hidden-columns", "data", allow_duplicate=True),
|
||||||
|
Input("tableau_column_list", "selected_rows"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def update_hidden_columns_from_checkboxes(selected_columns):
|
||||||
|
if selected_columns:
|
||||||
|
selected_columns = [columns[i] for i in selected_columns]
|
||||||
|
hidden_columns = [col for col in columns if col not in selected_columns]
|
||||||
|
return hidden_columns
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("tableau_datatable", "hidden_columns"),
|
||||||
|
Input(
|
||||||
|
"tableau-hidden-columns",
|
||||||
|
"data",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def store_hidden_columns(hidden_columns):
|
||||||
|
return hidden_columns
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("tableau_column_list", "selected_rows"),
|
||||||
|
Input("tableau_datatable", "hidden_columns"),
|
||||||
|
State("tableau_column_list", "selected_rows"), # pour éviter la boucle infinie
|
||||||
|
)
|
||||||
|
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
|
||||||
|
hidden_cols = hidden_cols or get_default_hidden_columns("tableau")
|
||||||
|
|
||||||
|
# Show all columns that are NOT hidden
|
||||||
|
visible_cols = [columns.index(col) for col in columns if col not in hidden_cols]
|
||||||
|
return visible_cols
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("tableau_columns", "is_open"),
|
||||||
|
Input("tableau_columns_open", "n_clicks"),
|
||||||
|
Input("tableau_columns_close", "n_clicks"),
|
||||||
|
State("tableau_columns", "is_open"),
|
||||||
|
)
|
||||||
|
def toggle_tableau_columns(click_open, click_close, is_open):
|
||||||
|
if click_open or click_close:
|
||||||
|
return not is_open
|
||||||
|
return is_open
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("tableau_datatable", "filter_query", allow_duplicate=True),
|
||||||
|
Output("tableau_datatable", "sort_by", allow_duplicate=True),
|
||||||
|
Input("btn-tableau-reset", "n_clicks"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def reset_view(n_clicks):
|
||||||
|
return "", []
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import dash_bootstrap_components as dbc
|
||||||
|
import polars as pl
|
||||||
|
from dash import (
|
||||||
|
ClientsideFunction,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
State,
|
||||||
|
callback,
|
||||||
|
clientside_callback,
|
||||||
|
dcc,
|
||||||
|
html,
|
||||||
|
register_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
from src.figures import (
|
||||||
|
DataTable,
|
||||||
|
get_distance_histogram,
|
||||||
|
get_top_org_table,
|
||||||
|
make_column_picker,
|
||||||
|
point_on_map,
|
||||||
|
)
|
||||||
|
from src.utils import (
|
||||||
|
columns,
|
||||||
|
df,
|
||||||
|
df_titulaires,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_title(titulaire_id: str = None) -> str:
|
||||||
|
titulaire_nom = df_titulaires.filter(pl.col("titulaire_id") == titulaire_id).select(
|
||||||
|
"titulaire_nom"
|
||||||
|
)
|
||||||
|
if titulaire_nom.height > 0:
|
||||||
|
return f"Marchés publics remportés par {titulaire_nom.item(0, 0)} | decp.info"
|
||||||
|
return "Marchés publics remportés | decp.info"
|
||||||
|
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path_template="/titulaires/<titulaire_id>",
|
||||||
|
title=get_title,
|
||||||
|
name="Titulaire",
|
||||||
|
description="Consultez les marchés publics remportés par ce titulaire.",
|
||||||
|
image_url=meta_content["image_url"],
|
||||||
|
order=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
datatable = html.Div(
|
||||||
|
className="marches_table",
|
||||||
|
children=DataTable(
|
||||||
|
dtid="titulaire_datatable",
|
||||||
|
persistence=True,
|
||||||
|
persistence_type="local",
|
||||||
|
persisted_props=["filter_query", "sort_by"],
|
||||||
|
page_action="custom",
|
||||||
|
filter_action="custom",
|
||||||
|
sort_action="custom",
|
||||||
|
page_size=10,
|
||||||
|
hidden_columns=[],
|
||||||
|
columns=[{"id": col, "name": col} for col in df.columns],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = [
|
||||||
|
dcc.Store(id="titulaire_data", storage_type="memory"),
|
||||||
|
dcc.Store(id="titulaire-hidden-columns", storage_type="local"),
|
||||||
|
dcc.Store(id="filter-cleanup-trigger-titulaire"),
|
||||||
|
dcc.Location(id="titulaire_url", refresh="callback-nav"),
|
||||||
|
html.Div(
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
style={"marginBottom": "50px"},
|
||||||
|
children=[
|
||||||
|
dbc.Row(
|
||||||
|
className="mb-2",
|
||||||
|
children=[
|
||||||
|
dbc.Col(
|
||||||
|
html.H2(
|
||||||
|
children=[
|
||||||
|
html.Span(id="titulaire_siret"),
|
||||||
|
" - ",
|
||||||
|
html.Span(id="titulaire_nom"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
width=8,
|
||||||
|
),
|
||||||
|
dbc.Col(
|
||||||
|
dcc.Dropdown(
|
||||||
|
id="titulaire_year",
|
||||||
|
options=["Toutes les années"]
|
||||||
|
+ [
|
||||||
|
str(year)
|
||||||
|
for year in range(
|
||||||
|
2018, int(datetime.date.today().year) + 1
|
||||||
|
)
|
||||||
|
],
|
||||||
|
placeholder="Année",
|
||||||
|
),
|
||||||
|
width=4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
dbc.Row(
|
||||||
|
className="mb-2",
|
||||||
|
children=[
|
||||||
|
dbc.Col(
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
width=4,
|
||||||
|
),
|
||||||
|
dbc.Col(
|
||||||
|
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",
|
||||||
|
className="btn btn-primary",
|
||||||
|
),
|
||||||
|
dcc.Download(id="download-data-titulaire"),
|
||||||
|
],
|
||||||
|
width=4,
|
||||||
|
),
|
||||||
|
dbc.Col(
|
||||||
|
id="titulaire_map",
|
||||||
|
width=4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
dbc.Row(
|
||||||
|
children=[
|
||||||
|
dbc.Col(
|
||||||
|
html.Div(
|
||||||
|
children=[
|
||||||
|
html.H3("Top acheteurs"),
|
||||||
|
html.Div(
|
||||||
|
className="marches_table",
|
||||||
|
id="top10_acheteurs",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
width=8,
|
||||||
|
),
|
||||||
|
dbc.Col(id="titulaire-distance-histogram", width=4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# 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(
|
||||||
|
[
|
||||||
|
# Bouton modal des colonnes affichées
|
||||||
|
dbc.Button(
|
||||||
|
"Colonnes affichées",
|
||||||
|
id="titulaire_columns_open",
|
||||||
|
className="column_list",
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
className="btn btn-primary",
|
||||||
|
),
|
||||||
|
dcc.Download(id="titulaire-download-filtered-data"),
|
||||||
|
dbc.Button(
|
||||||
|
"Remise à zéro",
|
||||||
|
title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
|
||||||
|
id="btn-titulaire-reset",
|
||||||
|
className="btn btn-primary",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
className="table-menu",
|
||||||
|
),
|
||||||
|
dbc.Modal(
|
||||||
|
[
|
||||||
|
dbc.ModalHeader(
|
||||||
|
dbc.ModalTitle("Choix des colonnes à afficher")
|
||||||
|
),
|
||||||
|
dbc.ModalBody(
|
||||||
|
id="titulaire_columns_body",
|
||||||
|
children=make_column_picker("titulaire"),
|
||||||
|
),
|
||||||
|
dbc.ModalFooter(
|
||||||
|
dbc.Button(
|
||||||
|
"Fermer",
|
||||||
|
id="titulaire_columns_close",
|
||||||
|
className="ms-auto",
|
||||||
|
n_clicks=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
id="titulaire_columns",
|
||||||
|
is_open=False,
|
||||||
|
fullscreen="md-down",
|
||||||
|
scrollable=True,
|
||||||
|
size="xl",
|
||||||
|
),
|
||||||
|
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="titulaire_url", component_property="pathname"),
|
||||||
|
)
|
||||||
|
def update_titulaire_infos(url):
|
||||||
|
titulaire_siret = url.split("/")[-1]
|
||||||
|
data = get_annuaire_data(titulaire_siret)
|
||||||
|
data_etablissement = data.get("matching_etablissements") if data else None
|
||||||
|
if data_etablissement:
|
||||||
|
data_etablissement = data_etablissement[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}"
|
||||||
|
)
|
||||||
|
raison_sociale = data["nom_raison_sociale"]
|
||||||
|
libelle_commune = data_etablissement["libelle_commune"]
|
||||||
|
|
||||||
|
else:
|
||||||
|
titulaire_map = html.Div()
|
||||||
|
code_departement, nom_departement, nom_region = "", "", ""
|
||||||
|
departement = ""
|
||||||
|
lien_annuaire = ""
|
||||||
|
raison_sociale = html.Span(
|
||||||
|
f"N° SIREN inconnu de l'INSEE ({titulaire_siret[:9]})"
|
||||||
|
)
|
||||||
|
libelle_commune = ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
titulaire_siret,
|
||||||
|
raison_sociale,
|
||||||
|
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="titulaire_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 les années":
|
||||||
|
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"),
|
||||||
|
Output("filter-cleanup-trigger-titulaire", "data"),
|
||||||
|
Input(component_id="titulaire_url", component_property="href"),
|
||||||
|
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(
|
||||||
|
href, 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,
|
||||||
|
"titulaire",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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", ["titulaire_distance"])
|
||||||
|
|
||||||
|
|
||||||
|
@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: list[dict[str, Any]],
|
||||||
|
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 les années", 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, "titu download")
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Pour nettoyer les icontains et i< des filtres
|
||||||
|
# voir aussi src/assets/dash_clientside.js
|
||||||
|
clientside_callback(
|
||||||
|
ClientsideFunction(
|
||||||
|
namespace="clientside",
|
||||||
|
function_name="clean_filters",
|
||||||
|
),
|
||||||
|
Output("filter-cleanup-trigger-titulaire", "data", allow_duplicate=True),
|
||||||
|
Input("filter-cleanup-trigger-titulaire", "data"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("titulaire-hidden-columns", "data", allow_duplicate=True),
|
||||||
|
Input("titulaire_column_list", "selected_rows"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def update_hidden_columns_from_checkboxes(selected_columns):
|
||||||
|
if selected_columns:
|
||||||
|
selected_columns = [columns[i] for i in selected_columns]
|
||||||
|
hidden_columns = [col for col in columns if col not in selected_columns]
|
||||||
|
return hidden_columns
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("titulaire_datatable", "hidden_columns", allow_duplicate=True),
|
||||||
|
Input(
|
||||||
|
"titulaire-hidden-columns",
|
||||||
|
"data",
|
||||||
|
),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def store_hidden_columns(hidden_columns):
|
||||||
|
return hidden_columns
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("titulaire_column_list", "selected_rows"),
|
||||||
|
Input("titulaire_datatable", "hidden_columns"),
|
||||||
|
State("titulaire_column_list", "selected_rows"), # pour éviter la boucle infinie
|
||||||
|
)
|
||||||
|
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
|
||||||
|
hidden_cols = hidden_cols or get_default_hidden_columns("titulaire")
|
||||||
|
|
||||||
|
# Show all columns that are NOT hidden
|
||||||
|
visible_cols = [columns.index(col) for col in columns if col not in hidden_cols]
|
||||||
|
return visible_cols
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("titulaire_columns", "is_open"),
|
||||||
|
Input("titulaire_columns_open", "n_clicks"),
|
||||||
|
Input("titulaire_columns_close", "n_clicks"),
|
||||||
|
State("titulaire_columns", "is_open"),
|
||||||
|
)
|
||||||
|
def toggle_titulaire_columns(click_open, click_close, is_open):
|
||||||
|
if click_open or click_close:
|
||||||
|
return not is_open
|
||||||
|
return is_open
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("titulaire_datatable", "filter_query", allow_duplicate=True),
|
||||||
|
Output("titulaire_datatable", "sort_by"),
|
||||||
|
Input("btn-titulaire-reset", "n_clicks"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def reset_view(n_clicks):
|
||||||
|
return "", []
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("titulaire-distance-histogram", "children"),
|
||||||
|
Input("titulaire_data", "data"),
|
||||||
|
)
|
||||||
|
def update_titulaire_distance_histogram(data):
|
||||||
|
lff = pl.LazyFrame(data)
|
||||||
|
if "titulaire_distance" in lff.collect_schema().names():
|
||||||
|
lff = lff.with_columns(
|
||||||
|
pl.col("titulaire_distance").cast(pl.Float64, strict=False)
|
||||||
|
)
|
||||||
|
fig = get_distance_histogram(lff)
|
||||||
|
return [
|
||||||
|
html.H3("Distance acheteur-titulaire"),
|
||||||
|
html.H6("par nombre de marchés", className="card-subtitle mb-2 text-muted"),
|
||||||
|
fig,
|
||||||
|
]
|
||||||
+835
-48
@@ -1,78 +1,134 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from time import sleep
|
import uuid
|
||||||
|
from collections import OrderedDict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
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 dash import no_update
|
||||||
|
from httpx import HTTPError, 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")
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
format="%(asctime)s %(levelname)-8s %(message)s",
|
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
|
logger = logging.getLogger("decp.info")
|
||||||
|
development = os.getenv("DEVELOPMENT", "False").lower() == "true"
|
||||||
|
if development:
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
logging.getLogger("httpx").setLevel("WARNING")
|
||||||
|
|
||||||
|
|
||||||
def split_filter_part(filter_part):
|
def split_filter_part(filter_part):
|
||||||
print("filter part", filter_part)
|
operators = [
|
||||||
|
["s<", "<"],
|
||||||
|
["s>", ">"],
|
||||||
|
["i<", "<"],
|
||||||
|
["i>", ">"],
|
||||||
|
["icontains", "contains"],
|
||||||
|
# [" ", "contains"]
|
||||||
|
]
|
||||||
|
logger.debug("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:
|
||||||
name_part, value_part = filter_part.split(operator_group[0], 1)
|
name_part, value_part = filter_part.split(operator_group[0], 1)
|
||||||
name_part = name_part.strip()
|
name_part = name_part.strip()
|
||||||
value = value_part.strip()
|
value = value_part.strip()
|
||||||
name = name_part[name_part.find("{") + 1 : name_part.rfind("}")]
|
name = name_part[name_part.find("{") + 1 : name_part.rfind("}")]
|
||||||
print("=>", name, operator_group[1], value)
|
logger.debug("=> " + " ".join([name, operator_group[1], value]))
|
||||||
|
|
||||||
return name, operator_group[1], value
|
return name, operator_group[1], value
|
||||||
|
|
||||||
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):
|
||||||
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/'
|
detail_link = (
|
||||||
+ pl.col("titulaire_id")
|
'<a href = "/titulaires/'
|
||||||
+ '">'
|
+ pl.col("titulaire_id")
|
||||||
+ pl.col("titulaire_id")
|
+ '">'
|
||||||
+ "</a>"
|
+ pl.col(col)
|
||||||
|
+ "</a>"
|
||||||
|
)
|
||||||
|
if col == "titulaire_nom":
|
||||||
|
detail_link = (
|
||||||
|
detail_link
|
||||||
|
+ ' <a href="/observatoire?titulaire_id='
|
||||||
|
+ pl.col("titulaire_id")
|
||||||
|
+ '" title="Voir dans l\'observatoire">📊</a>'
|
||||||
|
)
|
||||||
|
dff = dff.with_columns(
|
||||||
|
pl.when(
|
||||||
|
pl.Expr.or_(
|
||||||
|
pl.col("titulaire_typeIdentifiant").is_null(),
|
||||||
|
pl.col("titulaire_typeIdentifiant") == "SIRET",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(detail_link)
|
||||||
|
.otherwise(pl.col(col))
|
||||||
|
.alias(col)
|
||||||
|
)
|
||||||
|
if col.startswith("acheteur_"):
|
||||||
|
detail_link = (
|
||||||
|
'<a href = "/acheteurs/'
|
||||||
|
+ pl.col("acheteur_id")
|
||||||
|
+ '">'
|
||||||
|
+ pl.col(col)
|
||||||
|
+ "</a>"
|
||||||
|
)
|
||||||
|
if col == "acheteur_nom":
|
||||||
|
detail_link = (
|
||||||
|
detail_link
|
||||||
|
+ ' <a href="/observatoire?acheteur_id='
|
||||||
|
+ pl.col("acheteur_id")
|
||||||
|
+ '" title="Voir dans l\'observatoire">📊</a>'
|
||||||
|
)
|
||||||
|
dff = dff.with_columns(detail_link.alias(col))
|
||||||
|
if col == "uid":
|
||||||
|
dff = dff.with_columns(
|
||||||
|
(
|
||||||
|
'<a href = "/marches/'
|
||||||
|
+ pl.col("uid")
|
||||||
|
+ '">'
|
||||||
|
+ 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 +152,86 @@ 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 unformat_montant(number: str) -> float:
|
||||||
|
number = number.replace(" €", "")
|
||||||
|
number = number.replace(" €", "").replace(" ", "")
|
||||||
|
number = number.replace(",", ".")
|
||||||
|
number = number.strip()
|
||||||
|
return float(number)
|
||||||
|
|
||||||
|
|
||||||
|
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 "titulaire_distance" in dff.columns:
|
||||||
|
dff = dff.with_columns(
|
||||||
|
pl.col("titulaire_distance")
|
||||||
|
.pipe(format_distance)
|
||||||
|
.alias("titulaire_distance")
|
||||||
|
)
|
||||||
|
|
||||||
|
return dff
|
||||||
|
|
||||||
|
|
||||||
|
def get_annuaire_data(siret: str) -> dict:
|
||||||
|
url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}"
|
||||||
|
try:
|
||||||
|
response = get(url).raise_for_status()
|
||||||
|
response = response.json()["results"][0]
|
||||||
|
except (HTTPError, IndexError):
|
||||||
|
response = None
|
||||||
|
logger.warning("Could not fetch data from recherche-entreprises.api.")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
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 +247,670 @@ 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)
|
||||||
|
|
||||||
|
# Mention pour les org dont on a pas le nom
|
||||||
|
for col in ["acheteur_nom", "titulaire_nom"]:
|
||||||
|
lff = lff.with_columns(
|
||||||
|
pl.when(pl.col(col).is_null())
|
||||||
|
.then(pl.lit("[Identifiant non reconnu dans la base INSEE]"))
|
||||||
|
.otherwise(pl.col(col))
|
||||||
|
.name.keep()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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_statistics() -> dict:
|
||||||
|
return (
|
||||||
|
get(
|
||||||
|
"https://www.data.gouv.fr/api/1/datasets/r/0ccf4a75-f3aa-4b46-8b6a-18aeb63e36df",
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
.raise_for_status()
|
||||||
|
.json()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_departements() -> dict:
|
||||||
|
with open("data/departements.json", "rb") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_departements_geojson() -> dict:
|
||||||
|
with open("./data/departements-1000m.geojson") as f:
|
||||||
|
geojson = json.load(f)
|
||||||
|
|
||||||
|
# Ajout de feature.id
|
||||||
|
for f in geojson["features"]:
|
||||||
|
f["id"] = f["properties"]["code"]
|
||||||
|
|
||||||
|
return geojson
|
||||||
|
|
||||||
|
|
||||||
|
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, filter_source: str
|
||||||
|
) -> pl.LazyFrame:
|
||||||
|
_schema = lff.collect_schema()
|
||||||
|
track_search(filter_query, filter_source)
|
||||||
|
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])
|
||||||
|
# logger.debug("filter_value:", filter_value)
|
||||||
|
# logger.debug("filter_value_type:", type(filter_value))
|
||||||
|
# logger.debug("operator:", operator)
|
||||||
|
# logger.debug("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('"')
|
||||||
|
if filter_value.endswith("*"):
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col(col_name).str.starts_with(filter_value[:-1])
|
||||||
|
)
|
||||||
|
elif filter_value.startswith("*"):
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col(col_name).str.ends_with(filter_value[1:])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col(col_name).str.contains("(?i)" + filter_value)
|
||||||
|
)
|
||||||
|
elif col_type.startswith("Int") or col_type.startswith("Float"):
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
logger.debug(sort_by)
|
||||||
|
return lff
|
||||||
|
|
||||||
|
|
||||||
|
def setup_table_columns(
|
||||||
|
dff, hideable: bool = True, exclude: list = None, new_columns: list = None
|
||||||
|
) -> tuple:
|
||||||
|
# Liste finale de colonnes
|
||||||
|
markdown_exceptions = ["montant", "titulaire_distance", "distance", "dureeMois"]
|
||||||
|
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:
|
||||||
|
# Si le champ est un champ créé par erreur lors d'une jointure, on le skip
|
||||||
|
if column_id.endswith("_left") or column_id.endswith("_right"):
|
||||||
|
logger.warning(f"Champ innatendu : {column_id}")
|
||||||
|
continue
|
||||||
|
column_name = column_id
|
||||||
|
column_object = {"title": column_name, "description": ""}
|
||||||
|
|
||||||
|
presentation = "input" if column_id in markdown_exceptions else "markdown"
|
||||||
|
|
||||||
|
column = {
|
||||||
|
"name": column_name,
|
||||||
|
"id": column_id,
|
||||||
|
"presentation": presentation,
|
||||||
|
"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.get("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",
|
||||||
|
"titulaire_distance",
|
||||||
|
"montant",
|
||||||
|
"codeCPV",
|
||||||
|
"dureeRestanteMois",
|
||||||
|
]
|
||||||
|
elif page == "titulaire":
|
||||||
|
displayed_columns = [
|
||||||
|
"uid",
|
||||||
|
"objet",
|
||||||
|
"dateNotification",
|
||||||
|
"acheteur_id",
|
||||||
|
"acheteur_nom",
|
||||||
|
"titulaire_distance",
|
||||||
|
"montant",
|
||||||
|
"codeCPV",
|
||||||
|
"dureeRestanteMois",
|
||||||
|
]
|
||||||
|
elif page == "tableau":
|
||||||
|
displayed_columns = os.getenv("DISPLAYED_COLUMNS")
|
||||||
|
else:
|
||||||
|
displayed_columns = os.getenv("DISPLAYED_COLUMNS")
|
||||||
|
logger.warning(f"Invalid page: {page}")
|
||||||
|
|
||||||
|
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 = OrderedDict()
|
||||||
|
|
||||||
|
for col in original_schema["fields"]:
|
||||||
|
new_schema[col["name"]] = col
|
||||||
|
|
||||||
|
return new_schema
|
||||||
|
|
||||||
|
|
||||||
|
def track_search(query, category):
|
||||||
|
if len(query) >= 4 and not development and os.getenv("MATOMO_DOMAIN"):
|
||||||
|
url = "https://decp.info"
|
||||||
|
params = {
|
||||||
|
"idsite": os.getenv("MATOMO_ID_SITE"),
|
||||||
|
"url": url,
|
||||||
|
"rec": "1",
|
||||||
|
"action_name": "search" if category == "home_page_search" else "filter",
|
||||||
|
"search_cat": category,
|
||||||
|
"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, "home_page_search")
|
||||||
|
|
||||||
|
# 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))
|
||||||
|
.drop([f"token_{token}" for token in tokens])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format result
|
||||||
|
dff = add_links(dff)
|
||||||
|
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")
|
||||||
|
dff = dff.group_by(f"{org_type}_id", f"{org_type}_nom", "Département").sum()
|
||||||
|
dff = dff.sort("Marchés", descending=True)
|
||||||
|
|
||||||
|
return dff
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_table_data(
|
||||||
|
data, data_timestamp, filter_query, page_current, page_size, sort_by, source_table
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
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 source_table:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
|
||||||
|
if os.getenv("DEVELOPMENT").lower() == "true":
|
||||||
|
logger.debug(" + + + + + + + + + + + + + + + + + + ")
|
||||||
|
|
||||||
|
trigger_cleanup = no_update
|
||||||
|
|
||||||
|
# Récupération des données
|
||||||
|
if isinstance(data, list):
|
||||||
|
lff: pl.LazyFrame = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
|
||||||
|
elif isinstance(data, pl.LazyFrame):
|
||||||
|
lff = data
|
||||||
|
else:
|
||||||
|
lff: pl.LazyFrame = df.lazy() # start from the original data
|
||||||
|
|
||||||
|
# Application des filtres
|
||||||
|
if filter_query:
|
||||||
|
lff = filter_table_data(lff, filter_query, source_table)
|
||||||
|
trigger_cleanup = no_update if source_table == "tableau" else str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Application des tris
|
||||||
|
if sort_by and 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 les pages de détails
|
||||||
|
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
|
||||||
|
table_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,
|
||||||
|
table_columns,
|
||||||
|
tooltip,
|
||||||
|
data_timestamp + 1,
|
||||||
|
nb_rows,
|
||||||
|
download_disabled,
|
||||||
|
download_text,
|
||||||
|
download_title,
|
||||||
|
trigger_cleanup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_dashboard_data(
|
||||||
|
lff: pl.LazyFrame,
|
||||||
|
year,
|
||||||
|
acheteur_id,
|
||||||
|
acheteur_categorie,
|
||||||
|
acheteur_departement_code,
|
||||||
|
titulaire_id,
|
||||||
|
titulaire_categorie,
|
||||||
|
titulaire_departement_code,
|
||||||
|
type,
|
||||||
|
objet,
|
||||||
|
code_cpv,
|
||||||
|
considerations_sociales,
|
||||||
|
considerations_environnementales,
|
||||||
|
techniques,
|
||||||
|
marche_innovant,
|
||||||
|
sous_traitance_declaree,
|
||||||
|
montant_min=None,
|
||||||
|
montant_max=None,
|
||||||
|
) -> pl.LazyFrame:
|
||||||
|
if year:
|
||||||
|
lff = lff.filter(pl.col("dateNotification").dt.year() == int(year))
|
||||||
|
else:
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col("dateNotification") > (datetime.now() - timedelta(days=365))
|
||||||
|
)
|
||||||
|
|
||||||
|
if acheteur_id:
|
||||||
|
lff = lff.filter(pl.col("acheteur_id").str.contains(acheteur_id))
|
||||||
|
else:
|
||||||
|
if acheteur_categorie:
|
||||||
|
lff = lff.filter(pl.col("acheteur_categorie") == acheteur_categorie)
|
||||||
|
if acheteur_departement_code:
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col("acheteur_departement_code").is_in(acheteur_departement_code)
|
||||||
|
)
|
||||||
|
|
||||||
|
if titulaire_id:
|
||||||
|
lff = lff.filter(pl.col("titulaire_id").str.contains(titulaire_id))
|
||||||
|
else:
|
||||||
|
if titulaire_categorie:
|
||||||
|
lff = lff.filter(pl.col("titulaire_categorie") == titulaire_categorie)
|
||||||
|
if titulaire_departement_code:
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col("titulaire_departement_code").is_in(titulaire_departement_code)
|
||||||
|
)
|
||||||
|
|
||||||
|
if type:
|
||||||
|
lff = lff.filter(pl.col("type") == type)
|
||||||
|
|
||||||
|
if objet:
|
||||||
|
lff = lff.filter(pl.col("objet").str.contains(f"(?i){objet}"))
|
||||||
|
|
||||||
|
if code_cpv:
|
||||||
|
lff = lff.filter(pl.col("codeCPV").str.starts_with(code_cpv))
|
||||||
|
|
||||||
|
if marche_innovant and marche_innovant != "all":
|
||||||
|
lff = lff.filter(pl.col("marcheInnovant") == marche_innovant)
|
||||||
|
|
||||||
|
if sous_traitance_declaree and sous_traitance_declaree != "all":
|
||||||
|
lff = lff.filter(pl.col("sousTraitanceDeclaree") == sous_traitance_declaree)
|
||||||
|
|
||||||
|
if techniques:
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col("techniques")
|
||||||
|
.str.split(", ")
|
||||||
|
.list.set_intersection(techniques)
|
||||||
|
.list.len()
|
||||||
|
> 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if considerations_sociales:
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col("considerationsSociales")
|
||||||
|
.str.split(", ")
|
||||||
|
.list.set_intersection(considerations_sociales)
|
||||||
|
.list.len()
|
||||||
|
> 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if considerations_environnementales:
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col("considerationsEnvironnementales")
|
||||||
|
.str.split(", ")
|
||||||
|
.list.set_intersection(considerations_environnementales)
|
||||||
|
.list.len()
|
||||||
|
> 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if montant_min is not None:
|
||||||
|
lff = lff.filter(pl.col("montant") >= montant_min)
|
||||||
|
|
||||||
|
if montant_max is not None:
|
||||||
|
lff = lff.filter(pl.col("montant") <= montant_max)
|
||||||
|
|
||||||
|
return lff
|
||||||
|
|
||||||
|
|
||||||
|
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 = " Ajoutez des filtres pour réduire le nombre de lignes, Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul."
|
||||||
|
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 = "Télécharger les données telles qu'affichées au format Excel"
|
||||||
|
return download_disabled, download_text, download_title
|
||||||
|
|
||||||
|
|
||||||
|
def get_enum_values_as_dict(column_name):
|
||||||
|
try:
|
||||||
|
options = {}
|
||||||
|
for value in data_schema[column_name]["enum"]:
|
||||||
|
options[value] = value
|
||||||
|
return options
|
||||||
|
except KeyError:
|
||||||
|
return {"not_found": "not found"}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def make_org_jsonld(org_id, org_type, org_name=None, type_org_id="SIRET") -> dict:
|
||||||
|
org_types = {"acheteur": "GovernmentOrganization", "titulaire": "Organization"}
|
||||||
|
address = None
|
||||||
|
if type_org_id.lower() == "siret" and len(org_id) == 14:
|
||||||
|
annuaire_data = get_annuaire_data(org_id)
|
||||||
|
annuaire_address = annuaire_data["matching_etablissements"][0]
|
||||||
|
code_postal = annuaire_address["code_postal"]
|
||||||
|
commune = annuaire_address["libelle_commune"]
|
||||||
|
|
||||||
|
address = (
|
||||||
|
{
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"streetAddress": annuaire_address.get("adresse", "")
|
||||||
|
.replace(code_postal, "")
|
||||||
|
.replace(commune, "")
|
||||||
|
.strip(),
|
||||||
|
"addressLocality": commune,
|
||||||
|
"postalCode": code_postal,
|
||||||
|
"addressCountry": "FR",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
jsonld = {
|
||||||
|
"@type": org_types[org_type],
|
||||||
|
"name": org_name,
|
||||||
|
"url": f"https://decp.info/{org_type}s/{org_id}",
|
||||||
|
"sameAs": f"https://annuaire-entreprises.data.gouv.fr/etablissement/{org_id}",
|
||||||
|
"identifier": {
|
||||||
|
"@type": "PropertyValue",
|
||||||
|
"propertyID": type_org_id.lower(),
|
||||||
|
"value": org_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if address:
|
||||||
|
jsonld["address"] = address
|
||||||
|
|
||||||
|
return jsonld
|
||||||
|
|
||||||
|
|
||||||
|
df: pl.DataFrame = get_decp_data()
|
||||||
|
schema = df.collect_schema()
|
||||||
|
|
||||||
|
df_acheteurs = get_org_data(df, "acheteur")
|
||||||
|
df_titulaires = get_org_data(df, "titulaire")
|
||||||
|
df_acheteurs_departement: pl.DataFrame = (
|
||||||
|
df_acheteurs.select(["acheteur_id", "acheteur_nom", "acheteur_departement_code"])
|
||||||
|
.unique()
|
||||||
|
.sort("acheteur_nom")
|
||||||
|
)
|
||||||
|
df_titulaires_departement: pl.DataFrame = (
|
||||||
|
df_titulaires.select(
|
||||||
|
["titulaire_id", "titulaire_nom", "titulaire_departement_code"]
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
.sort("titulaire_nom")
|
||||||
|
)
|
||||||
|
df_acheteurs_marches: pl.DataFrame = (
|
||||||
|
df.select("uid", "objet", "acheteur_id").unique().sort("acheteur_id")
|
||||||
|
)
|
||||||
|
df_titulaires_marches: pl.DataFrame = (
|
||||||
|
df.select("uid", "objet", "titulaire_id").unique().sort("titulaire_id")
|
||||||
|
)
|
||||||
|
|
||||||
|
departements = get_departements()
|
||||||
|
departements_geojson = get_departements_geojson()
|
||||||
|
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()
|
||||||
|
columns = df.columns
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
import pytest
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def test_data():
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"uid": "1",
|
||||||
|
"id": "1",
|
||||||
|
"acheteur_nom": "ACHETEUR 1",
|
||||||
|
"acheteur_id": "123",
|
||||||
|
"titulaire_nom": "TITULAIRE 1",
|
||||||
|
"titulaire_id": "345",
|
||||||
|
"montant": 10,
|
||||||
|
"dateNotification": datetime.date(2025, 1, 1),
|
||||||
|
"codeCPV": "71600000",
|
||||||
|
"donneesActuelles": True,
|
||||||
|
"acheteur_departement_code": "75",
|
||||||
|
"acheteur_departement_nom": "Paris",
|
||||||
|
"acheteur_commune_nom": "Paris",
|
||||||
|
"titulaire_departement_code": "35",
|
||||||
|
"titulaire_departement_nom": "Ille-et-Vilaine",
|
||||||
|
"titulaire_commune_nom": "Rennes",
|
||||||
|
"titulaire_distance": 10,
|
||||||
|
"titulaire_typeIdentifiant": "SIRET",
|
||||||
|
"objet": "Objet test",
|
||||||
|
"dureeRestanteMois": 12,
|
||||||
|
"lieuExecution_code": "75001",
|
||||||
|
"sourceFile": "test.xml",
|
||||||
|
"sourceDataset": "test_dataset",
|
||||||
|
"datePublicationDonnees": datetime.date(2025, 1, 1),
|
||||||
|
"considerationsSociales": "",
|
||||||
|
"considerationsEnvironnementales": "",
|
||||||
|
"type": "Marché",
|
||||||
|
"acheteur_categorie": "Collectivité",
|
||||||
|
"titulaire_categorie": "PME",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
path = "tests/test.parquet"
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
print(f"Writing test data to: {path}") # <-- This will show you the real path
|
||||||
|
|
||||||
|
pl.DataFrame(data).write_parquet("tests/test.parquet")
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_setup_options():
|
||||||
|
options = Options()
|
||||||
|
options.add_argument("--window-size=1200,1200 ")
|
||||||
|
options.add_experimental_option(
|
||||||
|
"prefs",
|
||||||
|
{
|
||||||
|
"download.default_directory": "/home/colin/git/decp.info",
|
||||||
|
"download.prompt_for_download": False,
|
||||||
|
"download.directory_upgrade": True,
|
||||||
|
"safebrowsing.enabled": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return options
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import polars as pl
|
||||||
|
from dash.testing.composite import DashComposite
|
||||||
|
from selenium.webdriver import Keys
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.remote.webelement import WebElement
|
||||||
|
|
||||||
|
|
||||||
|
def test_001_logo_and_search(dash_duo: DashComposite):
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
dash_duo.start_server(app)
|
||||||
|
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||||
|
assert dash_duo.find_element(".logo > h1").text == "decp.info"
|
||||||
|
|
||||||
|
for org_type in ["acheteur", "titulaire"]:
|
||||||
|
name = f"{org_type.upper()} 1"
|
||||||
|
search_bar: WebElement = dash_duo.find_element("#search")
|
||||||
|
|
||||||
|
dash_duo.clear_input(search_bar)
|
||||||
|
|
||||||
|
search_bar.send_keys(name)
|
||||||
|
search_bar.send_keys(Keys.ENTER)
|
||||||
|
|
||||||
|
dash_duo.wait_for_element(f"#results_{org_type}_datatable", timeout=2)
|
||||||
|
result_table: WebElement = dash_duo.find_element(
|
||||||
|
f"#results_{org_type}_datatable tbody"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result_table.find_elements(by=By.TAG_NAME, value="tr")) == 2, (
|
||||||
|
"The search should return only one result"
|
||||||
|
) # header row + 1 result
|
||||||
|
assert result_table.find_element(
|
||||||
|
by=By.CSS_SELECTOR, value=f'td[data-dash-column="{org_type}_nom"]'
|
||||||
|
).text.startswith(name), (
|
||||||
|
f"The search result should have the right {org_type} name"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_002_filter_persistence(dash_duo: DashComposite):
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
dash_duo.start_server(app)
|
||||||
|
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||||
|
|
||||||
|
def open_page_and_check_filter_input():
|
||||||
|
dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}")
|
||||||
|
filter_input_selector = (
|
||||||
|
'.marches_table th[data-dash-column="uid"] input[type="text"]'
|
||||||
|
)
|
||||||
|
dash_duo.wait_for_element(filter_input_selector, timeout=2)
|
||||||
|
_filter_input: WebElement = dash_duo.find_element(filter_input_selector)
|
||||||
|
return _filter_input
|
||||||
|
|
||||||
|
for page in ["tableau", "acheteurs/123", "titulaires/345"]:
|
||||||
|
print("page:", page)
|
||||||
|
filter_input = open_page_and_check_filter_input()
|
||||||
|
filter_input.send_keys("11") # a UID that doesn't exist
|
||||||
|
filter_input.send_keys(Keys.ENTER)
|
||||||
|
filter_input = open_page_and_check_filter_input()
|
||||||
|
assert filter_input.get_attribute("value") == "11"
|
||||||
|
|
||||||
|
|
||||||
|
def test_003_tableau_download(dash_duo: DashComposite):
|
||||||
|
from pages.acheteur import download_acheteur_data
|
||||||
|
from pages.tableau import download_data
|
||||||
|
from pages.titulaire import download_titulaire_data
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
# Juste pour instancier l'app
|
||||||
|
print(app.server.name)
|
||||||
|
|
||||||
|
dicts = pl.read_parquet("tests/test.parquet").to_dicts()
|
||||||
|
|
||||||
|
outputs = [
|
||||||
|
download_data(1, "", [], None),
|
||||||
|
download_acheteur_data(1, dicts, "123", "2025"),
|
||||||
|
download_titulaire_data(1, dicts, "345", "2025"),
|
||||||
|
]
|
||||||
|
for output in outputs:
|
||||||
|
assert isinstance(output, dict)
|
||||||
|
for f in ["content", "filename", "type", "base64"]:
|
||||||
|
assert f in output
|
||||||
|
assert isinstance(output["content"], str) and len(output["content"]) > 100
|
||||||
|
assert isinstance(output["filename"], str) and output["filename"].startswith(
|
||||||
|
"decp_"
|
||||||
|
)
|
||||||
|
assert output["type"] is None
|
||||||
|
assert output["base64"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_004_add_links_observatoire_acheteur():
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from src.utils import add_links
|
||||||
|
|
||||||
|
dff = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"acheteur_id": ["123"],
|
||||||
|
"acheteur_nom": ["ACHETEUR 1"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = add_links(dff)
|
||||||
|
nom_value = result["acheteur_nom"][0]
|
||||||
|
id_value = result["acheteur_id"][0]
|
||||||
|
|
||||||
|
# acheteur_nom should contain detail link + observatoire link
|
||||||
|
assert "/acheteurs/123" in nom_value
|
||||||
|
assert "ACHETEUR 1" in nom_value
|
||||||
|
assert "/observatoire?acheteur_id=123" in nom_value
|
||||||
|
assert "📊" in nom_value
|
||||||
|
|
||||||
|
# acheteur_id should NOT contain observatoire link
|
||||||
|
assert "/observatoire" not in id_value
|
||||||
|
|
||||||
|
|
||||||
|
def test_005_add_links_observatoire_titulaire():
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from src.utils import add_links
|
||||||
|
|
||||||
|
dff = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"titulaire_id": ["345"],
|
||||||
|
"titulaire_nom": ["TITULAIRE 1"],
|
||||||
|
"titulaire_typeIdentifiant": ["SIRET"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = add_links(dff)
|
||||||
|
nom_value = result["titulaire_nom"][0]
|
||||||
|
id_value = result["titulaire_id"][0]
|
||||||
|
|
||||||
|
# titulaire_nom should contain detail link + observatoire link
|
||||||
|
assert "/titulaires/345" in nom_value
|
||||||
|
assert "TITULAIRE 1" in nom_value
|
||||||
|
assert "/observatoire?titulaire_id=345" in nom_value
|
||||||
|
assert "📊" in nom_value
|
||||||
|
|
||||||
|
# titulaire_id should NOT contain observatoire link
|
||||||
|
assert "/observatoire" not in id_value
|
||||||
|
|
||||||
|
|
||||||
|
def test_006_observatoire_url_to_input(dash_duo: DashComposite):
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
dash_duo.start_server(app)
|
||||||
|
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||||
|
|
||||||
|
# Navigate to observatoire with acheteur_id query param
|
||||||
|
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=123")
|
||||||
|
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
time.sleep(1) # Allow callback chain to complete
|
||||||
|
|
||||||
|
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||||
|
assert acheteur_input.get_attribute("value") == "123", (
|
||||||
|
"acheteur_id input should be populated from URL param"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_007_observatoire_share_url(dash_duo: DashComposite):
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
dash_duo.start_server(app)
|
||||||
|
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||||
|
|
||||||
|
# Navigate to observatoire with acheteur_id query param
|
||||||
|
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=123")
|
||||||
|
dash_duo.wait_for_element("#observatoire-share-url", timeout=4)
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
time.sleep(1) # Allow callback chain to complete
|
||||||
|
|
||||||
|
share_url_input = dash_duo.find_element("#observatoire-share-url")
|
||||||
|
share_url_value = share_url_input.get_attribute("value")
|
||||||
|
|
||||||
|
assert "acheteur_id=123" in share_url_value, (
|
||||||
|
f"Share URL should contain acheteur_id param, got: {share_url_value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_008_search_to_observatoire(dash_duo: DashComposite):
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
dash_duo.start_server(app)
|
||||||
|
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||||
|
|
||||||
|
# Search for an acheteur
|
||||||
|
search_bar = dash_duo.find_element("#search")
|
||||||
|
search_bar.send_keys("ACHETEUR 1")
|
||||||
|
search_bar.send_keys(Keys.ENTER)
|
||||||
|
|
||||||
|
dash_duo.wait_for_element("#results_acheteur_datatable", timeout=2)
|
||||||
|
|
||||||
|
# Find the observatoire link in acheteur_nom column
|
||||||
|
observatoire_link = dash_duo.find_element(
|
||||||
|
'#results_acheteur_datatable td[data-dash-column="acheteur_nom"] a[href*="observatoire"]'
|
||||||
|
)
|
||||||
|
assert "📊" in observatoire_link.text
|
||||||
|
|
||||||
|
# Click the observatoire link
|
||||||
|
observatoire_link.click()
|
||||||
|
|
||||||
|
# Wait for observatoire page to load
|
||||||
|
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
time.sleep(1) # Allow callback chain to complete
|
||||||
|
|
||||||
|
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||||
|
assert acheteur_input.get_attribute("value") == "123", (
|
||||||
|
"acheteur_id input should be populated after navigating from search"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_010_observatoire_montant_filter():
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from src.utils import prepare_dashboard_data
|
||||||
|
|
||||||
|
data = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"uid": ["1", "2", "3"],
|
||||||
|
"montant": [100.0, 500.0, 1000.0],
|
||||||
|
"dateNotification": [datetime.date(2025, 1, 1)] * 3,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply(min_val=None, max_val=None):
|
||||||
|
return prepare_dashboard_data(
|
||||||
|
data.lazy(),
|
||||||
|
year="2025",
|
||||||
|
acheteur_id=None,
|
||||||
|
acheteur_categorie=None,
|
||||||
|
acheteur_departement_code=None,
|
||||||
|
titulaire_id=None,
|
||||||
|
titulaire_categorie=None,
|
||||||
|
titulaire_departement_code=None,
|
||||||
|
type=None,
|
||||||
|
objet=None,
|
||||||
|
code_cpv=None,
|
||||||
|
considerations_sociales=None,
|
||||||
|
considerations_environnementales=None,
|
||||||
|
techniques=None,
|
||||||
|
marche_innovant=None,
|
||||||
|
sous_traitance_declaree=None,
|
||||||
|
montant_min=min_val,
|
||||||
|
montant_max=max_val,
|
||||||
|
).collect()
|
||||||
|
|
||||||
|
assert apply().height == 3
|
||||||
|
assert apply(min_val=400).height == 2 # 500, 1000
|
||||||
|
assert apply(max_val=500).height == 2 # 100, 500
|
||||||
|
assert apply(min_val=200, max_val=600).height == 1 # 500 only
|
||||||
|
|
||||||
|
|
||||||
|
def test_009_observatoire_filter_persistence(dash_duo: DashComposite):
|
||||||
|
import time
|
||||||
|
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
dash_duo.start_server(app)
|
||||||
|
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||||
|
|
||||||
|
# Clear localStorage to start from a clean state
|
||||||
|
dash_duo.driver.execute_script("localStorage.clear()")
|
||||||
|
|
||||||
|
# Navigate to observatoire without URL params
|
||||||
|
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire")
|
||||||
|
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||||
|
|
||||||
|
# Set the acheteur_id text input; press Enter to trigger the debounced save callback
|
||||||
|
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||||
|
dash_duo.clear_input(acheteur_input)
|
||||||
|
acheteur_input.send_keys("123")
|
||||||
|
acheteur_input.send_keys(Keys.ENTER)
|
||||||
|
|
||||||
|
time.sleep(0.3) # allow the save callback to write to localStorage
|
||||||
|
|
||||||
|
# Navigate away
|
||||||
|
dash_duo.wait_for_page(f"{dash_duo.server_url}/")
|
||||||
|
|
||||||
|
# Navigate back without URL params
|
||||||
|
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire")
|
||||||
|
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||||
|
time.sleep(0.5) # allow restore callback chain to complete
|
||||||
|
|
||||||
|
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||||
|
assert acheteur_input.get_attribute("value") == "123", (
|
||||||
|
"acheteur_id should be restored from localStorage after navigating back"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also verify URL params still override localStorage
|
||||||
|
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=123")
|
||||||
|
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||||
|
assert acheteur_input.get_attribute("value") == "123", (
|
||||||
|
"URL param acheteur_id should override the value stored in localStorage"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_011_observatoire_multi_param_url(dash_duo: DashComposite):
|
||||||
|
import time
|
||||||
|
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
dash_duo.start_server(app)
|
||||||
|
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||||
|
|
||||||
|
# Navigate with multiple filter params
|
||||||
|
dash_duo.wait_for_page(
|
||||||
|
f"{dash_duo.server_url}/observatoire?annee=2024&acheteur_id=12345678901234&montant_min=10000"
|
||||||
|
)
|
||||||
|
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||||
|
|
||||||
|
time.sleep(1) # Allow callback chain to complete
|
||||||
|
|
||||||
|
# Verify acheteur_id input
|
||||||
|
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||||
|
assert acheteur_input.get_attribute("value") == "12345678901234", (
|
||||||
|
"acheteur_id input should be populated from URL param"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify montant_min input
|
||||||
|
montant_input = dash_duo.find_element("#dashboard_montant_min")
|
||||||
|
montant_value = montant_input.get_attribute("value")
|
||||||
|
assert montant_value in ("10000", "10000.0"), (
|
||||||
|
f"montant_min input should be populated from URL param, got: {montant_value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_distance_histogram_returns_graph():
|
||||||
|
import polars as pl
|
||||||
|
from dash import dcc
|
||||||
|
|
||||||
|
from src.figures import get_distance_histogram
|
||||||
|
|
||||||
|
lff = pl.LazyFrame({"titulaire_distance": [1, 10, 100, 500, 1000]})
|
||||||
|
result = get_distance_histogram(lff)
|
||||||
|
assert isinstance(result, dcc.Graph)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_distance_histogram_handles_nulls():
|
||||||
|
import polars as pl
|
||||||
|
from dash import dcc
|
||||||
|
|
||||||
|
from src.figures import get_distance_histogram
|
||||||
|
|
||||||
|
lff = pl.LazyFrame({"titulaire_distance": [None, None, 50]})
|
||||||
|
result = get_distance_histogram(lff)
|
||||||
|
assert isinstance(result, dcc.Graph)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_distance_histogram_all_nulls():
|
||||||
|
import polars as pl
|
||||||
|
from dash import dcc
|
||||||
|
|
||||||
|
from src.figures import get_distance_histogram
|
||||||
|
|
||||||
|
lff = pl.LazyFrame({"titulaire_distance": pl.Series([], dtype=pl.Int64)})
|
||||||
|
result = get_distance_histogram(lff)
|
||||||
|
assert isinstance(result, dcc.Graph)
|
||||||
Reference in New Issue
Block a user