Compare commits

..

259 Commits

Author SHA1 Message Date
Colin Maudry 06186c1691 Merge branch 'hotfix/2.7.1' 2026-03-23 13:40:01 +01:00
Colin Maudry 30b6874045 Changelog 2.7.1 2026-03-23 13:39:49 +01:00
Colin Maudry 6e5f4011e5 On garde toutes les valeurs en paramètre puour prepare_dashboard_data 2026-03-23 13:39:37 +01:00
Colin Maudry 1f48319a0f Utilisation d'un store plutôt que df global, regroupement des inputs/outputs 2026-03-23 13:25:08 +01:00
Colin Maudry 08fc4dcfdc Merge branch 'release/2.7.0' 2026-03-23 07:49:04 +01:00
Colin Maudry ad3f2cf654 Changelog 2.7.0 2026-03-23 07:48:54 +01:00
Colin Maudry 24bce6e2d6 Lien vers #sources 2026-03-23 07:43:37 +01:00
Colin Maudry 72da1a15e2 Nettoyage HTML recherche.py 2026-03-23 07:43:37 +01:00
Colin Maudry f4514bf06c Le bouton Partager n'apparaît que s'il y a des filtres 2026-03-23 07:43:02 +01:00
Colin Maudry a54f78875e Utilise l'année en cours pour plafonner les données du graph sources #65 2026-03-21 12:25:31 +01:00
Colin Maudry 7b78e0a0ea Style, ordre des années #65 2026-03-21 12:09:00 +01:00
Colin Maudry eecd75ac42 Style des boutons Prévisualiser et Partager #65 2026-03-21 11:55:48 +01:00
Colin Maudry b06f3c91e0 test: add multi-param URL round-trip test for observatoire
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 11:33:38 +01:00
Colin Maudry 139b820b6b fix: remove duplicate observatoire-share-url element in layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:32:43 +01:00
Colin Maudry bb2cde2fc5 feat: sync_observatoire_share_url encodes all 17 filter params
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 11:31:47 +01:00
Colin Maudry 3957ca1662 feat: restore_filters reads all 17 filter params from URL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 11:27:54 +01:00
Colin Maudry 547accd7be fix: update test_010 to use prepare_dashboard_data instead of removed _apply_filters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 11:25:54 +01:00
Colin Maudry ff6b5d0d41 Add design spec for full URL sharing on observatoire page 2026-03-21 11:15:26 +01:00
Colin Maudry a3dce84cc5 Merge branch 'feature/preview_data' into dev 2026-03-21 10:28:19 +01:00
Colin Maudry e6bf671f16 Prévisualisation des données: bonnes données téléchargées #65 2026-03-21 10:28:07 +01:00
Colin Maudry b34f63f711 Prévisualisation des données: choix des colonnes #65 2026-03-21 10:17:58 +01:00
Colin Maudry d6e14e2564 Prévisualisation des données fonctionnelle #65 2026-03-21 10:02:11 +01:00
Colin Maudry dd63feeeac Style des boutons 2026-03-20 17:49:27 +01:00
Colin Maudry 3b2cb15935 Prévisualisation basique des données #65 2026-03-20 17:38:10 +01:00
Colin Maudry 91ea9eccea Top 10 acheteurs et titulaires #65 2026-03-20 16:44:29 +01:00
Colin Maudry a89677604b Médian des distances titulaire-acheteur #65 2026-03-20 14:20:44 +01:00
Colin Maudry e5419bab9c Donut : ajout à autres si moins de 1% #65; 2026-03-20 13:57:44 +01:00
Colin Maudry 4f9f31c4c7 Ajout des filtres objet et code CPV #65 2026-03-20 11:46:56 +01:00
Colin Maudry 28fdca2a06 Tri des départements pour dropdown #65 2026-03-20 11:33:25 +01:00
Colin Maudry 771dcf0ea1 Moins de barres dans l'histogramme distances #65 2026-03-20 11:32:50 +01:00
Colin Maudry dac9efee88 Carte résumé => figures.py #65 2026-03-20 10:20:44 +01:00
Colin Maudry 50c3947c04 Filtres sur techniques, sous-traitance, innovant #65 2026-03-19 18:36:54 +01:00
Colin Maudry 9852d55a3d Tentative de tri des départemetns 2026-03-19 13:02:42 +01:00
Colin Maudry 144e714235 fix: reduce right margin in distance histogram to use full card width
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:02:41 +01:00
Colin Maudry a9d3d96a5e "Partager" masqué dans l'observatoire pour l'instant #65 2026-03-19 00:04:59 +01:00
Colin Maudry dbfc20921a Amélioration du layout dans titulaire et acheteur 2026-03-18 23:54:41 +01:00
Colin Maudry d8f1a884a3 fix: show actual km ranges in distance histogram tooltip
Replace px.histogram with manually-computed go.Bar so hover text
displays human-readable distance ranges (e.g. "8.9 – 10.0 km")
instead of raw log10 bin values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:35:04 +01:00
Colin Maudry 0ac6c55d9e Correction imports 2026-03-18 23:21:47 +01:00
Colin Maudry 6a6be51455 refactor: replace CSS grid layout with Dash Bootstrap Components grid
Replace the custom CSS grid (`.wrapper`, `.org_*`, `.results_*` classes)
in acheteur, titulaire, and recherche pages with dbc.Row/dbc.Col.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:21:17 +01:00
Colin Maudry 52958ccbaa Merge branch 'feature/65_observatoire' into dev 2026-03-18 22:59:28 +01:00
Colin Maudry 8a61d3268a fix: lecture du dataframe plus flexible dans la création de l'histogram 2026-03-18 22:53:17 +01:00
Colin Maudry 32aa877797 fix: guard against missing titulaire_distance column in get_distance_histogram 2026-03-18 22:40:28 +01:00
Colin Maudry 858ab6c61a feat: add distance histogram to titulaire detail page 2026-03-18 22:28:34 +01:00
Colin Maudry b08d517f36 feat: add distance histogram to acheteur detail page 2026-03-18 22:26:57 +01:00
Colin Maudry 44d2d7d2c1 feat: add distance histogram card to observatoire dashboard
Integrate the get_distance_histogram function into the observatoire dashboard
to display buyer-contractor distance distribution on a logarithmic scale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:24:01 +01:00
Colin Maudry f2af09bb35 fix: use streaming collect and filter zero distances in get_distance_histogram 2026-03-18 22:22:54 +01:00
Colin Maudry 24db748b6a feat: add get_distance_histogram figure function
Implement get_distance_histogram that creates a histogram of titulaire distances
with logarithmic scale. Add 3 unit tests covering basic functionality, null handling,
and edge cases. Also add DATA_SCHEMA_PATH to pytest env config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:15:26 +01:00
Colin Maudry 1ffa995a4a docs: spec for distance histogram on observatoire, acheteur, titulaire pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:02:20 +01:00
Colin Maudry 9da3d9ce34 Ne pas supprimer les données de test à la fin 2026-03-18 21:48:23 +01:00
Colin Maudry 125c520c19 Claude memory 2026-03-18 21:47:29 +01:00
Colin Maudry 78d528f75b Filtre par montant #65 2026-03-18 21:13:21 +01:00
Colin Maudry c77511d4e8 Taille de donut flexible #65 2026-03-18 20:57:30 +01:00
Colin Maudry f2046d6ba7 Meilleure intégration du nom de l'org #65 2026-03-18 20:46:38 +01:00
Colin Maudry 26dd2aaf1a Correction de l'injection du nom d'org dans le titre #65 2026-03-18 20:41:46 +01:00
Colin Maudry f8fffb6fa4 get_top_org un peu plus configurable #65 2026-03-18 20:40:59 +01:00
Colin Maudry b4a42449ad feat: restore observatoire filters from localStorage on page load #65 2026-03-18 20:35:31 +01:00
Colin Maudry 051e908bd1 feat: save observatoire filters to localStorage on change #65 2026-03-18 20:33:15 +01:00
Colin Maudry 1fdcb12dd2 feat: add dcc.Store and debounce text inputs on observatoire page #65 2026-03-18 20:32:43 +01:00
Colin Maudry 5d4c0b8438 test: failing test for observatoire localStorage filter persistence #65 2026-03-18 20:31:45 +01:00
Colin Maudry 958c3956ea Correction des problèmes de double reload #65 2026-03-18 16:05:29 +01:00
Colin Maudry acb8500dc0 Test e2e : recherche → observatoire #65
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:02:27 +01:00
Colin Maudry e804b6bca2 URL partageable pour la page observatoire #65
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:59:32 +01:00
Colin Maudry 3745f6df74 Callback URL → filtres sur la page observatoire #65
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:55:41 +01:00
Colin Maudry bf1791635f Ajout du lien observatoire dans titulaire_nom via add_links() #65
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:48:05 +01:00
Colin Maudry 1d88682f85 Ajout du lien observatoire dans acheteur_nom via add_links() #65
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:43:10 +01:00
Colin Maudry d1a876ba9c Plan d'implémentation : lien observatoire depuis recherche/tableau #65
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 14:35:58 +01:00
Colin Maudry 3a70bbd9ea Ajout du spec : lien observatoire depuis recherche/tableau #65
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 14:24:11 +01:00
Colin Maudry 79a06f996e Bouton de téléchargement des données #65 2026-03-18 13:54:00 +01:00
Colin Maudry 2f90a754ab Style inputs, ajout donut type marché #65 2026-03-18 13:38:38 +01:00
Colin Maudry d50ec5b01e Statistiques => Observatoire #65 2026-03-17 22:59:01 +01:00
Colin Maudry c02fb995c5 Ajout de filtres et des donuts de catégorie #65 2026-03-17 22:44:15 +01:00
Colin Maudry 6c778882f9 Filtre par type de marché #65 2026-03-17 20:51:09 +01:00
Colin Maudry e754a3a217 Configuration fine des tailles de cartes #65 2026-03-17 20:33:36 +01:00
Colin Maudry 9629231e29 Fixed masquage silencieux de la chloropleth #65 2026-03-17 17:30:41 +01:00
Colin Maudry ab3377ef60 Grid de cards avec points de rupture #65 2026-03-17 17:16:24 +01:00
Colin Maudry f531ce7091 Choix de carte dynamique, même pour les TOM #65 2026-03-16 18:06:32 +01:00
Colin Maudry adc8457abc Cartes avec cluster de points si > lignes #65 2026-03-15 16:05:33 +01:00
Colin Maudry 302d253e6e Utilisation de cluster de marqueurs grâce à dash-leaflet #65 2026-03-14 00:28:02 +01:00
Colin Maudry f0f9d8cb3d dashboard_acheteur_departement_code est une liste 2026-03-14 00:15:53 +01:00
Colin Maudry 4771d14744 Utilise map_count_marches si trop de marchés 2026-03-14 00:00:15 +01:00
Colin Maudry d9f97cf8b3 Modification de map_count_marches (plus efficace, utilise le dép de l'acheteur) 2026-03-13 23:59:54 +01:00
Colin Maudry c7d1a5ec73 Début de dashboard avec quelques filtres et viz #65 2026-03-13 19:22:48 +01:00
Colin Maudry 4f19085b75 Merge branch 'main' into feature/65_observatoire 2026-03-03 14:31:00 +01:00
Colin Maudry 0db800fdab Changelog 2.6.2 2026-02-22 18:41:01 +01:00
Colin Maudry 4619dd2708 Correction du téléchargemnent buggé dans /tableau + test 2026-02-22 18:39:31 +01:00
Colin Maudry 367e5f64ff Changelog 2.6.1 2026-02-17 14:50:29 +01:00
Colin Maudry adda58bada Suppresion des liens canoniques, pas trouvé comment les insérer assez tôt 2026-02-17 14:24:46 +01:00
Colin Maudry 2b0b048520 Amélioration du nom titulaire/acheteur 2026-02-15 16:54:30 +01:00
Colin Maudry 5c43bfea78 AMélioration de la génération de liens canoniques (pas de doublons) 2026-02-15 16:50:09 +01:00
Colin Maudry 15c5a800ed Merge tag 'v2.6.0' into dev
- 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)
2026-02-05 18:23:44 +01:00
Colin Maudry 83119b86e9 Merge branch 'release/2.6.0' 2026-02-05 18:23:26 +01:00
Colin Maudry af89bb0630 Plus grosses cases à cocher 2026-02-05 18:22:17 +01:00
Colin Maudry eb5be1972e Correction de soucis de sauvegarde des colonnes dans acheteur.py 2026-02-05 18:10:20 +01:00
Colin Maudry b303a8bea6 Bump version 2.6.0 et changelog 2026-02-05 17:29:54 +01:00
Colin Maudry 472fbb7cbb Améliorations des textes et petits ajustements 2026-02-05 17:16:31 +01:00
Colin Maudry 6891aabd4d Merge branch 'feature/ui_tests' into dev 2026-02-05 15:05:59 +01:00
Colin Maudry 3beedbcfe4 Test de la persistence des fitres (toutes pages) 2026-02-05 15:05:37 +01:00
Colin Maudry 37e893d642 Utilisation de la persistence sur /acheteurs 2026-02-05 14:54:30 +01:00
Colin Maudry 64fc40e5aa Meilleure gestion des SIRET absents du SIRENE dans acheteur et titulaire 2026-02-05 14:18:38 +01:00
Colin Maudry cb885eeb90 Utilisation des options de persistence de Dash 2026-02-04 23:59:01 +01:00
Colin Maudry 61dbf237cf Premier test UI 2026-02-04 23:33:59 +01:00
Colin Maudry 39af4bafbe titulaire : clientside callback ajouté 2026-02-04 13:05:59 +01:00
Colin Maudry f480439985 acheteur : le clientside callback est aussi trigger quand les colonnes affichées changent 2026-02-04 13:04:11 +01:00
Colin Maudry ad9b4f0fdf Nettoyage des filtres aussi sur acheteur (clientside callback) 2026-02-04 12:17:41 +01:00
Colin Maudry 0532f6b0a7 Colonnes, filtres et tri de la page titulaire en LocalStorage (buggy) 2026-02-04 09:46:22 +01:00
Colin Maudry 6fdf01e5e1 Colonnes, filtres et tri de la page acheteur en LocalStorage 2026-02-02 19:56:27 +01:00
Colin Maudry 15de2aaf17 Placeholder de dropdown plus visible 2026-02-02 19:51:40 +01:00
Colin Maudry 55e2992559 id table => tableau_datatable 2026-02-02 19:51:21 +01:00
Colin Maudry f16cca036d Toutes les pages s'ouvrent dans le même onglet puisque plus de perte de filtre 2026-02-02 19:02:27 +01:00
Colin Maudry deba0a244e Ajout d'un lien canonique vers chaque page dans le head #70
Dynamique, donc pas sûr que les moteurs de recherche le voit.
2026-02-02 18:28:50 +01:00
Colin Maudry ef11184286 Les colonnes masquées sont stockées en LocalStorage et correctement synchronisées 2026-02-02 17:11:32 +01:00
Colin Maudry edd52d471e Suppression (pour l'instant de la carte acheteur/titulaire) 2026-01-31 19:20:41 +01:00
Colin Maudry 7b3ea9c580 Les tris sont stockés en LocalStorage 2026-01-31 19:12:34 +01:00
Colin Maudry bfc4065cbb Les filtres sont stockés en LocalStorage 2026-01-31 18:28:19 +01:00
Colin Maudry 41a832d7be Tableau de choix des colonnes aussi sur les pages acheteur et titulaire 2026-01-31 17:38:54 +01:00
Colin Maudry 1561cb3c9a Tableau de choix des colonnes => filtrable 2026-01-31 01:44:11 +01:00
Colin Maudry f1725a95f8 Une ligne de boutons 2026-01-31 01:41:08 +01:00
Colin Maudry b59afd1cbc Merge branch 'feature/columns_modal' into dev 2026-01-31 01:34:20 +01:00
Colin Maudry 75b42f4baf Correction bug style cell 2026-01-31 01:33:26 +01:00
Colin Maudry 8f5d38ff78 Petites améliorations sur les boutons 2026-01-31 01:29:37 +01:00
Colin Maudry bd149ff11f Meilleure gestion des styles dans les tableaux 2026-01-31 01:27:59 +01:00
Colin Maudry cd81aa9532 Syncro sélection de colonnes et tableau fonctionnelle 2026-01-31 01:04:49 +01:00
Colin Maudry 2a381f6dbb Merge branch 'feature/mode_demploi_modal' into dev 2026-01-30 21:14:03 +01:00
Colin Maudry e609bc3e32 Weight des titres 2026-01-30 21:13:29 +01:00
Colin Maudry c3ebdb0057 Améliorations styles tableaux, headers 2026-01-30 20:13:59 +01:00
Colin Maudry 55bb92468e Styles de boutons homogènes 2026-01-30 20:05:58 +01:00
Colin Maudry 965aef06f9 Bonne application des fonts dans les cellules de tableau 2026-01-30 20:04:43 +01:00
Colin Maudry 3ce37784dc Modal mode d'emploi et style boutons 2026-01-30 19:22:54 +01:00
Colin Maudry cee51d929c Petites améliorations au suivi des recherches 2026-01-30 18:29:48 +01:00
Colin Maudry b11aa20743 Correction loggers 2026-01-30 15:19:46 +01:00
Colin Maudry 674639cf6e Merge branch 'main' into dev 2026-01-29 21:49:36 +01:00
Colin Maudry 68a87985c7 Bump version number 2.5.1 2026-01-29 21:48:35 +01:00
Colin Maudry 776aa271f1 Correction de la recherche #67 2026-01-29 21:48:13 +01:00
Colin Maudry 427eb111bf Merge tag 'v2.5.1' into dev
- 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))
2026-01-29 21:27:12 +01:00
Colin Maudry fe7277ce26 Merge branch 'hotfix/2.5.1' 2026-01-29 21:26:57 +01:00
Colin Maudry b68fa3872f Changelog 2.5.1 2026-01-29 21:26:54 +01:00
Colin Maudry b7ff04c69c Correction de l'affichage de la distance 2026-01-29 21:26:16 +01:00
Colin Maudry ccee27baee Correction de la couleur des bordures dans statistiques 2026-01-29 21:19:36 +01:00
Colin Maudry 47e17c48e7 Bugs de créations de noms acheteur/titulaire #67 #68 2026-01-29 21:16:35 +01:00
Colin Maudry e3b57608f4 padding right dans les td 2026-01-29 20:32:35 +01:00
Colin Maudry f4f0d70864 Merge branch 'release/2.5.0' 2026-01-29 20:26:31 +01:00
Colin Maudry 10c69013f0 Bump version 2.5.0 2026-01-29 20:26:00 +01:00
Colin Maudry c08e2fa143 Date changelog 2026-01-29 20:24:43 +01:00
Colin Maudry afd812c50d Suppression des styles non utilisés 2026-01-29 19:55:11 +01:00
Colin Maudry 536d37d67c Correction style tableau sources (bords) 2026-01-29 19:39:22 +01:00
Colin Maudry f43bb14e23 Alignement à droite pour les nombres 2026-01-29 19:38:21 +01:00
Colin Maudry 5422f95ea7 400 et 600 en weight 2026-01-29 19:37:56 +01:00
Colin Maudry 29f6e5c2c2 Finalisation en-têtes 2026-01-29 19:37:35 +01:00
Colin Maudry c311ed5f23 Doublons 2026-01-29 19:36:29 +01:00
Colin Maudry 31d0a626e6 Version number redesign 2026-01-29 19:24:07 +01:00
Colin Maudry 307e3379e0 Angles arrondis OK et tableau aligné 2026-01-29 18:48:30 +01:00
Colin Maudry 1fb18bcf6f Tentative d'angles ronds pour les data tables 2026-01-29 18:23:24 +01:00
Colin Maudry 30e822d435 Quelques améliorations visuelles sur les tableaux 2026-01-29 03:01:50 +01:00
Colin Maudry a44aaf6df6 Changelog 2026-01-29 02:43:14 +01:00
Colin Maudry 8c11a018a0 Alignement du logo et du numéro de version 2026-01-29 02:38:10 +01:00
Colin Maudry a0d6222424 Ajout des attributions pour les fonts 2026-01-29 02:25:47 +01:00
Colin Maudry 25115c305c Smoothing des fonts, logo plus fin 2026-01-29 02:25:24 +01:00
Colin Maudry 9d9533b8a2 Auto-cleanup des filtres après restauration URL 2026-01-29 01:41:21 +01:00
Colin Maudry e4f06acc56 Exemple de filtres domain-agnostic 2026-01-29 01:40:12 +01:00
Colin Maudry e0dc2687c4 Logging bug avec le contenu des filter_part 2026-01-29 01:39:27 +01:00
Colin Maudry 274e06cde4 Hébergement de bootstrap pour supprimer google fonts 2026-01-29 01:37:56 +01:00
Colin Maudry f9aadede8b robots.txt généré par le code #66 2026-01-29 01:37:08 +01:00
Colin Maudry d35862beb0 Réorganisation du CSS 2026-01-29 00:31:30 +01:00
Colin Maudry ed6811266d Nombreuses améliorations esthétiques et texte (font, couleurs, etc.) 2026-01-29 00:23:28 +01:00
Colin Maudry 06f658ccf1 Suivi des filtres comme des recherches, avec page source 2026-01-28 22:18:39 +01:00
Colin Maudry e4c6659205 Exclue des colonnes que si *_left *_right 2026-01-28 22:17:19 +01:00
Colin Maudry 434a659778 Généralisation du logging avec logger 2026-01-28 22:16:11 +01:00
Colin Maudry e573fcccae Page d'accueil plus accueillante, plus de recheche auto 2026-01-28 21:59:54 +01:00
Colin Maudry 84739df7cb Les pages dédiées au SEO ont leur répertoire #66 2026-01-28 21:59:06 +01:00
Colin Maudry 1d241ca9b7 Améliorations de l'UX tableau 2026-01-28 21:15:31 +01:00
Colin Maudry 0264aae3b9 Suivi des filtres appliqués 2026-01-28 18:42:25 +01:00
Colin Maudry fb5c37b34b Merge branch 'feature/66_seo' into dev 2026-01-28 14:06:03 +01:00
Colin Maudry 639b342178 Changelog #66 2026-01-28 14:02:25 +01:00
Colin Maudry e8534c8108 Finalement suppression des json-ld titulaire et acheteur #66 2026-01-28 14:01:15 +01:00
Colin Maudry f5d8026061 Ajout des données JSON-LD aux pages titulaires #66 2026-01-28 13:50:05 +01:00
Colin Maudry 1eecc45e29 Meta keywords (même si a priori ça aide pas pour le SEO) #66 2026-01-28 13:49:19 +01:00
Colin Maudry 07bdcf232d Ajout des données JSON-LD acheteur et fix make_org_jsonld #66 2026-01-28 13:32:08 +01:00
Colin Maudry 6e992e0a6e Ajout des données JSON-LD acheteur #66 2026-01-28 13:30:47 +01:00
Colin Maudry a76a14b030 Petits ajustements 2026-01-28 12:49:04 +01:00
Colin Maudry b3bd488882 Liste des marchés par acheteur et par titulaire #66 2026-01-28 12:48:47 +01:00
Colin Maudry a385e999d3 Plus jolie table des sources 2026-01-28 11:59:04 +01:00
Colin Maudry a4bda0483f Déplacement de la génération des df en bas, nouveaux dfs de base 2026-01-28 11:58:41 +01:00
Colin Maudry 9a1e5bee63 Départements sous forme de liste, ajout des titulaires par département #66 2026-01-28 11:57:41 +01:00
Colin Maudry 1c4fccac6e Nom de l'acheteur et du titulaire dans les titres #66 2026-01-28 11:50:55 +01:00
Colin Maudry 187feee544 Ajout d'une arborescence de departements #66 2026-01-27 17:48:49 +01:00
Colin Maudry 965f24ab92 Meta tag noindex sur test.decp.info 2026-01-26 07:02:03 +01:00
Colin Maudry 3aee2667d0 Meilleure description pour la page d'accueil 2026-01-26 07:01:35 +01:00
Colin Maudry 3d96a5e1b8 Ajout du JSON-LD #66 2026-01-24 17:19:47 +01:00
Colin Maudry 35928d7245 Namespacing de dcc.Location dans /acheteur 2026-01-24 14:44:32 +01:00
Colin Maudry ac42dde488 Fin de l'exception pour les colonnes *_left *_right 2026-01-24 14:43:33 +01:00
Colin Maudry ae366bf014 Page marché : objet en H1, durée restante, catégorie titulaire, distance 2026-01-24 14:42:45 +01:00
Colin Maudry bd64c397dc Namespacing des dcc.Location pour éviter les conflits entre pages 2026-01-24 14:35:12 +01:00
Colin Maudry 937e8be356 distance => titulaire_distance (+ affichage dans vue marché) 2026-01-24 14:19:02 +01:00
Colin Maudry e880eaff12 Description optionnelle 2026-01-22 18:58:23 +01:00
Colin Maudry 07a97293cc Merge branch 'main' into dev 2026-01-22 18:51:17 +01:00
Colin Maudry ef97cea679 Exclure les colonnes _right et _left 2026-01-22 18:49:08 +01:00
Colin Maudry c9fc2a01fc Changelog 2.4.1 2026-01-22 18:36:25 +01:00
Colin Maudry 8002bd711a Gestion des colonnes absentes du schéma 2026-01-22 18:32:27 +01:00
Colin Maudry 1ba78fe8df Màj changelog 2.4.0 2026-01-22 18:17:26 +01:00
Colin Maudry f1e24794e6 Màj changelog 2.4.0 2026-01-22 18:04:29 +01:00
Colin Maudry 9110b78f2a Merge tag 'v2.4.0' into dev
- Site à peu près utilisable sur petit écran (smartphone) ([#63](https://github.com/ColinMaudry/decp.info/issues/63))
- 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)
2026-01-22 17:58:47 +01:00
Colin Maudry 2ce6005c88 Merge branch 'release/2.4.0' 2026-01-22 17:57:53 +01:00
Colin Maudry 4234736656 Déblockage de tous les bots d'agents LLM 2026-01-22 17:42:39 +01:00
Colin Maudry 39faeca4d3 Màj mode d'emploi #42 2026-01-22 16:46:19 +01:00
Colin Maudry 82bd82944a Changelog #36 2026-01-22 16:36:55 +01:00
Colin Maudry 92f30a963f Ajout d'une table des matières dans A propos #36 2026-01-22 16:31:25 +01:00
Colin Maudry 267e2e15f8 Possibilité dans les champs non-numériques de filtrer le texte selon son début ou sa fin (text* et *text) #42 2026-01-21 18:04:33 +01:00
Colin Maudry dd52bd5158 Amélioration des titres et descriptions de pages #50 2026-01-21 17:47:03 +01:00
Colin Maudry d499906bbe Génération d'une sitemap #50 2026-01-21 17:40:53 +01:00
Colin Maudry 2fc501efbb Container de l'app toujours fluid, fix noms props #63 2026-01-21 16:32:20 +01:00
Colin Maudry 945f7974c8 Configuration du point de rupture de la navbar à 992px #63 2026-01-21 15:13:59 +01:00
Colin Maudry 1044379425 Meilleur affichage sur petit écran #63 2026-01-21 14:22:52 +01:00
Colin Maudry 421eaa0772 Premier jet navbar #63 2026-01-20 17:31:42 +01:00
Colin Maudry 3776b18ff5 Meilleure gestion des colonnes absentes du schéma 2026-01-20 11:24:26 +01:00
Colin Maudry 6005b2fcb5 Mise en valeur des moyens de consommer les données 2026-01-19 11:44:41 +01:00
Colin Maudry fc99723b2e Gestion des colonnes ajoutées mais absentes du schéma 2026-01-19 11:44:14 +01:00
Colin Maudry c3e801a15a Utilisation des dbc pour /marche #63 2026-01-19 11:34:20 +01:00
Colin Maudry 38ea8834af Merge tag 'v2.3.1' into dev
- 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
2026-01-16 14:16:47 +01:00
Colin Maudry efac172ac8 Merge branch 'hotfix/2.3.1' 2026-01-16 14:11:15 +01:00
Colin Maudry 337f68f2fc Changelog 2.3.1 2026-01-16 14:10:56 +01:00
Colin Maudry 2d43654c81 Ignore les champs absents du schéma 2026-01-16 14:08:42 +01:00
Colin Maudry 5bcd17e8b0 Skip les colonnes absentes du schéma 2026-01-16 13:57:19 +01:00
Colin Maudry ec28e89788 Style et texte 2026-01-16 03:14:56 +01:00
Colin Maudry 338311fc62 Style et texte 2026-01-16 02:57:35 +01:00
Colin Maudry 86b445a007 Tableau pour les statistiques par an 2026-01-16 02:18:17 +01:00
Colin Maudry 16a914a98a Ajout de la matrice de doublons par source 2026-01-16 02:17:47 +01:00
Colin Maudry 0bc82fae1e màj de l'état des sources de DECP 2026-01-16 00:31:27 +01:00
Colin Maudry 3c68317f9d Utilisation des stats produites par decp_processing 2026-01-14 18:16:50 +01:00
Colin Maudry d8dcb04fbf Fix anchor pour Sources 2025-12-27 10:18:01 +01:00
Colin Maudry edfe6dd65e Correction URL changelog 2025-12-24 11:44:40 +01:00
Colin Maudry 021361e2c1 Màj CHANGELOG.md 2025-12-24 11:34:23 +01:00
Colin Maudry 04ec9438f1 Liste de colonnes dans les URL plus compacte #58 2025-12-24 11:32:15 +01:00
Colin Maudry e6960dc16d Merge branch 'main' into dev 2025-12-24 11:05:28 +01:00
Colin Maudry 2fb71e466c Exemple d'URL dans le CHANGELOG 2025-12-24 11:02:35 +01:00
Colin Maudry b47463b638 L'URL de partage contient les colonnes à afficher, non les colonnes à masquer #58 2025-12-24 10:50:21 +01:00
Colin Maudry 84cf48182d Possibilité de filtrer une colonne avec plusieurs mots 2025-12-24 10:16:25 +01:00
Colin Maudry 0f11a575ff Merge tag 'v2.3.0' into dev
- 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
2025-12-24 09:46:48 +01:00
Colin Maudry c9459771ac Merge branch 'release/2.3.0' 2025-12-24 09:45:55 +01:00
Colin Maudry 99eef0eaa3 Ajout mode d'emploi #58 2025-12-24 09:40:15 +01:00
Colin Maudry 4719ef5754 Ajout de copy.svg 2025-12-24 09:35:55 +01:00
Colin Maudry 032ca5554e Déplacement des notes de version vers CHANGELOG.md 2025-12-24 09:31:33 +01:00
Colin Maudry 90d66bcbb6 Merge branch 'feature/58_filter_url' into dev 2025-12-18 04:25:39 +01:00
Colin Maudry 43104f611e Réparation des callbacks, mode d'emploi #58 2025-12-18 04:23:53 +01:00
Colin Maudry 24ef21761f Gestion des annonces dans .env 2025-12-18 01:37:58 +01:00
Colin Maudry e0fa8464d2 Merge tag 'v2.2.3' into dev
- mise à jour de l'adresse email de contact (colmo.tech)
- message sur l'indisponibilité des données MINEF
2025-12-04 16:40:14 +01:00
Colin Maudry 43fd2df2c0 Suggested by Gemini Pro, but race condition: filters apply before columns are created #58
Prompt: - In the tableau page (src/pages/tableau.py), I want to allow users to copy a URL in their clipboard that enables opening the page with the same view: applied filters, sorting and column selection of the datatable. Typically to share the view with a colleague via email or chat.
  In terms of Dash callbacks, that would mean the following:
	- one callback syncs the filters, the sort parameters and the selection of columns with a read-only text input that stores the URL to copy. This callback also ensures the button to copy to the clipboard is visible, possibly replacing the confirmation message (see next point)
	- one callback reacts to clicks on a button to store the URLin the clipboard. This button in on the same row as "Télécharger au format Excel" button. When clicked, the URL is stored in the clipboard, and the button is replaced with a confrmation message (URL copiée)
	- one callback reads the URL, and applies the filters, sorting and column selection
- The URL stores the view configuration (filters, sorting and columns) in URL parameters, similar to what is done in REST APIs. The values are the same as stored in the DataTable parameters, just URL encoded. The URL parameters are in French: filtres, tris, colonnes.
2025-12-03 16:31:05 +01:00
Colin Maudry 9ae6f29391 Plus de tolérance dans l'ingestion des data dicts 2025-11-24 16:54:50 +01:00
Colin Maudry 80a7e51ec4 Merge branch 'feature/share_filtering_datatables' into dev 2025-11-24 16:01:34 +01:00
Colin Maudry e216b42379 Meilleure gestion des df vides acheteur/titulaire 2025-11-24 16:00:21 +01:00
Colin Maudry 7f42ca67a2 Bonne configuration des boutons de téléchargemetn 2025-11-24 15:33:27 +01:00
Colin Maudry ec15852406 Téléchargemetn filtré OK dans acheteur 2025-11-24 14:00:28 +01:00
Colin Maudry 8c98a60c75 filtres, tris fonctionnels (acheteur) 2025-11-24 13:43:42 +01:00
Colin Maudry 565a0cfd08 Custom filter logic 2025-11-24 11:17:05 +01:00
Colin Maudry 53919bff3e Cast acheteur_year to int et non dateNotification to string 2025-11-24 10:41:47 +01:00
Colin Maudry 193cf5302c Merge branch 'main' into dev 2025-11-24 09:38:27 +01:00
Colin Maudry 59e363a1fc Merge tag 'v2.2.2' into dev
- Correction d'un bug dans le téléchargement Excel
2025-11-22 18:49:11 +01:00
Colin Maudry a51d2a83b4 Nettoyage 2025-11-18 15:37:47 +01:00
Colin Maudry 6b133c21ab Classe partagée pour la génération de datatables #56 2025-11-15 21:08:28 +01:00
Colin Maudry 503ec33f43 Classe partagée pour la génération de datatables #56 2025-11-15 21:01:59 +01:00
35 changed files with 18082 additions and 1418 deletions
+3
View File
@@ -3,6 +3,9 @@ PORT=8050
DEVELOPMENT=True
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
+188
View File
@@ -0,0 +1,188 @@
#### 2.7.1 (23 mars 2026)
- Correction du partage de données filtrées entre dashboard et vue des données
#### 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)
+89
View File
@@ -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
+3 -130
View File
@@ -1,8 +1,7 @@
# decp.info
> v2.2.3
Outil d'exploration et de téléchargement des données essentielles de la commande publique.
> v2.7.1
> Outil d'exploration et de téléchargement des données essentielles de la commande publique.
=> [decp.info](https://decp.info)
@@ -38,130 +37,4 @@ Ne pas oublier de mettre à jour les fichier .env.
## Notes de version
##### 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)
Voir [CHANGELOG](https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md).
+8
View File
@@ -391,8 +391,16 @@
"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 acheteurtitulaire", 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.
+25 -3
View File
@@ -1,13 +1,13 @@
[project]
name = "decp.info"
description = "Interface d'exploration et d'analyse des marchés publics français."
version = "2.2.3"
version = "2.7.1"
requires-python = ">= 3.10"
authors = [
{ name = "Colin Maudry", email = "colin@colmo.tech" }
]
dependencies = [
"dash==3.2.0",
"dash==3.4.0",
"dash[compress]",
"polars",
"gunicorn",
@@ -17,10 +17,32 @@ dependencies = [
"plotly[express]",
"httpx",
"pandas", # utilisé pour la création de certains graphiques
"unidecode"
"unidecode",
"dash-leaflet",
"dash-extensions"
]
[project.optional-dependencies]
dev = [
"pytest",
"pytest-env",
"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"
+112 -28
View File
@@ -1,19 +1,38 @@
import logging
import os
import dash_bootstrap_components as dbc
import tomllib
from dash import Dash, dcc, html, page_container, page_registry
from dash import Dash, Input, Output, State, dcc, html, page_container, page_registry
from dotenv import load_dotenv
from flask import send_from_directory
from flask import Response
load_dotenv()
app = Dash(
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",
use_pages=True,
compress=True,
meta_tags=meta_tags,
)
# COSMO (belle font, blue),
# UNITED (rouge, ubuntu font),
# LUMEN (gros séparateur, blue clair),
@@ -23,7 +42,29 @@ app = Dash(
# robots.txt
@app.server.route("/robots.txt")
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")
@@ -46,6 +87,7 @@ app.index_string = """
<title>{%title%}</title>
{%favicon%}
{%css%}
<!-- canonical link -->
</head>
<body>
{%app_entry%}
@@ -72,45 +114,87 @@ app.index_string = """
</html>
"""
app.layout = html.Div(
[
navbar = dbc.Navbar(
dbc.Container(
fluid=True,
children=[
dbc.NavItem(
children=[
html.Div(
[
html.Div(
[
html.A(children=html.H1("decp.info"), href="/"),
dcc.Link(html.H1("decp.info"), href="/", className="logo"),
html.P(
children=html.A(
[
html.A(
version,
href="https://github.com/ColinMaudry/decp.info?tab=readme-ov-file#notes-de-version",
target="_blank",
),
href="https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md",
)
],
className="version",
),
],
className="logo",
className="logo-wrapper",
)
],
style={"minWidth": "230px"},
),
html.Div(
id="announcements",
children=dcc.Markdown(
"️ La publication des données consolidées par le MINEF est actuellement [cassée](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-fichiers-consolides/#/discussions/69306148a4871a110fd7a7d0), ce qui casse la mise à jour de decp.info. Je travaille donc en priorité à une [désolidarisation des données du MINEF](https://github.com/ColinMaudry/decp-processing/issues/151). Merci pour votre patience."
dbc.Nav(
children=[dcc.Markdown(os.getenv("ANNOUNCEMENTS"), id="announcements")],
style={
"maxWidth": "1200px",
"display": "inline-block",
},
navbar=True,
id="announcements-nav",
),
),
html.Div(
dbc.NavbarToggler(id="navbar-toggler"),
dbc.Collapse(
dbc.Nav(
[
dcc.Link(
page["name"], href=page["relative_path"], className="nav"
dbc.NavItem(
dbc.NavLink(
page["name"].replace(" ", " "),
href=page["relative_path"],
active="exact",
)
)
for page in page_registry.values()
if page["name"] not in ["Acheteur", "Titulaire", "Marché"]
]
if page["name"]
in ["Recherche", "À propos", "Tableau", "Observatoire"]
],
className="ms-auto",
navbar=True,
),
id="navbar-collapse",
navbar=True,
),
],
className="navbar",
),
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",
),
]
)
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
+1
View File
@@ -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

File diff suppressed because it is too large Load Diff
+544
View File
@@ -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;
}
+132
View File
@@ -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;
},
},
});
-41
View File
@@ -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: /
-286
View File
@@ -1,286 +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;
}
#instructions {
max-width: 1000px;
}
details > div {
padding-top: 24px;
}
/* Logo et version */
div.logo {
width: 230px;
}
p.version {
float: left;
margin-top: 21px;
margin-left: 12px;
}
p.version > a {
text-decoration: none;
}
div.logo h1 {
float: left;
}
div.logo > a {
text-decoration: none;
color: black;
}
/* Réduire la taille du texte de la colonne Objet */
/*
td[data-dash-column="objet"],td[data-dash-column="titulaire_nom"],td[data-dash-column="acheteur_nom"], {
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;
font-family: "Open Sans", sans-serif;
text-align: left;
}
.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;
}
.dash-tooltip,
.dash-table-tooltip {
color: #333;
width: 400px !important;
max-width: 400px !important;
height: 150px !important;
max-height: 150px !important;
overflow: hidden;
}
.dash-tooltip pre,
.dash-tooltip code {
overflow: hidden;
height: 150px;
text-wrap: wrap;
font-family: "Open Sans", sans-serif;
}
/* Menu de masquage des colonnes */
.column-actions {
margin-right: 8px;
}
.column-header--hide {
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 */
.marches_table {
font-family: "Open Sans", sans-serif;
}
.marches_table .cell-table tr:nth-child(even) td {
background-color: #feeeee;
font-family: "Open Sans", sans-serif;
}
#header > *,
.dash-spreadsheet-menu button.export {
margin: 0 0 20px 20px;
}
/* Annonces */
#announcements {
margin-top: 25px;
max-width: 30%;
font-size: 90%;
}
/* Page de recherche */
.tagline {
text-align: center;
font-size: 120%;
display: block;
margin-top: 50px;
}
#search {
margin: 30px auto 0px auto;
width: 500px;
font-size: 16px;
height: 30px;
display: block;
}
.search_options {
margin: 16px auto;
width: 450px;
}
.search_options input {
margin-right: 12px;
}
.results_acheteur {
grid-column: 1;
grid-row: 1;
}
.results_titulaire {
grid-column: 2;
grid-row: 1;
}
/* Menu de navigation */
.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%;
}
h3 {
margin: 36px 0 24px 0;
}
summary > h3 {
margin: 0;
display: inline;
}
#_pages_content {
padding-top: 28px;
}
/* Vue acheteur/titulaire/recherche */
.wrapper {
display: grid;
grid-gap: 10px;
margin-bottom: 50px;
justify-content: space-between;
}
.org_title {
grid-column: 1 / 3;
grid-row: 1;
}
.org_year {
grid-column: 3;
grid-row: 1;
}
.org_infos {
grid-column: 1;
grid-row: 2;
}
.org_infos > p {
margin: 8px 0;
}
.org_stats {
grid-column: 2;
grid-row: 2;
}
.org_map {
grid-column: 3;
grid-row: 2;
}
.org_top {
grid-column: 1/3;
grid-row: 3;
}
/* Vue marché */
.marche_infos p {
margin-top: 6px;
margin-bottom: 6px;
}
.marche_title {
grid-column: 1/3;
grid-row: 1;
}
.marche_map {
grid-column: 3;
grid-row: 2;
width: 400px;
}
.marche_infos_1 {
grid-column: 1;
grid-row: 2;
}
.marche_infos_2 {
grid-column: 2;
grid-row: 2;
}
-65
View File
@@ -1,65 +0,0 @@
import polars as pl
from dash import dash_table, html
from utils import add_links_in_dict, format_values, setup_table_columns
def get_top_org_table(data, org_type: str):
dff = pl.DataFrame(data)
if dff.height == 0:
return html.Div()
dff = dff.select(
["uid", f"{org_type}_id", f"{org_type}_nom", "distance", "montant"]
)
dff_nb = dff.group_by(f"{org_type}_id", f"{org_type}_nom", "distance").agg(
pl.len().alias("Attributions"), pl.sum("montant").alias("montant")
)
dff_nb = dff_nb.sort(by="montant", descending=True)
dff_nb = dff_nb.cast(pl.String)
dff_nb = dff_nb.fill_null("")
dff_nb = format_values(dff_nb)
columns, tooltip = setup_table_columns(
dff_nb, hideable=False, exclude=[f"{org_type}_id"]
)
data = dff_nb.to_dicts()
data = add_links_in_dict(data, f"{org_type}")
return dash_table.DataTable(
data=data,
markdown_options={"html": True},
page_action="native",
page_size=10,
columns=columns,
cell_selectable=False,
tooltip_header=tooltip,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
"fontSize": "85%",
},
{
"if": {"column_id": "acheteur_nom"},
"minWidth": "200px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "16px",
# "fontSize": "85%",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "200px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "16px",
"whiteSpace": "normal",
# "fontSize": "85%",
},
],
)
+740 -73
View File
@@ -1,73 +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.graph_objects as go
import polars as pl
from dash import dash_table, dcc, html
from dash_extensions.javascript import Namespace
def get_map_count_marches(df: pl.DataFrame):
lf = df.lazy()
lf = lf.with_columns(
pl.col("lieuExecution_code").str.head(2).str.zfill(2).alias("Département")
)
lf = (
lf.select(["uid", "Département"])
.drop_nulls()
.unique(subset="uid")
.group_by("Département")
.len("uid")
)
# Suppression des infos pour les DOM/TOM pour l'instant
lf = lf.remove(pl.col("Département").is_in(["97", "98"]))
with open("./data/departements-1000m.geojson") as f:
departements = json.load(f)
# Ajout de feature.id
for f in departements["features"]:
f["id"] = f["properties"]["code"]
df = lf.collect(engine="streaming")
fig = px.choropleth(
from src.utils import (
add_links,
data_schema,
departements_geojson,
df,
geojson=departements,
locations="Département",
color="uid",
color_continuous_scale="Reds",
title="Nombres de marchés attribués par département (lieu d'exécution)",
range_color=(df["uid"].min(), df["uid"].max()),
labels={"uid": "Marchés attribués"},
scope="europe",
width=900,
height=700,
format_number,
setup_table_columns,
)
fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
mapbox={
"style": "carto-positron",
"center": {"lon": 10, "lat": 10},
"zoom": 1,
"domain": {"x": [0, 1], "y": [0, 1]},
def get_yearly_statistics(statistics, today_str) -> html.Div:
# Build DataFrame from statistics
years = list(reversed(range(2018, int(today_str.split("/")[-1]) + 1)))
data = []
for year in years:
year_str = str(year)
stat = statistics[year_str]
data.append(
{
"Année": year_str,
"Marchés et accord-cadres": format_number(
stat["nb_notifications_marches"]
),
"Acheteurs": format_number(stat["nb_acheteurs_uniques"]),
"Titulaires": format_number(stat["nb_titulaires_uniques"]),
}
)
return fig
dff = pl.DataFrame(data)
# Create Dash DataTable
table = dash_table.DataTable(
data=dff.to_dicts(),
columns=[
{"name": "Année", "id": "Année"},
{"name": "Marchés et accord-cadres", "id": "Marchés et accord-cadres"},
{"name": "Acheteurs", "id": "Acheteurs"},
{"name": "Titulaires", "id": "Titulaires"},
],
page_size=10,
sort_action="none",
filter_action="none",
style_header={"fontFamily": "Inter", "fontSize": "16px"},
style_cell={"fontFamily": "Inter", "fontSize": "16px"},
)
return html.Div(children=table, className="marches_table")
def get_barchart_sources(df: pl.DataFrame, type_date: str):
lf = df.lazy()
def get_barchart_sources(lff: pl.LazyFrame, type_date: str):
labels = {
"dateNotification": "notification",
"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
lf = lf.with_columns(
lff = lff.with_columns(
pl.when(pl.col("sourceDataset").str.starts_with("atexo"))
.then(pl.lit("plateformes atexo"))
.otherwise(pl.col("sourceDataset"))
@@ -75,38 +82,33 @@ def get_barchart_sources(df: pl.DataFrame, type_date: str):
)
# 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"))
.then(pl.lit("aws"))
.otherwise(pl.col("sourceDataset"))
.alias("sourceDataset")
)
lf = lf.with_columns(pl.col(type_date).dt.year().alias("annee"))
lf = lf.filter(
pl.col(type_date).is_not_null() & pl.col("annee").is_between(2019, 2025)
lff = lff.with_columns(pl.col(type_date).dt.year().alias("annee"))
lff = lff.filter(
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))
lf = (
lf.group_by([type_date, "sourceDataset"])
lff = lff.with_columns(pl.col(type_date).cast(pl.String).str.head(7))
lff = (
lff.group_by([type_date, "sourceDataset"])
.len()
.sort(by=[type_date, "len"], descending=True)
)
# lf = lf.with_columns(
# pl.when(pl.col("sourceDataset").is_null()).then(
# pl.lit("Source inconnue")).alias("sourceDataset")
# )
lff = lff.sort(by=["sourceDataset"], descending=False)
lf = lf.sort(by=["sourceDataset"], descending=False)
df: pl.DataFrame = lf.collect(engine="streaming")
dff: pl.DataFrame = lff.collect(engine="streaming")
fig = px.bar(
df,
dff,
x=type_date,
y="len",
color="sourceDataset",
title=f"Nombre de marchés attribués par date de {labels[type_date]} et source de données",
labels={
"len": "Nombre de marchés",
type_date: f"Mois de {labels[type_date]}",
@@ -114,12 +116,17 @@ def get_barchart_sources(df: pl.DataFrame, type_date: str):
},
)
return fig
graph = dcc.Graph(figure=fig)
return graph
def get_sources_tables(source_path) -> html.Div:
df = pl.read_csv(source_path)
df = df.with_columns(
try:
dff = pl.read_csv(source_path)
except (URLError, HTTPError):
return html.Div("Erreur de connexion")
dff = dff.with_columns(
(
pl.lit('<a href = "')
+ pl.col("url")
@@ -128,20 +135,29 @@ def get_sources_tables(source_path) -> html.Div:
+ pl.lit("</a>")
).alias("nom")
)
df = df.drop("url")
df = df.sort(by=["nb_marchés"], descending=True)
dff = dff.drop("url", "unique")
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(
id="source_table",
data=dff.to_dicts(),
columns=[
{
"name": i,
"name": columns[i],
"id": i,
"presentation": "markdown",
"type": "text",
"format": {"nully": "N/A"},
}
for i in df.schema.names()
for i in dff.schema.names()
],
style_cell_conditional=[
{
@@ -155,8 +171,9 @@ def get_sources_tables(source_path) -> html.Div:
],
sort_action="native",
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)
@@ -191,4 +208,654 @@ def point_on_map(lat, lon):
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",
)
+136 -46
View File
@@ -10,18 +10,24 @@ name = "À propos"
register_page(
__name__,
path="/a-propos",
title=meta_content["title"],
name=name,
description=meta_content["description"],
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(
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.
@@ -32,30 +38,39 @@ En effet, le potentiel des données d'attribution de marchés et des données qu
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...)
- 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("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("""
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("""
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 !
#### Pour explorer le projet
"""
),
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)
@@ -63,34 +78,40 @@ les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
- 🚰 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.
"""
),
html.H4(
"Qualité et exhaustivité des données",
id="qualite-exhausitivite",
),
dcc.Markdown(
"""Les données visibles sur ce site proviennent exclusivement de la publication de données ouvertes par les acheteurs publics ou en leur nom, régie par [l'arrêté du 22 décembre 2022](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000046850496). Leur qualité est donc principalement liée à la qualité de leur saisie par les agents publics, parfois peu aidé·es par la qualité des outils à leur disposition. Je pense que l'analyse de marchés individuels et le comptage de marchés sur des critères autres que financiers sont plutôt fiables. En revanche, certains montants de marché estimés à des valeurs farfelues ([1 euro](https://decp.info/marches/432766947000192025S01301), [1 milliard](https://decp.info/marches/2459004280001320210000000271)) faussent les calculs par aggrégation (sommes, moyennes, médianes) et donc la production de statistiques financières fiables. Acheteurs, acheteuses : s'il vous plaît, essayez d'estimer les montants des marchés publics attribués de manière plus précise.
Quant à l'exhaustivité, je consolide toutes les sources de données exploitables que j'ai pu identifier (voir [statistiques](/statistiques)). Certains profils d'acheteurs ne publient pas leurs données malgré l'obligation réglementaire :
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 :
- marches-securises.fr
- klekoon.fr (ils y travaillent)
**marches-publics.gouv.fr** (PLACE, [AIFE](https://aife.economie.gouv.fr)), la plateforme des marchés de l'État, ne publie plus de données depuis le 1er janvier 2024 et l'entrée en vigueur de l'arrêté. Nous n'avons donc plus de données sur les marchés publics passés par les ministères, ainsi que sur ceux passés par les acheteurs clients d'[achatpublic.com](https://www.achatpublic.com), qui s'appuie sur les moyens de publication de l'AIFE.
- 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="qualite-exhausitivite"),
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"),
dcc.Markdown("""
##### Publication
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 lEvêque, 75008 Paris
##### Suivi d'audience
"""
),
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 naffiche pas de bannière de consentement aux cookies, pourquoi ?**
@@ -99,21 +120,90 @@ Cest vrai, vous navez pas eu à cliquer sur un bloc qui recouvre la moiti
Rien dexceptionnel, je respecte simplement la loi, qui dit que certains outils de suivi daudience, correctement configurés pour respecter la vie privée, sont exemptés dautorisation préalable.
Jutilise 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 mest donc impossible dassocier 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"
# ),
Jutilise 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 mest donc impossible dassocier 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",
),
]
),
],
),
],
),
],
)
]
+349 -118
View File
@@ -1,53 +1,106 @@
import datetime
from typing import Any
import dash_bootstrap_components as dbc
import polars as pl
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
from dash import (
ClientsideFunction,
Input,
Output,
State,
callback,
clientside_callback,
dcc,
html,
register_page,
)
from src.callbacks import get_top_org_table
from src.figures import point_on_map
from src.figures import (
DataTable,
get_distance_histogram,
get_top_org_table,
make_card,
make_column_picker,
point_on_map,
)
from src.utils import (
add_links_in_dict,
columns,
df,
df_acheteurs,
filter_table_data,
format_number,
format_values,
get_annuaire_data,
get_button_properties,
get_default_hidden_columns,
get_departement_region,
meta_content,
setup_table_columns,
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=meta_content["title"],
title=get_title,
name="Acheteur",
description=meta_content["description"],
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.Location(id="url", refresh="callback-nav"),
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(
className="container",
children=[
html.Div(
className="wrapper",
style={"marginBottom": "50px"},
children=[
dbc.Row(
className="mb-2",
children=[
dbc.Col(
html.H2(
className="org_title",
children=[
html.Span(id="acheteur_siret"),
" - ",
html.Span(id="acheteur_nom"),
],
),
html.Div(
className="org_year",
children=dcc.Dropdown(
width=8,
),
dbc.Col(
dcc.Dropdown(
id="acheteur_year",
options=["Toutes"]
options=["Toutes les années"]
+ [
str(year)
for year in range(
@@ -56,52 +109,129 @@ layout = [
],
placeholder="Année",
),
width=4,
),
html.Div(
],
),
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(
[
"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.P(
["Région : ", html.Strong(id="acheteur_region")]
),
html.A(
id="acheteur_lien_annuaire",
children="Plus de détails sur l'Annuaire des entreprises",
target="_blank",
),
],
width=4,
),
html.Div(
className="org_stats",
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-acheteur-data",
id="btn-download-data-acheteur",
className="btn btn-primary",
),
dcc.Download(id="download-data-acheteur"),
],
width=4,
),
dbc.Col(
id="acheteur_map",
width=4,
),
dcc.Download(id="download-acheteur-data"),
],
),
html.Div(className="org_map", id="acheteur_map"),
html.Div(
className="org_top",
dbc.Row(
children=[
html.H3("Top titulaires"),
html.Div(className="marches_table", id="top10_titulaires"),
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"),
html.Div(id="acheteur_last_marches", children=""),
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,
],
),
],
),
]
@@ -115,16 +245,19 @@ layout = [
Output(component_id="acheteur_departement", component_property="children"),
Output(component_id="acheteur_region", component_property="children"),
Output(component_id="acheteur_lien_annuaire", component_property="href"),
Input(component_id="url", component_property="pathname"),
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})"
)
# if len(acheteur_siret) != 14:
# acheteur_siret = (
# f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})"
# )
data = get_annuaire_data(acheteur_siret)
data_etablissement = data["matching_etablissements"][0]
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"]
)
@@ -135,10 +268,21 @@ def update_acheteur_infos(url):
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,
data["nom_raison_sociale"],
data_etablissement["libelle_commune"],
raison_sociale,
libelle_commune,
acheteur_map,
departement,
nom_region,
@@ -154,7 +298,7 @@ def update_acheteur_infos(url):
Input(component_id="acheteur_data", component_property="data"),
)
def update_acheteur_stats(data):
dff = pl.DataFrame(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")
@@ -176,91 +320,51 @@ def update_acheteur_stats(data):
@callback(
Output(component_id="acheteur_data", component_property="data"),
Input(component_id="url", component_property="pathname"),
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) -> list[dict]:
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)
lff = lff.select(
"id",
"uid",
"objet",
"dateNotification",
"titulaire_id",
"titulaire_typeIdentifiant",
"titulaire_nom",
"distance",
"montant",
"codeCPV",
"dureeMois",
)
if acheteur_year and acheteur_year != "Toutes":
lff = lff.filter(
pl.col("dateNotification").cast(pl.String).str.starts_with(acheteur_year)
)
lff = lff.sort(["dateNotification", "id"], descending=True, nulls_last=True)
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 = lff.collect(engine="streaming").to_dicts()
return data
data = dff.to_dicts()
return data, download_disabled, download_text, download_title
@callback(
Output(component_id="acheteur_last_marches", component_property="children"),
Input(component_id="acheteur_data", component_property="data"),
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_table(data) -> html.Div:
dff = pl.DataFrame(data)
if dff.height == 0:
return html.Div(html.P("Aucun marché trouvé."))
dff = dff.cast(pl.String)
dff = dff.fill_null("")
dff = format_values(dff)
columns, tooltip = setup_table_columns(
dff,
hideable=False,
exclude=["titulaire_id", "titulaire_typeIdentifiant", "uid"],
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"
)
data = dff.to_dicts()
data = add_links_in_dict(data, "titulaire")
table = html.Div(
className="marches_table",
id="acheteur_datatable",
children=dash_table.DataTable(
data=data,
markdown_options={"html": True},
page_action="native",
filter_action="native",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
columns=columns,
tooltip_header=tooltip,
tooltip_duration=8000,
tooltip_delay=350,
cell_selectable=False,
page_size=10,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "300px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "200px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
),
)
return table
@callback(
@@ -268,12 +372,13 @@ def get_last_marches_table(data) -> html.Div:
Input(component_id="acheteur_data", component_property="data"),
)
def get_top_titulaires(data):
return get_top_org_table(data, "titulaire")
table = get_top_org_table(data, "titulaire", ["titulaire_distance"])
return make_card(fig=table, title="Top titulaires", lg=12, xl=12)
@callback(
Output("download-acheteur-data", "data"),
Input("btn-download-acheteur-data", "n_clicks"),
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"),
@@ -281,7 +386,7 @@ def get_top_titulaires(data):
)
def download_acheteur_data(
n_clicks,
data: [dict],
data: list[dict[str, Any]],
acheteur_nom: str,
annee: str,
):
@@ -289,8 +394,134 @@ def download_acheteur_data(
def to_bytes(buffer):
df_to_download.write_excel(
buffer, worksheet="DECP" if annee in ["Toutes", None] else annee
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 acheteurtitulaire",
subtitle="en nombre de marchés, échelle logarithmique",
fig=fig,
lg=12,
xl=12,
)
View File
+78
View File
@@ -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
+25
View File
@@ -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()
]
),
]
)
+101
View File
@@ -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
+106 -22
View File
@@ -1,17 +1,31 @@
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, meta_content
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=meta_content["title"],
title=get_title,
name="Marché",
description=meta_content["description"],
description="Consultez les détails de ce marché public : montant, acheteur, titulaires, modifications, etc.",
image_url=meta_content["image_url"],
order=7,
)
@@ -19,11 +33,18 @@ register_page(
layout = [
dcc.Store(id="marche_data"),
dcc.Store(id="titulaires_data"),
dcc.Location(id="url", refresh="callback-nav"),
html.Div(
className="container marche_infos",
dcc.Location(id="marche_url", refresh="callback-nav"),
html.Script(type="application/ld+json", id="marche_jsonld"),
dbc.Container(
className="marche_infos",
children=[
html.P("Vous consultez un résumé des données de ce marché public"),
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(
@@ -37,20 +58,22 @@ layout = [
html.P(
"Le montant total payé aux titulaires, la durée du marché et la liste des titulaires peuvent cependant encore évoluer jusqu'à la fin de l'exécution du marché."
),
html.Div(
className="wrapper",
children=[
html.Div(
className="marche_map",
id="marche_map",
]
)
),
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"),
],
),
html.Div(className="marche_infos_1", id="marche_infos_1"),
html.Div(className="marche_infos_2", id="marche_infos_2"),
],
]
),
],
),
@@ -60,7 +83,7 @@ layout = [
@callback(
Output("marche_data", "data"),
Output("titulaires_data", "data"),
Input(component_id="url", component_property="pathname"),
Input(component_id="marche_url", component_property="pathname"),
)
def get_marche_data(url) -> tuple[dict, list]:
marche_uid = url.split("/")[-1]
@@ -81,6 +104,7 @@ def get_marche_data(url) -> tuple[dict, list]:
@callback(
Output("marche_objet", "children"),
Output("marche_infos_1", "children"),
Output("marche_infos_2", "children"),
Output("marche_infos_titulaires", "children"),
@@ -88,7 +112,7 @@ def get_marche_data(url) -> tuple[dict, list]:
Input("titulaires_data", "data"),
)
def update_marche_info(marche, titulaires):
def make_parameter(col):
def make_parameter(col, bold=True):
column_object = data_schema.get(col)
column_name = column_object.get("title") if column_object else col
@@ -132,12 +156,14 @@ def update_marche_info(marche, titulaires):
else:
value = ""
param_content = html.P([column_name, " : ", html.Strong(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("objet"),
make_parameter("dateNotification"), # date
make_parameter("nature"),
make_parameter("acheteur_nom"), # lien
@@ -146,6 +172,7 @@ def update_marche_info(marche, titulaires):
make_parameter("procedure"),
make_parameter("techniques"), # list
make_parameter("dureeMois"),
make_parameter("dureeRestanteMois"),
make_parameter("offresRecues"),
make_parameter("datePublicationDonnees"), # date
make_parameter("formePrix"),
@@ -171,14 +198,71 @@ def update_marche_info(marche, titulaires):
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_infos[:half], marche_infos[half:], titulaires_lines
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)
+934
View File
@@ -0,0 +1,934 @@
import urllib.parse
from datetime import datetime
import dash_bootstrap_components as dbc
import polars as pl
from dash import (
ALL,
Input,
Output,
State,
callback,
ctx,
dcc,
html,
no_update,
register_page,
)
from src.figures import (
DataTable,
get_barchart_sources,
get_dashboard_summary_table,
get_distance_histogram,
get_duplicate_matrix,
get_geographic_maps,
get_top_org_table,
make_card,
make_column_picker,
make_donut,
)
from src.utils import (
columns,
departements,
df,
df_acheteurs,
df_titulaires,
get_default_hidden_columns,
get_enum_values_as_dict,
logger,
meta_content,
prepare_dashboard_data,
prepare_table_data,
)
name = "Observatoire"
register_page(
__name__,
path="/observatoire",
title="Observatoire | decp.info",
name=name,
description="Visualisez l'état de la publication des données essentielles des marchés publics en France.",
image_url=meta_content["image_url"],
order=3,
)
options_years = []
for year in reversed(range(2017, datetime.now().year + 1)):
option_year = {
"label": str(year),
"value": year,
}
options_years.append(option_year)
options_departements = []
for code in departements.keys():
departement = {
"label": f"{departements[code]['departement']} ({code})",
"value": code,
}
options_departements.append(departement)
OBSERVATOIRE_COLUMNS = [
col
for col in df.columns
if col.startswith("acheteur")
or col.startswith("titulaire")
or col
in [
"uid",
"dateNotification",
"montant",
"considerationsSociales",
"considerationsEnvironnementales",
"marcheInnovant",
"sousTraitanceDeclaree",
"techniques",
"sourceDataset",
"type",
"codeCPV",
]
]
layout = [
dcc.Location(id="dashboard_url", refresh="callback-nav"),
dcc.Store(id="observatoire-filters", storage_type="local"),
dcc.Store(id="observatoire-hidden-columns", storage_type="local"),
dcc.Store(
id="filter-cleanup-trigger-observatoire-preview"
), # utilisé juste pour ne pas avoir à adapter les données retournées de prepare_table data
dbc.Modal(
[
dbc.ModalHeader(dbc.ModalTitle("Montants")),
dbc.ModalBody(
[
dcc.Markdown(
"""
Les données saisies et publiées par les acheteurs comportent de nombreux montants farfelus qui sabotent les statistiques, au lieu de montants estimés avec rigueur. On parle de montants atteignant parfois les millions de milliards. Certains réutilisateurs des données mettent de côté ces marchés ou bien modifient les montants selon des règles fatalement arbitraires. J'ai fait le choix de ne quasiment pas modifier les données* afin de visibiliser le problème.
Alors, on fait comment ?
\\* Les montants composés de plus de 11 chiffres, sans les décimales, [sont ramenés](https://github.com/ColinMaudry/decp-processing/blob/main/src/tasks/clean.py#L63-L71) à 12 311 111 111, un nombre qui reste très élevé et qui est facilement reconnaissable.
"""
),
]
),
dbc.ModalFooter(
dbc.Button("Fermer", id="montant-modal-close", className="ms-auto")
),
],
id="montant-modal",
is_open=False,
),
html.Div(
className="container-fluid",
children=[
html.H2(children=[name], id="page_title"),
dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-statistques",
type="default",
children=[
dbc.Row(
[
dbc.Col(
xl=3,
lg=4,
id="filters",
children=[
html.H5("Période d'attribution"),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_year",
options=options_years,
placeholder="12 derniers mois",
persistence=True,
persistence_type="local",
),
),
),
html.H5("Acheteur"),
dbc.Row(
dbc.Col(
dcc.Input(
id="dashboard_acheteur_id",
placeholder="SIRET",
debounce=True,
style={"width": "100%"},
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_acheteur_categorie",
options=get_enum_values_as_dict(
"acheteur_categorie"
),
placeholder="Catégorie",
persistence=True,
persistence_type="local",
)
),
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_acheteur_departement_code",
searchable=True,
multi=True,
placeholder="Département",
options=options_departements,
persistence=True,
persistence_type="local",
),
),
),
html.H5("Titulaire"),
dbc.Row(
dbc.Col(
dcc.Input(
id="dashboard_titulaire_id",
placeholder="SIRET",
debounce=True,
style={"width": "100%"},
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_titulaire_categorie",
placeholder="Catégorie",
options=get_enum_values_as_dict(
"titulaire_categorie"
),
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_titulaire_departement_code",
searchable=True,
multi=True,
placeholder="Département",
options=options_departements,
persistence=True,
persistence_type="local",
),
),
),
html.H5("Marché"),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_marche_type",
placeholder="Type",
options=get_enum_values_as_dict("type"),
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
dbc.Col(
dcc.Input(
id="dashboard_marche_objet",
placeholder="Objet",
debounce=True,
style={"width": "100%"},
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
[
dbc.Col(
dcc.Input(
id="dashboard_marche_code_cpv",
placeholder="Code CPV (début)",
debounce=True,
style={"width": "100%"},
persistence=True,
persistence_type="local",
),
lg=8,
),
dbc.Col(
html.A(
"liste des codes",
href="https://cpvcodes.eu/fr",
target="_blank",
),
lg=4,
),
]
),
dbc.Row(
[
dbc.Col(
dcc.Input(
id="dashboard_montant_min",
placeholder="Montant min.",
type="number",
min=0,
debounce=True,
style={"width": "100%"},
persistence=True,
persistence_type="local",
),
width=6,
),
dbc.Col(
dcc.Input(
id="dashboard_montant_max",
placeholder="Montant max.",
type="number",
min=0,
debounce=True,
style={"width": "100%"},
persistence=True,
persistence_type="local",
),
width=6,
),
]
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_marche_techniques",
placeholder="Techniques d'achat",
options=get_enum_values_as_dict(
"techniques"
),
multi=True,
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
[
dbc.Col("Sous-traitance :", lg=5),
dbc.Col(
dbc.RadioItems(
id="dashboard_marche_sous_traitance_declaree",
options=[
{
"label": "Tous",
"value": "all",
},
{
"label": "Oui",
"value": "oui",
},
{
"label": "Non",
"value": "non",
},
],
value="all",
inline=True,
persistence=True,
persistence_type="local",
),
lg=7,
),
]
),
dbc.Row(
[
dbc.Col("Marché innovant :", lg=5),
dbc.Col(
dbc.RadioItems(
id="dashboard_marche_innovant",
options=[
{
"label": "Tous",
"value": "all",
},
{
"label": "Oui",
"value": "oui",
},
{
"label": "Non",
"value": "non",
},
],
value="all",
inline=True,
persistence=True,
persistence_type="local",
),
lg=7,
),
]
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_marche_considerations_sociales",
placeholder="Considérations sociales",
options=get_enum_values_as_dict(
"considerationsSociales"
),
multi=True,
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_marche_considerations_environnementales",
placeholder="Considérations environnementales",
multi=True,
options=get_enum_values_as_dict(
"considerationsEnvironnementales"
),
persistence=True,
persistence_type="local",
),
),
),
dbc.Row(
[
dbc.Col(
[
dcc.Download(
id="download-observatoire"
),
dbc.Button(
"Voir les données",
id="btn-observatoire-preview",
className="btn btn-primary mt-2",
color="primary",
outline=True,
),
dcc.Input(
id="observatoire-share-url",
readOnly=True,
style={"display": "none"},
),
],
lg=12,
xl=6,
),
dbc.Col(
id="observatoire-copy-container",
lg=12,
xl=6,
),
]
),
],
),
dbc.Col(
width=12,
lg=8,
xl=9,
id="cards",
children=[],
),
]
)
],
),
],
),
dbc.Offcanvas(
id="observatoire-preview",
title="Prévisualisation des données",
placement="bottom",
is_open=False,
scrollable=True,
style={"height": "75vh"},
children=[
# Header row: title + "Colonnes affichées" button
dbc.Row(
[
dbc.Col(
html.Div(
className="table-menu",
children=[
dbc.Button(
"Choisir les colonnes",
id="observatoire-preview-columns-open",
className="btn btn-primary",
),
html.P(id="nb_rows_observatoire"),
dbc.Button(
"Télécharger au format Excel",
id="btn-download-observatoire",
disabled=True,
className="btn btn-primary",
outline=True,
),
],
),
width="auto",
),
],
className="mb-2 align-items-center",
),
# Column picker modal
dbc.Modal(
[
dbc.ModalHeader(
dbc.ModalTitle("Colonnes affichées dans la prévisualisation")
),
dbc.ModalBody(
id="observatoire-preview-columns-body",
children=make_column_picker("observatoire_preview"),
),
dbc.ModalFooter(
dbc.Button(
"Fermer",
id="observatoire-preview-columns-close",
className="ms-auto",
n_clicks=0,
)
),
],
id="observatoire-preview-columns-modal",
is_open=False,
fullscreen="md-down",
scrollable=True,
size="xl",
),
# DataTable
html.Div(
className="marches_table",
children=DataTable(
dtid="observatoire-preview-table",
page_size=5,
page_action="custom",
sort_action="custom",
filter_action="custom",
hidden_columns=[],
columns=[{"id": col, "name": col} for col in OBSERVATOIRE_COLUMNS],
),
),
],
),
]
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_sous_traitance_declaree", "sous_traitance", False, "all"),
("dashboard_marche_considerations_sociales", "social", True, None),
("dashboard_marche_considerations_environnementales", "env", True, None),
]
@callback(
*[Output(fp[0], "value") for fp in FILTER_PARAMS],
Input("dashboard_url", "search"),
Input("dashboard_url", "pathname"),
State("observatoire-filters", "data"),
)
def restore_filters(search, _pathname, stored_filters):
if search:
params = urllib.parse.parse_qs(search.lstrip("?"))
known_keys = {fp[1] for fp in FILTER_PARAMS}
if any(k in params for k in known_keys):
values = []
for _comp_id, url_key, is_multi, default in FILTER_PARAMS:
if url_key in params:
if is_multi:
values.append(params[url_key])
else:
raw = params[url_key][0]
if url_key in ("montant_min", "montant_max"):
try:
raw = float(raw)
except (ValueError, TypeError):
raw = None
values.append(raw)
else:
values.append(default)
return tuple(values)
return (no_update,) * 17
@callback(
Output("observatoire-share-url", "value"),
Output("observatoire-copy-container", "children"),
*[Input(fp[0], "value") for fp in FILTER_PARAMS],
Input("dashboard_url", "href"),
)
def sync_observatoire_share_url(*args):
# Last arg is href (State), rest are filter values
filter_values = args[:-1]
href = args[-1]
if not href:
print("no update")
return no_update, no_update
base_url = href.split("?")[0]
params = []
for (_, url_key, is_multi, default), value in zip(FILTER_PARAMS, filter_values):
if value is None or value == default or value == [] or value == "":
continue
if is_multi and isinstance(value, list):
for v in value:
params.append((url_key, v))
else:
params.append((url_key, value))
query_string = urllib.parse.urlencode(params)
full_url = f"{base_url}?{query_string}" if query_string else base_url
print("query", query_string)
if params:
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 cette vue",
id="btn-copy-observatoire",
className="btn btn-primary mt-2",
title="Copier l'adresse de cette vue filtrée pour la partager.",
)
],
)
else:
copy_button = html.Div()
return full_url, copy_button
@callback(
Output("observatoire-copy-container", "children", allow_duplicate=True),
Input("btn-copy-observatoire", "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("cards", "children"),
Output("observatoire-filters", "data"),
*[Input(fp[0], "value") for fp in FILTER_PARAMS],
)
def udpate_dashboard_cards(*filter_values):
lff: pl.LazyFrame = df.lazy()
# Filtrage des données
filter_params = {}
for (input_id, url_key, is_multi, default), value in zip(
FILTER_PARAMS, filter_values
):
filter_params[input_id] = value
print(filter_params)
lff = prepare_dashboard_data(lff=lff, **filter_params)
# Génération des métriques
dff = lff.collect(engine="streaming")
logger.debug("Filter data: " + str(dff.height))
df_per_uid = (
dff.select("uid", "montant").group_by("uid").agg(pl.col("montant").first())
)
nb_marches = df_per_uid.height
cards = []
card_summary_table = get_dashboard_summary_table(dff, df_per_uid, nb_marches)
cards.append(make_card(title="Résumé", paragraphs=card_summary_table))
donut_acheteur_categorie, nb_acheteur_categories = make_donut(
lff,
"acheteur_categorie",
nulls="Autres",
per_uid=True,
potentially_many_names=True,
)
cards.append(
make_card(
title="Catégorie d'acheteur",
subtitle="en nombre de marchés attribués",
fig=donut_acheteur_categorie,
lg=12 if nb_acheteur_categories > 4 else 6,
xl=8 if nb_acheteur_categories > 4 else 4,
)
)
donut_titulaire_categorie = make_donut(
lff, "titulaire_categorie", per_uid=False, nulls="?"
)
cards.append(
make_card(
title="Catégorie d'entreprise",
subtitle="en nombre de titulaires",
fig=donut_titulaire_categorie,
)
)
donut_marche_type = make_donut(lff, "type", per_uid=True, nulls="?")
cards.append(
make_card(
title="Type d'achat",
subtitle="en nombre de marchés attribués",
fig=donut_marche_type,
)
)
distance_histogram = get_distance_histogram(lff)
cards.append(
make_card(
title="Distance acheteurtitulaire",
subtitle="en nombre de marchés, échelle logarithmique",
fig=distance_histogram,
)
)
top_acheteurs = get_top_org_table(
lff, org_type="acheteur", filters=False, extra_columns=[]
)
cards.append(make_card(title="Top acheteurs", fig=top_acheteurs, lg=12, xl=8))
top_titulaires = get_top_org_table(
lff, org_type="titulaire", filters=False, extra_columns=[]
)
cards.append(make_card(title="Top titulaires", fig=top_titulaires, lg=12, xl=8))
geographic_maps: list[dbc.Col] = get_geographic_maps(dff)
other_cards = []
sources_barchart = get_barchart_sources(lff, type_date="dateNotification")
other_cards.append(
make_card(
title="Sources de données",
subtitle="Nombre de marchés attribués par mois de notification et source de données",
fig=sources_barchart,
lg=12,
xl=8,
)
)
duplicate_matrix = get_duplicate_matrix()
other_cards.append(
make_card(
title="Matrice de doublons entre sources de données",
subtitle="Ce graphique illustre les doublons de marchés publics entre sources, c'est-à-dire la proportion de marchés publiés par plus d'une source.",
fig=duplicate_matrix,
lg=12,
xl=8,
)
)
return dbc.Row(children=cards + geographic_maps + other_cards), filter_params
@callback(
Output("download-observatoire", "data"),
Input("btn-download-observatoire", "n_clicks"),
State("observatoire-filters", "data"),
State("observatoire-hidden-columns", "data"),
prevent_initial_call=True,
)
def download_observatoire(_n_clicks, filter_params, hidden_columns):
lff = prepare_dashboard_data(lff=df.lazy(), **(filter_params or {}))
if hidden_columns:
lff = lff.drop(hidden_columns)
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_observatoire_{date}.xlsx")
@callback(
Output("montant-modal", "is_open"),
Input({"type": "modal-trigger", "index": ALL}, "n_clicks"),
Input("montant-modal-close", "n_clicks"),
prevent_initial_call=True,
)
def toggle_montant_modal(n_triggers, _close):
return isinstance(ctx.triggered_id, dict) and any(n_triggers)
@callback(
Output("page_title", "children"),
Input("dashboard_acheteur_id", "value"),
Input("dashboard_titulaire_id", "value"),
prevent_initial_call=False,
)
def add_organization_name_in_title(acheteur_id, titulaire_id):
def lookup_nom(df_org, id_col, nom_col, org_id):
match = df_org.filter(pl.col(id_col) == org_id)
return match[nom_col].item(0) if match.height >= 1 else None
if acheteur_id and len(acheteur_id) == 14:
if nom := lookup_nom(df_acheteurs, "acheteur_id", "acheteur_nom", acheteur_id):
return [
name,
html.Small(nom, className="text-muted d-block fw-normal fs-5"),
]
elif titulaire_id and len(titulaire_id) == 14:
if nom := lookup_nom(
df_titulaires, "titulaire_id", "titulaire_nom", titulaire_id
):
return [
name,
html.Small(nom, className="text-muted d-block fw-normal fs-5"),
]
return name
@callback(
Output("observatoire-preview", "is_open"),
Input("btn-observatoire-preview", "n_clicks"),
State("observatoire-preview", "is_open"),
prevent_initial_call=True,
)
def toggle_observatoire_preview(n_clicks, is_open):
return not is_open
@callback(
Output("observatoire-preview-table", "data"),
Output("observatoire-preview-table", "columns"),
Output("observatoire-preview-table", "tooltip_header"),
Output("observatoire-preview-table", "data_timestamp"),
Output("nb_rows_observatoire", "children"),
Output("btn-download-observatoire", "disabled"),
Output("btn-download-observatoire", "children"),
Output("btn-download-observatoire", "title"),
Output("filter-cleanup-trigger-observatoire-preview", "data", allow_duplicate=True),
Input("observatoire-preview", "is_open"),
Input("observatoire-preview-table", "filter_query"),
Input("observatoire-preview-table", "page_current"),
Input("observatoire-preview-table", "page_size"),
Input("observatoire-preview-table", "sort_by"),
State("observatoire-preview-table", "data_timestamp"),
State("observatoire-filters", "data"),
prevent_initial_call=True,
)
def populate_preview_table(
is_open,
filter_query,
page_current,
page_size,
sort_by,
data_timestamp,
filter_params,
):
if not is_open:
return (no_update,) * 9
lff = prepare_dashboard_data(lff=df.lazy(), **(filter_params or {}))
return prepare_table_data(
lff,
data_timestamp,
filter_query,
page_current,
page_size,
sort_by,
"observatoire-preview",
)
@callback(
Output("observatoire-hidden-columns", "data", allow_duplicate=True),
Input("observatoire_preview_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("observatoire-preview-table", "hidden_columns"),
Input(
"observatoire-hidden-columns",
"data",
),
)
def store_hidden_columns(hidden_columns):
return hidden_columns
@callback(
Output("observatoire_preview_column_list", "selected_rows"),
Input("observatoire-preview-table", "hidden_columns"),
State(
"observatoire_preview_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("observatoire-preview-columns-modal", "is_open"),
Input("observatoire-preview-columns-open", "n_clicks"),
Input("observatoire-preview-columns-close", "n_clicks"),
State("observatoire-preview-columns-modal", "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
+66 -42
View File
@@ -1,5 +1,7 @@
from dash import Input, Output, callback, dash_table, dcc, html, register_page
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,
@@ -13,9 +15,9 @@ name = "Recherche"
register_page(
__name__,
path="/",
title=meta_content["title"],
title="Recherche de marchés publics | decp.info",
name=name,
description=meta_content["description"],
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,
)
@@ -25,33 +27,73 @@ layout = html.Div(
children=[
html.Div(
className="tagline",
children=html.P(
"Exploration et téléchargement des données des marchés publics"
),
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)"])],
# ),
html.Div(id="search_results", className="wrapper"),
dbc.Row(id="search_results"),
],
)
@callback(
Output("search_results", "children"),
Input("search", "value"),
Output("mention_tableau", "style"),
Input("search", "n_submit"),
Input("search-button", "n_clicks"),
State("search", "value"),
prevent_initial_call=True,
)
def update_search_results(query):
if len(query) >= 1:
content = []
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":
@@ -68,44 +110,26 @@ def update_search_results(query):
# Format output
columns, tooltip = setup_table_columns(results, hideable=False)
org_content = [
html.Div(
className=f"results_{org_type}",
col = (
dbc.Col(
children=[
html.H3(f"{org_type.title()}s : {count}"),
dash_table.DataTable(
DataTable(
dtid=f"results_{org_type}_datatable",
columns=columns,
data=results.to_dicts(),
page_size=10,
# style_table={"overflowX": "auto"},
markdown_options={"html": True},
cell_selectable=False,
style_cell_conditional=[
{
"if": {"column_id": "acheteur_nom"},
"maxWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"maxWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
sort_action="none",
filter_action="none",
),
],
md=6,
)
if count > 0
else html.P(f"Aucun {org_type} trouvé."),
]
content.extend(org_content)
else html.P(f"Aucun {org_type} trouvé.")
)
cols.append(col)
return content
else:
return html.P("")
style = {"textAlign": "center", "display": "none"}
return cols, style
return html.P(""), {"textAlign": "center"}
-55
View File
@@ -1,55 +0,0 @@
from dash import dcc, html, register_page
from src.figures import get_barchart_sources, get_map_count_marches
from src.utils import df, meta_content
name = "Statistiques"
register_page(
__name__,
path="/statistiques",
title=meta_content["title"],
name=name,
description=meta_content["description"],
image_url=meta_content["image_url"],
order=3,
)
layout = [
html.Div(
className="container",
children=[
html.H2(name),
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(df)),
dcc.Graph(
figure=get_barchart_sources(df, "dateNotification")
),
dcc.Graph(
figure=get_barchart_sources(
df, "datePublicationDonnees"
)
),
],
)
],
),
],
)
]
+426 -155
View File
@@ -1,114 +1,202 @@
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 Input, Output, State, callback, dash_table, dcc, html, register_page
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 (
add_links,
add_resource_link,
columns,
df,
filter_table_data,
format_number,
format_values,
get_default_hidden_columns,
invert_columns,
logger,
meta_content,
setup_table_columns,
prepare_table_data,
schema,
sort_table_data,
)
update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y")
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()
schema = df.collect_schema()
name = "Tableau"
register_page(
__name__,
path="/tableau",
title=meta_content["title"],
title="Tableau des marchés publics | decp.info",
name=name,
description=meta_content["description"],
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=dash_table.DataTable(
cell_selectable=False,
id="table",
children=DataTable(
dtid="tableau_datatable",
persisted_props=["filter_query", "sort_by"],
persistence_type="local",
persistence=True,
page_size=20,
page_current=0,
page_action="custom",
filter_action="custom",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
sort_action="custom",
sort_mode="multi",
sort_by=[],
row_deletable=False,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_nom"},
"minWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
data_timestamp=0,
markdown_options={"html": True},
tooltip_duration=8000,
tooltip_delay=350,
hidden_columns=get_default_hidden_columns(schema),
hidden_columns=[],
columns=[{"id": col, "name": col} for col in df.columns],
),
)
layout = [
html.Div(
html.Details(
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=[
html.Summary(
html.H3("Mode d'emploi", style={"text-decoration": "underline"}),
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.
##### Filtres
##### 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 est insensible à la casse (majuscules/minuscules) et retourne les valeurs qui contiennent
le texte recherché. Exemple : `rennes` retourne "RENNES METROPOLE".
- Champs numériques : vous pouvez soit taper un nombre pour trouver les valeurs égales, soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres.
- Champs date : vous pouvez également utiliser **>** ou **<**. Exemples : `< 2024-01-31` pour "avant le 31 janvier 2024",
`2024` pour "en 2024", `> 2022` pour "à partir de 2022"
- 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. Vos filtres sont remis à zéro quand vous rafraîchissez la page.
Vous pouvez filtrer plusieurs colonnes à la fois.
##### Tri
##### 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 : tri ascendant, tri descendant, pas de tri.
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
@@ -119,28 +207,35 @@ layout = [
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="instructions",
id="tableau_help",
is_open=False,
fullscreen="md-down",
scrollable=True,
size="lg",
),
id="header",
# 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.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(
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,
@@ -148,9 +243,36 @@ layout = [
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,
],
),
@@ -158,89 +280,39 @@ layout = [
@callback(
Output("table", "data"),
Output("table", "columns"),
Output("table", "tooltip_header"),
Output("table", "data_timestamp"),
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"),
Input("table", "page_current"),
Input("table", "page_size"),
Input("table", "filter_query"),
Input("table", "sort_by"),
State("table", "data_timestamp"),
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(page_current, page_size, filter_query, sort_by, data_timestamp):
if os.getenv("DEVELOPMENT").lower() == "true":
print(" + + + + + + + + + + + + + + + + + + ")
# Application des filtres
lff: pl.LazyFrame = df.lazy() # start from the original data
if filter_query:
lff = filter_table_data(lff, filter_query)
if len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
# Matérialisation des filtres
dff: pl.DataFrame = lff.collect()
height = dff.height
nb_rows = f"{format_number(height)} lignes ({format_number(dff.select('uid').unique().height)} marchés)"
# Pagination des données
start_row = page_current * page_size
# end_row = (page_current + 1) * page_size
dff = dff.slice(start_row, page_size)
# Tout devient string
dff = dff.cast(pl.String)
# Remplace les strings null par "", mais pas les numeric null
dff = dff.fill_null("")
# Ajout des liens vers l'annuaire des entreprises
dff = add_links(dff)
# Ajout des liens vers les fichiers Open Data
dff = add_resource_link(dff)
# Formatage des montants
dff = format_values(dff)
columns, tooltip = setup_table_columns(dff)
dicts = dff.to_dicts()
if height > 65000:
download_disabled = True
download_text = "Téléchargement désactivé au-delà de 65 000 lignes"
download_title = "Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul. Contactez-moi pour me présenter votre besoin en téléchargement afin que je puisse adapter la solution."
else:
download_disabled = False
download_text = "Télécharger au format Excel"
download_title = ""
return (
dicts,
columns,
tooltip,
data_timestamp + 1,
nb_rows,
download_disabled,
download_text,
download_title,
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("table", "filter_query"),
State("table", "sort_by"),
State("table", "hidden_columns"),
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):
@@ -251,9 +323,9 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
lff = lff.drop(hidden_columns)
if filter_query:
lff = filter_table_data(lff, filter_query)
lff = filter_table_data(lff, filter_query, "tab download")
if len(sort_by) > 0:
if sort_by and len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
def to_bytes(buffer):
@@ -261,3 +333,202 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
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 "", []
+372 -129
View File
@@ -1,55 +1,105 @@
import datetime
from typing import Any
import dash_bootstrap_components as dbc
import polars as pl
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
from dash import (
ClientsideFunction,
Input,
Output,
State,
callback,
clientside_callback,
dcc,
html,
register_page,
)
from src.callbacks import get_top_org_table
from src.figures import point_on_map
from src.figures import (
DataTable,
get_distance_histogram,
get_top_org_table,
make_column_picker,
point_on_map,
)
from src.utils import (
add_links_in_dict,
columns,
df,
df_titulaires,
filter_table_data,
format_number,
format_values,
get_annuaire_data,
get_button_properties,
get_default_hidden_columns,
get_departement_region,
meta_content,
setup_table_columns,
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=meta_content["title"],
title=get_title,
name="Titulaire",
description=meta_content["description"],
description="Consultez les marchés publics remportés par ce titulaire.",
image_url=meta_content["image_url"],
order=5,
)
# 21690123100011
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.Location(id="url", refresh="callback-nav"),
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(
className="container",
children=[
html.Div(
className="wrapper",
style={"marginBottom": "50px"},
children=[
dbc.Row(
className="mb-2",
children=[
dbc.Col(
html.H2(
className="org_title",
children=[
html.Span(id="titulaire_siret"),
" - ",
html.Span(id="titulaire_nom"),
],
),
html.Div(
className="org_year",
children=dcc.Dropdown(
width=8,
),
dbc.Col(
dcc.Dropdown(
id="titulaire_year",
options=["Toutes"]
options=["Toutes les années"]
+ [
str(year)
for year in range(
@@ -58,52 +108,140 @@ layout = [
],
placeholder="Année",
),
width=4,
),
html.Div(
],
),
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(
[
"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.P(
[
"Région : ",
html.Strong(id="titulaire_region"),
]
),
html.A(
id="titulaire_lien_annuaire",
children="Plus de détails sur l'Annuaire des entreprises",
target="_blank",
),
],
width=4,
),
html.Div(
className="org_stats",
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-titulaire-data",
id="btn-download-data-titulaire",
className="btn btn-primary",
),
dcc.Download(id="download-data-titulaire"),
],
width=4,
),
dbc.Col(
id="titulaire_map",
width=4,
),
dcc.Download(id="download-titulaire-data"),
],
),
html.Div(className="org_map", id="titulaire_map"),
dbc.Row(
children=[
dbc.Col(
html.Div(
className="org_top",
children=[
html.H3("Top acheteurs"),
html.Div(className="marches_table", id="top10_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"),
html.Div(id="titulaire_last_marches", children=""),
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,
],
),
],
),
]
@@ -117,16 +255,15 @@ layout = [
Output(component_id="titulaire_departement", component_property="children"),
Output(component_id="titulaire_region", component_property="children"),
Output(component_id="titulaire_lien_annuaire", component_property="href"),
Input(component_id="url", component_property="pathname"),
Input(component_id="titulaire_url", component_property="pathname"),
)
def update_titulaire_infos(url):
titulaire_siret = url.split("/")[-1]
if len(titulaire_siret) != 14:
titulaire_siret = (
f"Le SIRET renseigné doit faire 14 caractères ({titulaire_siret})"
)
data = get_annuaire_data(titulaire_siret)
data_etablissement = data["matching_etablissements"][0]
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"]
)
@@ -137,10 +274,23 @@ def update_titulaire_infos(url):
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,
data["nom_raison_sociale"],
data_etablissement["libelle_commune"],
raison_sociale,
libelle_commune,
titulaire_map,
departement,
nom_region,
@@ -156,123 +306,88 @@ def update_titulaire_infos(url):
Input(component_id="titulaire_data", component_property="data"),
)
def update_titulaire_stats(data):
dff = pl.DataFrame(data)
dff = pl.DataFrame(data, strict=False, infer_schema_length=5000)
if dff.height == 0:
dff = pl.DataFrame(schema=dff.collect_schema())
nb_marches = 0
nb_acheteurs = 0
else:
df_marches = dff.unique("uid")
nb_marches = format_number(df_marches.height)
# somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item()))
marches_remportes = [html.Strong(nb_marches), " marchés et accord-cadres remportés"]
# + ", pour un total de ", html.Strong(somme_marches + " €")]
del df_marches
nb_acheteurs = dff.unique("acheteur_id").height
nb_acheteurs = [
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",
]
del dff
return marches_remportes, nb_acheteurs
return texte_marches_remportes, texte_nb_acheteurs
@callback(
Output(component_id="titulaire_data", component_property="data"),
Input(component_id="url", component_property="pathname"),
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) -> list[dict]:
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")
)
lff = lff.select(
"id",
"uid",
"objet",
"dateNotification",
"acheteur_id",
"acheteur_nom",
"distance",
"montant",
"codeCPV",
"dureeMois",
)
if titulaire_year and titulaire_year != "Toutes":
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("")
data = lff.collect(engine="streaming").to_dicts()
return data
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(component_id="titulaire_last_marches", component_property="children"),
Input(component_id="titulaire_data", component_property="data"),
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_table(data) -> html.Div:
columns = [
"uid",
"objet",
"dateNotification",
"acheteur_nom",
"montant",
"codeCPV",
"dureeMois",
]
dff = pl.DataFrame(data)
dff = dff.cast(pl.String)
dff = dff.fill_null("")
dff = format_values(dff)
columns, tooltip = setup_table_columns(
dff, hideable=False, exclude=["acheteur_id", "id"]
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",
)
data = dff.to_dicts()
# Idéalement on utiliserait add_org_links(), mais le résultat attendu
# est différent de home.py (Tableau)
data = add_links_in_dict(data, "acheteur")
table = html.Div(
className="marches_table",
id="titulaire_datatable",
children=dash_table.DataTable(
data=data,
markdown_options={"html": True},
page_action="native",
filter_action="native",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
columns=columns,
tooltip_header=tooltip,
tooltip_duration=8000,
tooltip_delay=350,
cell_selectable=False,
page_size=10,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "300px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_nom"},
"maxWidth": "400px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
),
)
return table
@callback(
@@ -280,12 +395,12 @@ def get_last_marches_table(data) -> html.Div:
Input(component_id="titulaire_data", component_property="data"),
)
def get_top_acheteurs(data):
return get_top_org_table(data, "acheteur")
return get_top_org_table(data, "acheteur", ["titulaire_distance"])
@callback(
Output("download-titulaire-data", "data"),
Input("btn-download-titulaire-data", "n_clicks"),
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"),
@@ -293,7 +408,7 @@ def get_top_acheteurs(data):
)
def download_titulaire_data(
n_clicks,
data: [dict],
data: list[dict[str, Any]],
titulaire_nom: str,
annee: str,
):
@@ -301,8 +416,136 @@ def download_titulaire_data(
def to_bytes(buffer):
df_to_download.write_excel(
buffer, worksheet="DECP" if annee in ["Toutes", None] else annee
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,
]
+457 -62
View File
@@ -2,23 +2,28 @@ import json
import logging
import os
import uuid
from collections import OrderedDict
from datetime import datetime, timedelta
from time import localtime, sleep
import polars as pl
import polars.selectors as cs
from httpx import get, post
from polars import Schema
from dash import no_update
from httpx import HTTPError, get, post
from polars.exceptions import ComputeError
from unidecode import unidecode
logger = logging.getLogger("decp.info")
logging.getLogger("httpx").setLevel("WARNING")
logging.basicConfig(
format="%(asctime)s %(levelname)-8s %(message)s",
level=logging.INFO,
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):
@@ -28,15 +33,16 @@ def split_filter_part(filter_part):
["i<", "<"],
["i>", ">"],
["icontains", "contains"],
# [" ", "contains"]
]
print("filter part", filter_part)
logger.debug("filter part " + filter_part)
for operator_group in operators:
if operator_group[0] in filter_part:
name_part, value_part = filter_part.split(operator_group[0], 1)
name_part = name_part.strip()
value = value_part.strip()
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
@@ -53,10 +59,24 @@ def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
return dff
def add_links(dff: pl.DataFrame, target: str = "_blank"):
def add_links(dff: pl.DataFrame):
for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
if col in dff.columns:
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_(
@@ -64,32 +84,32 @@ def add_links(dff: pl.DataFrame, target: str = "_blank"):
pl.col("titulaire_typeIdentifiant") == "SIRET",
)
)
.then(
'<a href = "/titulaires/'
+ pl.col("titulaire_id")
+ f'" target="{target}">'
+ pl.col(col)
+ "</a>"
)
.then(detail_link)
.otherwise(pl.col(col))
.alias(col)
)
if col.startswith("acheteur_"):
dff = dff.with_columns(
(
detail_link = (
'<a href = "/acheteurs/'
+ pl.col("acheteur_id")
+ f'" target="{target}">'
+ '">'
+ pl.col(col)
+ "</a>"
).alias(col)
)
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")
+ f'" target="{target}">'
+ '">'
+ pl.col("uid")
+ "</a>"
).alias("uid")
@@ -145,6 +165,14 @@ def format_number(number) -> str:
return number
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
@@ -182,9 +210,11 @@ def format_values(dff: pl.DataFrame) -> pl.DataFrame:
if "montant" in dff.columns:
dff = dff.with_columns(pl.col("montant").pipe(format_montant).alias("montant"))
if "distance" in dff.columns:
if "titulaire_distance" in dff.columns:
dff = dff.with_columns(
pl.col("distance").pipe(format_distance).alias("distance")
pl.col("titulaire_distance")
.pipe(format_distance)
.alias("titulaire_distance")
)
return dff
@@ -192,8 +222,13 @@ def format_values(dff: pl.DataFrame) -> pl.DataFrame:
def get_annuaire_data(siret: str) -> dict:
url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}"
response = get(url)
return response.json()["results"][0]
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:
@@ -221,6 +256,15 @@ def get_decp_data() -> pl.DataFrame:
# 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)
@@ -240,12 +284,34 @@ def get_org_data(dff: pl.DataFrame, org_type: str) -> pl.DataFrame:
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]
@@ -256,18 +322,19 @@ def get_departement_region(code_postal):
return code_departement, nom_departement, nom_region
def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
debug = os.getenv("DEVELOPMENT", "False").lower() == "true"
schema = lff.collect_schema()
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])
if debug:
print("filter_value:", filter_value)
print("filter_value_type:", type(filter_value))
print("operator:", operator)
print("col_type:", col_type)
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())
@@ -296,6 +363,16 @@ def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
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)
)
@@ -318,12 +395,15 @@ def sort_table_data(lff: pl.LazyFrame, sort_by: list) -> pl.LazyFrame:
descending=[col["direction"] == "desc" for col in sort_by],
nulls_last=True,
)
print(sort_by)
logger.debug(sort_by)
return lff
def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tuple:
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:
@@ -331,43 +411,77 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup
continue
column_object = data_schema.get(column_id)
if column_object:
column_name = column_object.get("title", column_id)
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": "markdown",
"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["description"],
+ column_object.get("description", ""),
"type": "markdown",
}
return columns, tooltip
def get_default_hidden_columns(schema: Schema):
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 = []
if displayed_columns:
displayed_columns = displayed_columns.replace(" ", "").split(",")
for col in schema.names():
if col in displayed_columns:
continue
else:
hidden_columns.append(col)
return hidden_columns
raise ValueError("DISPLAYED_COLUMNS n'est pas configuré")
def get_data_schema() -> dict:
@@ -383,34 +497,23 @@ def get_data_schema() -> dict:
else:
raise Exception(f"Chemin vers le schéma invalide: {path}")
new_schema = {}
new_schema = OrderedDict()
for col in original_schema["fields"]:
new_schema[col["name"]] = col
new_schema["sourceDataset"] = {
"description": "Code de la source des données, avec un lien vers le fichier Open Data dont proviennent les données de ce marché public.",
"title": "Source des données",
"short_name": "Source",
}
return new_schema
def track_search(query):
if (
len(query) >= 4
and os.getenv("DEVELOPMENT").lower != "true"
and os.getenv("MATOMO_DOMAIN")
):
if os.getenv("DEVELOPMENT").lower() == "true":
url = "https://test.decp.info"
else:
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": "front_page_search",
"action_name": "search" if category == "home_page_search" else "filter",
"search_cat": category,
"rand": uuid.uuid4().hex,
"apiv": "1",
"h": localtime().tm_hour,
@@ -438,7 +541,7 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
return dff.select(pl.lit(False).alias("matches"))
# Enregistrement des recherche dans Matomo
track_search(query)
track_search(query, "home_page_search")
# Normalize query
normalized_query = unidecode(query.strip()).upper()
@@ -483,12 +586,11 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
dff = (
dff.with_columns(token_matches + [match_score])
.filter(pl.col("match_score") == len(tokens))
.sort("Marchés", descending=True)
.drop([f"token_{token}" for token in tokens])
)
# Format result
dff = add_links(dff, target="")
dff = add_links(dff)
dff = dff.with_columns(
pl.concat_str(
pl.col(f"{org_type}_departement_nom"),
@@ -499,14 +601,306 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
)
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,
dashboard_year,
dashboard_acheteur_id,
dashboard_acheteur_categorie,
dashboard_acheteur_departement_code,
dashboard_titulaire_id,
dashboard_titulaire_categorie,
dashboard_titulaire_departement_code,
dashboard_marche_type,
dashboard_marche_objet,
dashboard_marche_code_cpv,
dashboard_marche_considerations_sociales,
dashboard_marche_considerations_environnementales,
dashboard_marche_techniques,
dashboard_marche_innovant,
dashboard_marche_sous_traitance_declaree,
dashboard_montant_min=None,
dashboard_montant_max=None,
) -> pl.LazyFrame:
if dashboard_year:
lff = lff.filter(pl.col("dateNotification").dt.year() == int(dashboard_year))
else:
lff = lff.filter(
pl.col("dateNotification") > (datetime.now() - timedelta(days=365))
)
if dashboard_acheteur_id:
lff = lff.filter(pl.col("acheteur_id").str.contains(dashboard_acheteur_id))
else:
if dashboard_acheteur_categorie:
lff = lff.filter(pl.col("acheteur_categorie") == dashboard_acheteur_categorie)
if dashboard_acheteur_departement_code:
lff = lff.filter(
pl.col("acheteur_departement_code").is_in(dashboard_acheteur_departement_code)
)
if dashboard_titulaire_id:
lff = lff.filter(pl.col("titulaire_id").str.contains(dashboard_titulaire_id))
else:
if dashboard_titulaire_categorie:
lff = lff.filter(pl.col("titulaire_categorie") == dashboard_titulaire_categorie)
if dashboard_titulaire_departement_code:
lff = lff.filter(
pl.col("titulaire_departement_code").is_in(dashboard_titulaire_departement_code)
)
if dashboard_marche_type:
lff = lff.filter(pl.col("type") == dashboard_marche_type)
if dashboard_marche_objet:
lff = lff.filter(pl.col("objet").str.contains(f"(?i){dashboard_marche_objet}"))
if dashboard_marche_code_cpv:
lff = lff.filter(pl.col("codeCPV").str.starts_with(dashboard_marche_code_cpv))
if dashboard_marche_innovant and dashboard_marche_innovant != "all":
lff = lff.filter(pl.col("marcheInnovant") == dashboard_marche_innovant)
if dashboard_marche_sous_traitance_declaree and dashboard_marche_sous_traitance_declaree != "all":
lff = lff.filter(pl.col("sousTraitanceDeclaree") == dashboard_marche_sous_traitance_declaree)
if dashboard_marche_techniques:
lff = lff.filter(
pl.col("techniques")
.str.split(", ")
.list.set_intersection(dashboard_marche_techniques)
.list.len()
> 0
)
if dashboard_marche_considerations_sociales:
lff = lff.filter(
pl.col("considerationsSociales")
.str.split(", ")
.list.set_intersection(dashboard_marche_considerations_sociales)
.list.len()
> 0
)
if dashboard_marche_considerations_environnementales:
lff = lff.filter(
pl.col("considerationsEnvironnementales")
.str.split(", ")
.list.set_intersection(dashboard_marche_considerations_environnementales)
.list.len()
> 0
)
if dashboard_montant_min is not None:
lff = lff.filter(pl.col("montant") >= dashboard_montant_min)
if dashboard_montant_max is not None:
lff = lff.filter(pl.col("montant") <= dashboard_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"
)
@@ -519,3 +913,4 @@ meta_content = {
),
}
data_schema = get_data_schema()
columns = df.columns
View File
+64
View File
@@ -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
+369
View File
@@ -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(),
dashboard_year="2025",
dashboard_acheteur_id=None,
dashboard_acheteur_categorie=None,
dashboard_acheteur_departement_code=None,
dashboard_titulaire_id=None,
dashboard_titulaire_categorie=None,
dashboard_titulaire_departement_code=None,
dashboard_marche_type=None,
dashboard_marche_objet=None,
dashboard_marche_code_cpv=None,
dashboard_marche_considerations_sociales=None,
dashboard_marche_considerations_environnementales=None,
dashboard_marche_techniques=None,
dashboard_marche_innovant=None,
dashboard_marche_sous_traitance_declaree=None,
dashboard_montant_min=min_val,
dashboard_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)