Compare commits

...

220 Commits

Author SHA1 Message Date
Colin Maudry d8f5739168 Merge branch 'hotfix/2.2.1' 2025-11-15 16:48:46 +01:00
Colin Maudry dd07351bdd Changelog 2.2.1 2025-11-15 16:48:37 +01:00
Colin Maudry 4fb376d43d Open Sans partout 2025-11-14 23:03:39 +01:00
Colin Maudry 2335ae3d59 Contact => À propos 2025-11-14 18:29:32 +01:00
Colin Maudry 9e9770b1a0 Ajout d'une phrase d'intro et développement du placeholder search #58 2025-11-14 18:17:42 +01:00
Colin Maudry ce4570afad Suppression de l'annonce sur le contact 2025-11-14 18:16:06 +01:00
Colin Maudry 4c72c0e269 Suppression du workflow tag release 2025-11-13 14:59:11 +01:00
Colin Maudry 9a035ae431 Ignorer les - dans les recherches #58 2025-11-13 14:53:08 +01:00
Colin Maudry d58183beb5 Merge branch 'release/2.2.0' 2025-11-13 14:26:41 +01:00
Colin Maudry 18f8388df5 Changelog 2.2.0 2025-11-13 14:25:41 +01:00
Colin Maudry ef39be3346 Réduction du logging httpx 2025-11-13 14:23:08 +01:00
Colin Maudry 783fb88d0c Line height 2025-11-13 14:13:36 +01:00
Colin Maudry c19076ee85 Hauteur de lignes tableau 2025-11-12 22:18:46 +01:00
Colin Maudry c44ef311c3 Suppression du temps de latence entre les recherches #58 2025-11-12 22:09:15 +01:00
Colin Maudry 63622d83f5 Suivi des recherches #58 2025-11-12 16:08:38 +01:00
Colin Maudry 5bfce2a1d9 Merge branch 'main' into dev 2025-11-12 15:07:36 +01:00
Colin Maudry a3f8bd0981 Merge branch 'feature/58_search' into dev 2025-11-12 14:56:37 +01:00
Colin Maudry bb6c0dec26 Changelog #58 2025-11-12 14:55:38 +01:00
Colin Maudry 459ca6a846 Réarrangements résultats #58 2025-11-12 14:54:12 +01:00
Colin Maudry 8637915eb2 Cleanup HTML 2025-11-11 12:50:58 +01:00
Colin Maudry e187cf33aa Recherche fonctinonelle acheteurs et titulaires #58 2025-11-11 12:42:01 +01:00
Colin Maudry 2f3ce6a467 Amélioration du message d'annonce 2025-11-11 09:04:11 +01:00
Colin Maudry 1310caef4d Merge tag 'v2.1.7' into dev
- Remplacement du formulaire de contact par une adresse email
2025-11-11 08:42:28 +01:00
Colin Maudry efd9bd4238 Merge branch 'hotfix/2.1.7' 2025-11-11 08:39:58 +01:00
Colin Maudry 39844071ad Changelog 2.1.7 2025-11-11 08:39:41 +01:00
Colin Maudry 5c64177c6a Remplacement du formulaire de contact par email 2025-11-11 08:37:47 +01:00
Colin Maudry 1e85dbe562 Champ de recherche, récup de données dédiée #58 2025-11-11 07:47:00 +01:00
Colin Maudry 6835f328b6 Ajout de la distance dans les tableaux top #55 2025-11-10 15:30:47 +01:00
Colin Maudry a3eb48bacb Format values inclut le formatage des distances 2025-11-10 15:29:44 +01:00
Colin Maudry 1848c881ac Merge branch 'feature/55_top10' into dev 2025-11-10 13:33:42 +01:00
Colin Maudry bbb04a4487 Changelog #55 2025-11-10 13:33:37 +01:00
Colin Maudry e9faf80609 Top 10 acheteurs sur les pages titulaire #55 2025-11-10 13:30:50 +01:00
Colin Maudry f6a38a29bd Top 10 titulaires sur les pages acheteur #55 2025-11-10 13:01:17 +01:00
Colin Maudry c6e096e42a Merge branch 'feature/54_colonnes_defaut' into dev 2025-11-07 08:19:12 +01:00
Colin Maudry 4c6f63f723 Changelog #54 2025-11-07 08:15:18 +01:00
Colin Maudry f3c8fd4481 Configuration des colonnes affichées par défaut #54 2025-11-07 08:13:35 +01:00
Colin Maudry 5ca52fadd7 Merge tag 'v2.1.6' into dev
- Stabilisation de la vue marché
2025-10-15 19:44:55 +02:00
Colin Maudry ee91e7a91f Merge branch 'hotfix/2.1.6' 2025-10-15 19:44:09 +02:00
Colin Maudry 392f862baa Changelog 2.1.6 2025-10-15 19:44:02 +02:00
Colin Maudry 199476ec30 Stabilisation affichage fiche marché #40 2025-10-15 19:42:53 +02:00
Colin Maudry c98a36f759 Fix fiche marche 2025-10-14 12:54:47 +02:00
Colin Maudry 65e0f4c5f0 Gestion des montants inexistants 2025-10-10 18:25:08 +02:00
Colin Maudry bc7652f336 Merge branch 'main' into dev 2025-10-10 17:48:47 +02:00
Colin Maudry 188383a882 Changelog 2.1.5 2025-10-10 17:47:18 +02:00
Colin Maudry 7f3a0bcb21 Renommage de la Github action d'auto release 2025-10-10 17:45:15 +02:00
Colin Maudry 3270abb7f6 Refactor du filtre pour être plus résilient (et fonctionner sur les montants) 2025-10-10 17:44:51 +02:00
Colin Maudry 2eb984a95f Ne plus afficher de nulls dans les tableaux de données (tableau, titulaire, acheteur) 2025-10-10 17:44:16 +02:00
Colin Maudry 776126a481 Merge tag 'v2.1.4' into dev
- possibilité de filtrer sur le champ "Source"
- création automatique d'une release Github quand je push un tag
2025-10-08 09:34:30 +02:00
Colin Maudry 59abf35a12 Merge branch 'hotfix/2.1.4' 2025-10-08 09:34:00 +02:00
Colin Maudry a40eb64245 Changelog 2.1.4 2025-10-08 09:33:51 +02:00
Colin Maudry acae1517cb Nouvelle tentative auto-release 2025-10-08 09:31:41 +02:00
Colin Maudry 9a9decde6e Possible de filtrer sur le champ 'Source' 2025-10-08 09:26:44 +02:00
Colin Maudry a1ae962c73 Changelog 2.1.3 2025-10-04 19:15:56 +02:00
Colin Maudry 0404aa0973 Adaptation au format TableSchema 2025-10-04 19:13:51 +02:00
Colin Maudry effe01dedf Tentative d'auto release à partir d'un tag 2025-10-04 19:13:25 +02:00
Colin Maudry aaa70039d2 Merge branch 'hotfix/2.1.2' 2025-10-03 16:45:35 +02:00
Colin Maudry 7dc5f09545 Changelog 2.1.2 2025-10-03 16:45:29 +02:00
Colin Maudry 91e7f373ec Coquille 2025-10-03 16:42:44 +02:00
Colin Maudry c7768ea8f1 Global lf => global df pour être moins sensible au update de fichier source 2025-10-03 16:29:58 +02:00
Colin Maudry 816d0de324 Merge tag 'v2.1.1' into dev
- 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
2025-10-01 13:22:42 +02:00
Colin Maudry c2fab377d5 Merge branch 'hotfix/2.1.1' 2025-10-01 13:22:07 +02:00
Colin Maudry d7cdf38cda Changelog 2.1.1 2025-10-01 13:22:02 +02:00
Colin Maudry 85f0084b7b Exhaustivité et qualité des données close #43 2025-10-01 13:19:56 +02:00
Colin Maudry 712dc5a7f8 Rétrécissement de la carte 2025-10-01 13:18:58 +02:00
Colin Maudry 84b94fd882 Affiche du nombre de marchés 2025-10-01 11:57:55 +02:00
Colin Maudry 90e9bc3c8c Merge tag 'v2.1.0' into dev
- 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 (😁)
2025-09-30 17:16:45 +02:00
Colin Maudry 442f1ded27 Merge branch 'release/2.1.0' 2025-09-30 17:14:16 +02:00
Colin Maudry e4dcc19e8b template.env 2025-09-30 17:13:42 +02:00
Colin Maudry 0b828c3d67 Numéro de version, description toml 2025-09-30 17:12:41 +02:00
Colin Maudry bc930bf6c9 Màj instruction #40 2025-09-30 15:09:21 +02:00
Colin Maudry 070e8c98b9 Merge branch 'feature/40_vue_marché' into dev 2025-09-30 15:02:04 +02:00
Colin Maudry 7c157d9d71 Vue marché simple #40 2025-09-30 15:01:54 +02:00
Colin Maudry 79d11b326e Formulaire de contact avec rate limiting simple #48 2025-09-30 11:35:18 +02:00
Colin Maudry 51c023a803 Merge branch 'feature/48_contact_form' into dev 2025-09-29 20:07:19 +02:00
Colin Maudry 819f2e315f Affichage du numéro de version et lien vers changelog 2025-09-29 20:06:04 +02:00
Colin Maudry 928f75f504 Formulaire de contact avec rate limiting simple #48 2025-09-29 19:32:15 +02:00
Colin Maudry 1d21cbb69d Début de configuration de colonnes partagée 2025-09-29 17:36:12 +02:00
Colin Maudry ee2bae190d Récupération du schéma #33 2025-09-29 17:08:45 +02:00
Colin Maudry ad6005259e Les filtres <> marchent aussi avec les strings #42 2025-09-29 17:07:35 +02:00
Colin Maudry f01a991f4f Améliorations de style 2025-09-29 17:06:16 +02:00
Colin Maudry a44e1a37cb Noms de colonnes friendly et définition de colonne (tooltip) #33 2025-09-29 17:05:37 +02:00
Colin Maudry 502f652791 Chemin vers le schéma de données configurable 2025-09-29 17:03:44 +02:00
Colin Maudry fdb38b3b9b Formatage des montants #45 2025-09-28 23:11:09 +02:00
Colin Maudry 4394f38e35 Fix link titulaire 2025-09-28 22:19:07 +02:00
Colin Maudry ce9d23100d Correction des balises meta #39 2025-09-28 22:08:03 +02:00
Colin Maudry 70c5b91e6d Configuration des filtres (case insensitive, placeholder text #28 #35 2025-09-28 21:27:40 +02:00
Colin Maudry 4895bfc5ac Ajout des filtres natifs #28 #35 2025-09-28 21:05:23 +02:00
Colin Maudry d9db156626 Liens entre pages acheteur et titulaire, fournisseur => titulaire #28 #35 2025-09-28 21:01:22 +02:00
Colin Maudry d00efcd500 Script de déploiement intégré au yaml 2025-09-28 20:40:35 +02:00
Colin Maudry 41df3f6d8e Merge branch 'feature/39_meta' into dev 2025-09-28 20:21:36 +02:00
Colin Maudry 0db814710b Changelog 2025-09-28 20:21:26 +02:00
Colin Maudry c1955ac807 Ajout des balises meta og et twitter (à tester) #39 2025-09-28 20:13:38 +02:00
Colin Maudry 4081b369d9 Recherche possible dans les dates (comme des strings) #37 2025-09-28 19:16:50 +02:00
Colin Maudry 8a63f72bee Vue fournisseur minimaliste #35 2025-09-28 18:40:00 +02:00
Colin Maudry cc05ac0d99 departements.json 2025-09-28 18:02:31 +02:00
Colin Maudry 8d236ab8dc Module httpx 2025-09-28 17:57:32 +02:00
Colin Maudry 0e20b0012f Début de vue fournisseur #35 2025-09-28 00:34:47 +02:00
Colin Maudry 5122bb7f3a Merge branch 'feature/28_vue_acheteur' into dev 2025-09-28 00:33:33 +02:00
Colin Maudry 4b8565dcf2 Chanlog 2025-09-28 00:33:21 +02:00
Colin Maudry 98b8699afb Rétablissement de la carte des marchés par dpt 2025-09-28 00:30:52 +02:00
Colin Maudry 1c5868d75f Utilisation de State plutôt qu'Input pour ne pas trigger le download #28 2025-09-27 23:58:23 +02:00
Colin Maudry bfa181cefd Suppression de la variable globale df_filtered, et adaptations 2025-09-27 23:57:51 +02:00
Colin Maudry 944f207d9b Téléchargement des données acheteur filtrées #28 2025-09-27 23:15:20 +02:00
Colin Maudry 8f335a52ea Notifié => attribué #28 2025-09-27 21:50:33 +02:00
Colin Maudry e88992a470 Le lien acheteur mène maintenant à la page acheteur au lieu de l'annuaire #28 2025-09-27 21:39:40 +02:00
Colin Maudry 9e5673c28f Ajout des liens dans le tableau le plus tard possible 2025-09-27 21:14:11 +02:00
Colin Maudry 2fe92d8a14 Plus d'informations, stats #28 2025-09-27 20:49:15 +02:00
Colin Maudry b88d1c9f93 Pagination derniers marchés #28 2025-09-27 18:16:53 +02:00
Colin Maudry 59321fc80a Commune acheteur, carte localisation #28 2025-09-27 14:29:48 +02:00
Colin Maudry df04286a4a Derniers marché notifiés #28 2025-09-27 11:48:57 +02:00
Colin Maudry 2fc319d1bc Merge branch 'main' into feature/28_vue_acheteur 2025-09-27 10:08:30 +02:00
Colin Maudry 29428a8706 Merge tag 'v2.0.1' into dev
- Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/decp.info/issues/38)
- Amélioration du script de déploiement (deploy.sh)
- Meilleures instructions d'installation et lancement
- Coquilles 🐚
2025-09-24 10:24:09 +02:00
Colin Maudry dbc80ad94e Merge branch 'hotfix/2.0.1' 2025-09-24 10:22:59 +02:00
Colin Maudry ec877669d4 Détails sur le déploiement dans README 2025-09-24 10:21:40 +02:00
Colin Maudry a41b70d5af Script pour le déploiement 2025-09-24 09:51:03 +02:00
Colin Maudry e41dbe772c Merged 2025-09-23 17:42:09 +02:00
Colin Maudry 5a3770027f Merge branch 'hotfix/2.0.1' 2025-09-23 17:40:14 +02:00
Colin Maudry 5c90b5f350 bump version number 2025-09-23 17:40:05 +02:00
Colin Maudry dd229a212f Bloquage du bouton de téléchargement si trop de lignes #38 2025-09-23 17:35:31 +02:00
Colin Maudry 8bcf57ce83 Merge branch 'hotfix/2.0.1' into dev 2025-09-23 13:14:29 +02:00
Colin Maudry 0fea49109a Bloquage du bouton de téléchargement si trop de lignes #38 2025-09-23 13:10:42 +02:00
Colin Maudry 8337f7cbf1 Merge branch 'hotfix/2.0.1' into dev 2025-09-23 12:30:03 +02:00
Colin Maudry 159d409237 Bloquage du bouton de téléchargement si trop de lignes #38 2025-09-23 12:29:41 +02:00
Colin Maudry 1586f9b7e4 Merge tag 'v2.0.1' into dev
Typos et instructions d'installation
2025-09-23 10:29:09 +02:00
Colin Maudry 46dfc23d42 Merge branch 'hotfix/2.0.1' 2025-09-23 10:28:56 +02:00
Colin Maudry 8c3642e5d6 Typo 2025-09-23 10:28:31 +02:00
Colin Maudry 0c99f037b5 Merge tag 'v2.0' into dev
- détails des sources de données
- section "À propos" plus développée
- correction de bugs dans les filtres de la data table
2025-09-23 09:11:11 +02:00
Colin Maudry 929b836cb0 Merge branch 'release/2.0' 2025-09-23 09:10:21 +02:00
Colin Maudry 73a10e20a6 README 2025-09-23 09:10:01 +02:00
Colin Maudry 48da8cde5c Comment out le formulaire pour l'instant, script ne s'execute pas 2025-09-23 08:57:49 +02:00
Colin Maudry 36ea24278f Suivi d'audience 2025-09-23 08:49:08 +02:00
Colin Maudry 000e4e89db Liens vers les données et le blog 2025-09-23 08:13:46 +02:00
Colin Maudry 3396986b2d Explication sur les sources de données et l'exhaustivité 2025-09-21 12:16:06 +02:00
Colin Maudry f3305d42cd acheteur_nom plus étroit, comment contribuer 2025-09-21 11:43:55 +02:00
Colin Maudry f56f5bfc94 Ajout des détails sur les sources de données #32 2025-09-21 11:43:21 +02:00
Colin Maudry 5a9b585b44 Ajout des détails sur les sources de données #32 2025-09-21 11:43:07 +02:00
Colin Maudry 89a419a8d2 Ajout des détails sur les sources de données #32 2025-09-21 11:42:29 +02:00
Colin Maudry 90af1f6eb4 GPL v3 2025-09-21 11:13:48 +02:00
Colin Maudry e9db143f45 Coquille robots #31 2025-09-20 08:25:51 +02:00
Colin Maudry 6a5b4aa2aa Text compression ok (zstd) #31 2025-09-20 08:23:05 +02:00
Colin Maudry 56b7a8a0a1 robots.txt #31 2025-09-20 08:13:45 +02:00
Colin Maudry 5bd519f9e5 app.compress = True #31 2025-09-20 07:59:47 +02:00
Colin Maudry 5ca5fc300f Adaptation du graphique aux nouvelles sources #32 2025-09-19 09:30:00 +02:00
Colin Maudry b93b290f97 Lazyframe dès le départ 2025-09-19 08:26:03 +02:00
Colin Maudry e8eba9ba66 Suppression des champs siren, noms des champs source 2025-09-19 08:24:20 +02:00
Colin Maudry 41d3ca7539 WIP #28 2025-09-19 08:20:15 +02:00
Colin Maudry 636e0b059d Récupération des données acheteur depuis annuaire #28 2025-08-21 11:56:39 +02:00
Colin Maudry 87d657abf8 Merge branch 'feature/17_filtres_num' into dev 2025-08-18 12:28:13 +02:00
Colin Maudry 5a89f70d4c Amélioration de l'affichage et du téléchargement des champs source et sourceOpenData 2025-08-18 12:27:53 +02:00
Colin Maudry 6e37e59c4c Gestion des filtres numériques < et > #17 2025-08-18 12:26:26 +02:00
Colin Maudry 3f931313a6 Rajout du champ typeGroupementOperateurs 2025-08-18 12:24:08 +02:00
Colin Maudry 815d25519f Suppression de l'icône de sensibilité à la casse 2025-08-10 19:15:19 +02:00
Colin Maudry cd45c364e6 Masquage des colonnes et répercution dans le téléchargement closes #13 2025-08-09 11:38:10 +02:00
Colin Maudry b423c01068 dash == 3.2.0 2025-08-09 11:34:32 +02:00
Colin Maudry 0729ebb23e Tri par colonnes multiples closes #23 2025-08-09 09:11:03 +02:00
Colin Maudry c682f16c40 Lien vers liste de diffusion #25 2025-08-06 15:56:34 +02:00
Colin Maudry 94f9192760 Affichage de source inconnue si source null #26 2025-08-06 12:03:19 +02:00
Colin Maudry f490e099ea Lien annuaire sans icône 2025-08-06 11:35:48 +02:00
Colin Maudry 8a9f135662 Précisions sur les données absentes 2025-07-05 18:50:21 +02:00
Colin Maudry b12a5b72c2 ID de loading différents 2025-07-05 18:42:30 +02:00
Colin Maudry c2ab1f49de Barre de chargement pour les figures statistiques 2025-07-05 18:30:34 +02:00
Colin Maudry 81526ae2b4 Ajout de figures.py #24 2025-07-05 18:21:37 +02:00
Colin Maudry e41351dfc1 Ajout du nombre de marchés notifiés par mois de publication et par source #24 2025-07-05 18:16:48 +02:00
Colin Maudry b854d77d2f Ajout du nombre de marchés notifiés par mois de notif et par source #24 2025-07-05 18:10:53 +02:00
Colin Maudry 3db5b804e4 Déplacement des figures vers un fichier dédié #24 2025-07-05 17:38:47 +02:00
Colin Maudry 89b0666e70 Changement de couleurs 2025-07-05 17:25:32 +02:00
Colin Maudry 95bc323d63 Venez discuter sur teampopendata.org 2025-07-02 00:28:17 +02:00
Colin Maudry 8376295464 Coquille 2025-06-26 12:52:10 +02:00
Colin Maudry 1e194f52a6 Plus d'infos dans A propos 2025-06-25 12:14:18 +02:00
Colin Maudry ff1402d427 Correction hauteur table-menu (était incliquable) 2025-06-24 16:06:04 +02:00
Colin Maudry 1ab5c639f0 lf => lff pour éviter un conflit avec le lf global 2025-06-24 15:02:53 +02:00
Colin Maudry 637c35522f Print aussi le col_type dans le filtre 2025-06-24 15:02:01 +02:00
Colin Maudry 83744cea65 Génère le schéma après les adaptations sur le df 2025-06-24 15:01:37 +02:00
Colin Maudry 23932c8dd3 Que les données actuelles 2025-06-24 15:00:25 +02:00
Colin Maudry 8e216124f1 Que les données actuelles 2025-06-24 15:00:07 +02:00
Colin Maudry b0e440595a Suppression des départements DOM/TOM pour ne pas perturber l'échelle 2025-06-16 23:02:37 +02:00
Colin Maudry 88f37c199a Fixed import de lf dans stats 2025-06-16 22:33:02 +02:00
Colin Maudry 68b3072aa3 Déplacement d'opérations récurrentes hors du callback 2025-06-16 22:15:51 +02:00
Colin Maudry 9f74aaea69 Date dernière màj 2025-06-16 19:22:00 +02:00
Colin Maudry 89f95904b8 Changements cosmétiques 2025-06-16 19:08:30 +02:00
Colin Maudry 8e7048490e Tri des marchés par datePublicationDonnées 2025-06-16 18:26:04 +02:00
Colin Maudry 59fb9e7b3a Page stats avec nb de marchés par département close #21 2025-06-13 13:18:51 +02:00
Colin Maudry 01e714dba5 Page stats avec nb de marchés par département close #21 2025-06-13 13:17:40 +02:00
Colin Maudry 859506c4aa Supprime page Stats, Utilisation => Mode d'emploi 2025-06-13 11:30:10 +02:00
Colin Maudry 4c6f38f41e Affichage du nombre de lignes close #19 2025-06-13 11:24:57 +02:00
Colin Maudry 1171708d9e Le téléchargement Excel inclut toutes les données filtrées close #20 2025-06-13 10:54:42 +02:00
Colin Maudry c66bf97572 Remplacement des null par '' close #18 2025-06-10 10:01:11 +02:00
Colin Maudry 804f6e7883 Filtres des champs numériques close #17 2025-06-10 08:29:11 +02:00
Colin Maudry 12d01bb3e0 Petits changements cosmétiques 2025-06-09 15:24:21 +02:00
Colin Maudry 9d37e0c59b lf.remove => lf.drop #15 2025-06-03 17:15:33 +02:00
Colin Maudry 5cd540a8d0 nom du workflow github 2025-06-03 16:56:49 +02:00
Colin Maudry 1717c118ab nom du workflow github 2025-06-03 16:54:12 +02:00
Colin Maudry 87f336a457 Suppression de colonnes + renommage df=>lf #15 2025-06-03 16:44:37 +02:00
Colin Maudry 0e9597de54 ruff check --fix . 2025-06-03 14:44:12 +02:00
Colin Maudry 972d7f55b2 Suppresion du filtre de fichier pour ruff 2025-06-03 14:43:20 +02:00
Colin Maudry 17df388035 ruff check --select I --fix . 2025-06-03 14:40:41 +02:00
Colin Maudry 21f0be3265 Ruff formatting 2025-06-03 14:39:22 +02:00
Colin Maudry c8b2cc3683 Merge branch 'dev' 2025-06-03 14:22:14 +02:00
Colin Maudry ad19744a47 Lecture du parquet comme simple df #9 2025-06-03 13:55:04 +02:00
Colin Maudry 59fe1df1e7 Merged dev 2025-06-02 20:35:47 +02:00
Colin Maudry 313a7ba230 A propos du projet 2025-06-02 20:25:45 +02:00
Colin Maudry 5c9fd6b4ef <script> Matomo refermée 2025-05-31 16:42:56 +02:00
Colin Maudry 74b122e75c <script> Matomo refermée 2025-05-31 16:42:00 +02:00
Colin Maudry a9b37c1907 Change la manière de détécter l'évènement GA 2025-05-31 16:30:30 +02:00
Colin Maudry 8ad69b4bae Ajout de liens vers l'annuaire des entreprises 2025-05-31 16:26:21 +02:00
Colin Maudry 10ec2c6649 Ajout de </script> manquant 2025-05-31 15:40:28 +02:00
Colin Maudry fadb4b1930 Ajout de la possibilité de déployer main d'un clic 2025-05-31 15:30:29 +02:00
Colin Maudry e595128c13 use appleboy action 2025-05-31 00:55:34 +02:00
Colin Maudry f9b46eea36 one liner ssh deploy 2025-05-31 00:41:11 +02:00
Colin Maudry 5ed704a497 set port for ssh 2025-05-31 00:14:10 +02:00
Colin Maudry 64001959c3 set environment 2025-05-31 00:11:18 +02:00
Colin Maudry ec8b109258 test port 2025-05-31 00:07:41 +02:00
Colin Maudry cd25673a5f variable name 2025-05-31 00:01:11 +02:00
Colin Maudry 459984bcbf folder name 2025-05-30 23:57:21 +02:00
Colin Maudry a2f9b2dca0 variable name 2025-05-30 23:53:40 +02:00
Colin Maudry cd208ee57b deploy.yaml 2025-05-30 23:46:22 +02:00
Colin Maudry 5ec550373d Merge branch 'dash' 2025-05-30 21:38:59 +02:00
Colin Maudry d1da7c321c Texte d'instructions 2025-05-30 21:38:53 +02:00
Colin Maudry e4a670948d Ajout du code de suivi Matomo 2025-05-30 21:38:14 +02:00
Colin Maudry 2034577eb2 Update FUNDING.yml 2024-05-27 22:03:16 +02:00
26 changed files with 3063 additions and 203 deletions
+51
View File
@@ -0,0 +1,51 @@
name: Déploiement
on:
workflow_dispatch:
pull_request:
types:
- closed
push:
branches:
- dev
jobs:
deploy:
# Trigger deploy workflow if
# ...I clicked on "Run workflow" in Github actions, thus deploy main to production env (main)
# or
# ...I merged a PR or pushed on the dev branch, thus deploy to the test env (dev)
if: |
(github.event_name == 'workflow_dispatch' && github.ref_name == 'main') ||
((github.event_name == 'pull_request' || github.event_name == 'push') && github.ref_name == 'dev')
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up SSH key
run: |
env
mkdir -p ~/.ssh
echo "${{ secrets.ARTIFACT_SSH_KEY }}" > /home/runner/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -p ${{ secrets.ARTIFACT_PORT }} ${{ secrets.ARTIFACT_HOST }} >> ~/.ssh/known_hosts
sudo apt-get install sshpass python3 python3-pip
- name: Deploy to server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.ARTIFACT_HOST }}
username: ${{ secrets.USER }}
port: ${{ secrets.ARTIFACT_PORT }}
key: ${{ secrets.ARTIFACT_SSH_KEY }}
passphrase: ${{ secrets.SSH_PSWD }}
command_timeout: 5m
script: |
systemctl stop ${{ vars.APP_NAME }}
cd /var/www/${{ vars.APP_NAME }}
git pull
source .venv/bin/activate
pip install .
deactivate
chown -R ${{ vars.APP_NAME }}:www-data *
systemctl start ${{ vars.APP_NAME }}
+8 -11
View File
@@ -2,23 +2,20 @@ repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v5.0.0
hooks: hooks:
- id: check-ast
- id: check-case-conflict - id: check-case-conflict
- id: check-yaml - id: check-yaml
- id: end-of-file-fixer - id: end-of-file-fixer
- id: trailing-whitespace - id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 23.3.0
hooks:
- id: black
args: [ --config=pyproject.toml ]
- repo: https://github.com/psf/black
rev: 23.3.0
hooks:
- id: black-jupyter
args: [ --config=pyproject.toml ]
- repo: https://github.com/pre-commit/mirrors-prettier - repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.5.1 rev: v2.5.1
hooks: hooks:
- id: prettier - id: prettier
files: \.(js|css|html|json|md)$ files: \.(js|css|html|json|md)$
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.12
hooks:
- id: ruff
args: [ "--fix" ]
- id: ruff
args: [ "check", "--select", "I", "--fix" ]
- id: ruff-format
+19
View File
@@ -1,3 +1,22 @@
DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432 DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432
PORT=8050 PORT=8050
DEVELOPMENT=True DEVELOPMENT=True
SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234"
# Chemin vers le schéma de données
DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a
# Colonnes masquées par défaut
DISPLAYED_COLUMNS="uid, acheteur_id, acheteur_nom, montant, objet, titulaire_nom, titulaire_id, dateNotification, dureeMois, acheteur_departement_code, sourceDataset"
# Formulaire de contact
SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP
LOGIN_PASSWORD="" # mot de passe du serveur
LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP
FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From)
TO_EMAIL="to@example.com" # adresse de destination des emails (To)
# Matomo
MATOMO_ID_SITE=
MATOMO_BASE_URL=
MATOMO_TOKEN=
+16
View File
@@ -0,0 +1,16 @@
decp.info is a web application that enable analysis and download of
French public procurement data.
Copyright (C) 2025 Colin Maudry
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
+90 -3
View File
@@ -1,5 +1,7 @@
# decp.info # decp.info
> v2.1.7
Outil d'exploration et de téléchargement des données essentielles de la commande publique. Outil d'exploration et de téléchargement des données essentielles de la commande publique.
=> [decp.info](https://decp.info) => [decp.info](https://decp.info)
@@ -10,16 +12,101 @@ Outil d'exploration et de téléchargement des données essentielles de la comma
python -m venv .venv python -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install . pip install .
# Copie et personnalisation du .env
cp template.env .env
nano .env
# Pour la production
gunicorn app:server gunicorn app:server
# Pour avoir le debuggage et le hot reload
python run.py
``` ```
## Dépôts de code connexes
## Déploiement
- **Production** (branche `main`, [decp.info](https://decp.info)) : déploiement manuel via un déclenchement de la Github Action [Déploiement](https://github.com/ColinMaudry/decp.info/actions/workflows/deploy.yaml)
- **Test** (branche `dev`, [test.decp.info](https://test.decp.info)) : déploiement automatique à chaque push sur la branche `dev`, via la même Github Action.
Ne pas oublier de mettre à jour les fichier .env.
## Liens connexes
- [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données) - [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données)
- [decp-table-schema](https://github.com/ColinMaudry/decp-table-schema) (schéma de données tabulaire) - [colin.maudry.com](https://colin.maudry.com) (blog)
## Notes de version ## Notes de version
### 2.0.0-alpha ##### 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 - Data table fonctionnelle
File diff suppressed because one or more lines are too long
+398
View File
@@ -0,0 +1,398 @@
{
"01": {
"departement": "Ain",
"region": "Auvergne-Rhône-Alpes"
},
"02": {
"departement": "Aisne",
"region": "Hauts-de-France"
},
"03": {
"departement": "Allier",
"region": "Auvergne-Rhône-Alpes"
},
"04": {
"departement": "Alpes-de-Haute-Provence",
"region": "Provence-Alpes-Côte d'Azur"
},
"05": {
"departement": "Hautes-Alpes",
"region": "Provence-Alpes-Côte d'Azur"
},
"06": {
"departement": "Alpes-Maritimes",
"region": "Provence-Alpes-Côte d'Azur"
},
"07": {
"departement": "Ardèche",
"region": "Auvergne-Rhône-Alpes"
},
"08": {
"departement": "Ardennes",
"region": "Grand Est"
},
"09": {
"departement": "Ariège",
"region": "Occitanie"
},
"10": {
"departement": "Aube",
"region": "Grand Est"
},
"11": {
"departement": "Aude",
"region": "Occitanie"
},
"12": {
"departement": "Aveyron",
"region": "Occitanie"
},
"13": {
"departement": "Bouches-du-Rhône",
"region": "Provence-Alpes-Côte d'Azur"
},
"14": {
"departement": "Calvados",
"region": "Normandie"
},
"15": {
"departement": "Cantal",
"region": "Auvergne-Rhône-Alpes"
},
"16": {
"departement": "Charente",
"region": "Nouvelle-Aquitaine"
},
"17": {
"departement": "Charente-Maritime",
"region": "Nouvelle-Aquitaine"
},
"18": {
"departement": "Cher",
"region": "Centre-Val de Loire"
},
"19": {
"departement": "Corrèze",
"region": "Nouvelle-Aquitaine"
},
"21": {
"departement": "Côte-d'Or",
"region": "Bourgogne-Franche-Comté"
},
"22": {
"departement": "Côtes-d'Armor",
"region": "Bretagne"
},
"23": {
"departement": "Creuse",
"region": "Nouvelle-Aquitaine"
},
"24": {
"departement": "Dordogne",
"region": "Nouvelle-Aquitaine"
},
"25": {
"departement": "Doubs",
"region": "Bourgogne-Franche-Comté"
},
"26": {
"departement": "Drôme",
"region": "Auvergne-Rhône-Alpes"
},
"27": {
"departement": "Eure",
"region": "Normandie"
},
"28": {
"departement": "Eure-et-Loir",
"region": "Centre-Val de Loire"
},
"29": {
"departement": "Finistère",
"region": "Bretagne"
},
"30": {
"departement": "Gard",
"region": "Occitanie"
},
"31": {
"departement": "Haute-Garonne",
"region": "Occitanie"
},
"32": {
"departement": "Gers",
"region": "Occitanie"
},
"33": {
"departement": "Gironde",
"region": "Nouvelle-Aquitaine"
},
"34": {
"departement": "Hérault",
"region": "Occitanie"
},
"35": {
"departement": "Ille-et-Vilaine",
"region": "Bretagne"
},
"36": {
"departement": "Indre",
"region": "Centre-Val de Loire"
},
"37": {
"departement": "Indre-et-Loire",
"region": "Centre-Val de Loire"
},
"38": {
"departement": "Isère",
"region": "Auvergne-Rhône-Alpes"
},
"39": {
"departement": "Jura",
"region": "Bourgogne-Franche-Comté"
},
"40": {
"departement": "Landes",
"region": "Nouvelle-Aquitaine"
},
"41": {
"departement": "Loir-et-Cher",
"region": "Centre-Val de Loire"
},
"42": {
"departement": "Loire",
"region": "Auvergne-Rhône-Alpes"
},
"43": {
"departement": "Haute-Loire",
"region": "Auvergne-Rhône-Alpes"
},
"44": {
"departement": "Loire-Atlantique",
"region": "Pays de la Loire"
},
"45": {
"departement": "Loiret",
"region": "Centre-Val de Loire"
},
"46": {
"departement": "Lot",
"region": "Occitanie"
},
"47": {
"departement": "Lot-et-Garonne",
"region": "Nouvelle-Aquitaine"
},
"48": {
"departement": "Lozère",
"region": "Occitanie"
},
"49": {
"departement": "Maine-et-Loire",
"region": "Pays de la Loire"
},
"50": {
"departement": "Manche",
"region": "Normandie"
},
"51": {
"departement": "Marne",
"region": "Grand Est"
},
"52": {
"departement": "Haute-Marne",
"region": "Grand Est"
},
"53": {
"departement": "Mayenne",
"region": "Pays de la Loire"
},
"54": {
"departement": "Meurthe-et-Moselle",
"region": "Grand Est"
},
"55": {
"departement": "Meuse",
"region": "Grand Est"
},
"56": {
"departement": "Morbihan",
"region": "Bretagne"
},
"57": {
"departement": "Moselle",
"region": "Grand Est"
},
"58": {
"departement": "Nièvre",
"region": "Bourgogne-Franche-Comté"
},
"59": {
"departement": "Nord",
"region": "Hauts-de-France"
},
"60": {
"departement": "Oise",
"region": "Hauts-de-France"
},
"61": {
"departement": "Orne",
"region": "Normandie"
},
"62": {
"departement": "Pas-de-Calais",
"region": "Hauts-de-France"
},
"63": {
"departement": "Puy-de-Dôme",
"region": "Auvergne-Rhône-Alpes"
},
"64": {
"departement": "Pyrénées-Atlantiques",
"region": "Nouvelle-Aquitaine"
},
"65": {
"departement": "Hautes-Pyrénées",
"region": "Occitanie"
},
"66": {
"departement": "Pyrénées-Orientales",
"region": "Occitanie"
},
"67": {
"departement": "Bas-Rhin",
"region": "Grand Est"
},
"68": {
"departement": "Haut-Rhin",
"region": "Grand Est"
},
"69": {
"departement": "Rhône",
"region": "Auvergne-Rhône-Alpes"
},
"70": {
"departement": "Haute-Saône",
"region": "Bourgogne-Franche-Comté"
},
"71": {
"departement": "Saône-et-Loire",
"region": "Bourgogne-Franche-Comté"
},
"72": {
"departement": "Sarthe",
"region": "Pays de la Loire"
},
"73": {
"departement": "Savoie",
"region": "Auvergne-Rhône-Alpes"
},
"74": {
"departement": "Haute-Savoie",
"region": "Auvergne-Rhône-Alpes"
},
"75": {
"departement": "Paris",
"region": "Île-de-France"
},
"76": {
"departement": "Seine-Maritime",
"region": "Normandie"
},
"77": {
"departement": "Seine-et-Marne",
"region": "Île-de-France"
},
"78": {
"departement": "Yvelines",
"region": "Île-de-France"
},
"79": {
"departement": "Deux-Sèvres",
"region": "Nouvelle-Aquitaine"
},
"80": {
"departement": "Somme",
"region": "Hauts-de-France"
},
"81": {
"departement": "Tarn",
"region": "Occitanie"
},
"82": {
"departement": "Tarn-et-Garonne",
"region": "Occitanie"
},
"83": {
"departement": "Var",
"region": "Provence-Alpes-Côte d'Azur"
},
"84": {
"departement": "Vaucluse",
"region": "Provence-Alpes-Côte d'Azur"
},
"85": {
"departement": "Vendée",
"region": "Pays de la Loire"
},
"86": {
"departement": "Vienne",
"region": "Nouvelle-Aquitaine"
},
"87": {
"departement": "Haute-Vienne",
"region": "Nouvelle-Aquitaine"
},
"88": {
"departement": "Vosges",
"region": "Grand Est"
},
"89": {
"departement": "Yonne",
"region": "Bourgogne-Franche-Comté"
},
"90": {
"departement": "Territoire de Belfort",
"region": "Bourgogne-Franche-Comté"
},
"91": {
"departement": "Essonne",
"region": "Île-de-France"
},
"92": {
"departement": "Hauts-de-Seine",
"region": "Île-de-France"
},
"93": {
"departement": "Seine-Saint-Denis",
"region": "Île-de-France"
},
"94": {
"departement": "Val-de-Marne",
"region": "Île-de-France"
},
"95": {
"departement": "Val-d'Oise",
"region": "Île-de-France"
},
"971": {
"departement": "Guadeloupe",
"region": "Guadeloupe"
},
"972": {
"departement": "Martinique",
"region": "Martinique"
},
"973": {
"departement": "Guyane",
"region": "Guyane"
},
"974": {
"departement": "La Réunion",
"region": "La Réunion"
},
"976": {
"departement": "Mayotte",
"region": "Mayotte"
}
}
+9 -3
View File
@@ -1,17 +1,23 @@
[project] [project]
name = "decp.info" name = "decp.info"
description = "" description = "Interface d'exploration et d'analyse des marchés publics français."
version = "2.0.0" version = "2.2.0"
requires-python = ">= 3.10" requires-python = ">= 3.10"
authors = [ authors = [
{ name = "Colin Maudry", email = "colin+decp@maudry.com" } { name = "Colin Maudry", email = "colin+decp@maudry.com" }
] ]
dependencies = [ dependencies = [
"dash", "dash==3.2.0",
"dash[compress]",
"polars", "polars",
"gunicorn", "gunicorn",
"dash-bootstrap-components", "dash-bootstrap-components",
"python-dotenv", "python-dotenv",
"xlsxwriter",
"plotly[express]",
"httpx",
"pandas", # utilisé pour la création de certains graphiques
"unidecode"
] ]
[project.optional-dependencies] [project.optional-dependencies]
-1
View File
@@ -1,4 +1,3 @@
from os import getenv
from src.app import app from src.app import app
# To use `gunicorn run:server` (prod) # To use `gunicorn run:server` (prod)
+88 -12
View File
@@ -1,21 +1,106 @@
from dash import Dash, html, page_container, page_registry, dcc import logging
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
import tomllib
from dash import Dash, dcc, html, page_container, page_registry
from dotenv import load_dotenv
from flask import send_from_directory
load_dotenv()
app = Dash(
external_stylesheets=[dbc.themes.SIMPLEX],
title="decp.info",
use_pages=True,
compress=True,
)
# COSMO (belle font, blue),
# UNITED (rouge, ubuntu font),
# LUMEN (gros séparateur, blue clair),
# SIMPLEX (rouge, séparateur)
app = Dash(external_stylesheets=[dbc.themes.UNITED], title="decp.info", use_pages=True) # robots.txt
@app.server.route("/robots.txt")
def robots():
return send_from_directory("./assets", "robots.txt", mimetype="text/plain")
logger = logging.getLogger("decp.info")
logging.basicConfig(
format="%(asctime)s %(levelname)-8s %(message)s",
level=logging.INFO,
datefmt="%Y-%m-%d %H:%M:%S",
)
with open("./pyproject.toml", "rb") as f:
pyproject = tomllib.load(f)
version = "v" + pyproject["project"]["version"]
app.index_string = """
<!DOCTYPE html>
<html lang="fr">
<head>
{%metas%}
<title>{%title%}</title>
{%favicon%}
{%css%}
</head>
<body>
{%app_entry%}
<footer>
{%config%}
{%scripts%}
{%renderer%}
</footer>
<script type="application/javascript">
console.log("Matomo");
var _paq = window._paq = window._paq || [];
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u="//analytics.maudry.com/";
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', '14']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
})();
</script>
</body>
</html>
"""
app.layout = html.Div( app.layout = html.Div(
[ [
html.Div( html.Div(
[ [
html.H1("decp.info"), html.Div(
[
html.A(children=html.H1("decp.info"), href="/"),
html.P(
children=html.A(
version,
href="https://github.com/ColinMaudry/decp.info?tab=readme-ov-file#notes-de-version",
target="_blank",
),
className="version",
),
],
className="logo",
),
html.Div(
id="announcements",
children=dcc.Markdown(""),
),
html.Div( html.Div(
[ [
dcc.Link( dcc.Link(
page["name"], href=page["relative_path"], className="nav" page["name"], href=page["relative_path"], className="nav"
) )
for page in page_registry.values() for page in page_registry.values()
if page["name"] not in ["Acheteur", "Titulaire", "Marché"]
] ]
), ),
], ],
@@ -24,15 +109,6 @@ app.layout = html.Div(
page_container, page_container,
] ]
) )
# @callback(
# Output(component_id="table", component_property="data", allow_duplicate=True),
# Input(component_id="search", component_property="value"),
# prevent_initial_call=True,
# )
# def global_search(text):
# new_df = df
# new_df = new_df.filter(pl.col("objet").str.contains("(?i)" + text))
# return new_df.to_dicts()
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=True) app.run(debug=True)
Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

+41
View File
@@ -0,0 +1,41 @@
# 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: /
+240 -18
View File
@@ -1,29 +1,72 @@
/* Change le texte du bout d'export */ /* Change la marge bout d'export */
button.export { .table-menu {
font-size: 0; font-size: 16px;
} margin: 12px;
button.export:before { height: 36px;
content: "Télécharger au format Excel";
font-size: 14px;
} }
/* Masquer le bouton de configuration de la casse dans les filtres */ .table-menu > * {
input.dash-filter--case { margin: 8px;
display: none; 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 */ /* Réduire la taille du texte de la colonne Objet */
td[data-dash-column="objet"] { /*
td[data-dash-column="objet"],td[data-dash-column="titulaire_nom"],td[data-dash-column="acheteur_nom"], {
font-size: 85%; font-size: 85%;
} }*/
/* Couleur des en-têtes */
.dash-table-container .dash-table-container
.dash-spreadsheet-container .dash-spreadsheet-container
.dash-spreadsheet-inner .dash-spreadsheet-inner
th.dash-header { th.dash-header {
background-color: #b33821; background-color: #b33821;
color: white; color: white;
font-family: "Open Sans", sans-serif;
text-align: left;
} }
.dash-table-container .dash-table-container
@@ -33,8 +76,66 @@ td[data-dash-column="objet"] {
background-color: #f0afa3; background-color: #f0afa3;
} }
#table tr:nth-child(even) td { .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; background-color: #feeeee;
font-family: "Open Sans", sans-serif;
} }
#header > *, #header > *,
@@ -42,10 +143,57 @@ td[data-dash-column="objet"] {
margin: 0 0 20px 20px; margin: 0 0 20px 20px;
} }
/* Annonces */
#announcements {
margin-top: 25px;
max-width: 30%;
font-size: 90%;
}
/* Page de recherche */
.tagline {
text-align: center;
font-size: 120%;
display: block;
margin-top: 50px;
}
#search {
margin: 30px auto 0px auto;
width: 500px;
font-size: 16px;
height: 30px;
display: block;
}
.search_options {
margin: 16px auto;
width: 450px;
}
.search_options input {
margin-right: 12px;
}
.results_acheteur {
grid-column: 1;
grid-row: 1;
}
.results_titulaire {
grid-column: 2;
grid-row: 1;
}
/* Menu de navigation */
.navbar { .navbar {
width: 100%; width: 100%;
height: 100px; height: 100px;
padding: 10px; padding: 0 10px;
border: 0;
border-bottom: solid 1px black;
} }
a.nav { a.nav {
@@ -54,11 +202,85 @@ a.nav {
font-size: 120%; font-size: 120%;
} }
h1 { h3 {
float: left; margin: 36px 0 24px 0;
width: 50%;
} }
h3 { summary > h3 {
margin: 0;
display: inline; 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
@@ -0,0 +1,65 @@
import polars as pl
from dash import dash_table, html
from utils import add_links_in_dict, format_values, setup_table_columns
def get_top_org_table(data, org_type: str):
dff = pl.DataFrame(data)
if dff.height == 0:
return html.Div()
dff = dff.select(
["uid", f"{org_type}_id", f"{org_type}_nom", "distance", "montant"]
)
dff_nb = dff.group_by(f"{org_type}_id", f"{org_type}_nom", "distance").agg(
pl.len().alias("Attributions"), pl.sum("montant").alias("montant")
)
dff_nb = dff_nb.sort(by="montant", descending=True)
dff_nb = dff_nb.cast(pl.String)
dff_nb = dff_nb.fill_null("")
dff_nb = format_values(dff_nb)
columns, tooltip = setup_table_columns(
dff_nb, hideable=False, exclude=[f"{org_type}_id"]
)
data = dff_nb.to_dicts()
data = add_links_in_dict(data, f"{org_type}")
return dash_table.DataTable(
data=data,
markdown_options={"html": True},
page_action="native",
page_size=10,
columns=columns,
cell_selectable=False,
tooltip_header=tooltip,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
"fontSize": "85%",
},
{
"if": {"column_id": "acheteur_nom"},
"minWidth": "200px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "16px",
# "fontSize": "85%",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "200px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "16px",
"whiteSpace": "normal",
# "fontSize": "85%",
},
],
)
+194
View File
@@ -0,0 +1,194 @@
import json
import plotly.express as px
import polars as pl
from dash import dash_table, dcc, html
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(
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,
)
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]},
}
)
return fig
def get_barchart_sources(df: pl.DataFrame, type_date: str):
lf = df.lazy()
labels = {
"dateNotification": "notification",
"datePublicationDonnees": "publication des données",
}
lf = lf.select("uid", type_date, "sourceDataset")
lf = lf.unique("uid")
# Rassemblement des datasets Atexo pour ne pas surcharger le graphique
lf = lf.with_columns(
pl.when(pl.col("sourceDataset").str.starts_with("atexo"))
.then(pl.lit("plateformes atexo"))
.otherwise(pl.col("sourceDataset"))
.alias("sourceDataset")
)
# Rassemblement des datasets AWS pour ne pas surcharger le graphique
lf = lf.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)
)
lf = lf.with_columns(pl.col(type_date).cast(pl.String).str.head(7))
lf = (
lf.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")
# )
lf = lf.sort(by=["sourceDataset"], descending=False)
df: pl.DataFrame = lf.collect(engine="streaming")
fig = px.bar(
df,
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]}",
"sourceDataset": "Source de données",
},
)
return fig
def get_sources_tables(source_path) -> html.Div:
df = pl.read_csv(source_path)
df = df.with_columns(
(
pl.lit('<a href = "')
+ pl.col("url")
+ pl.lit('">')
+ pl.col("nom")
+ pl.lit("</a>")
).alias("nom")
)
df = df.drop("url")
df = df.sort(by=["nb_marchés"], descending=True)
datatable = dash_table.DataTable(
id="source_table",
columns=[
{
"name": i,
"id": i,
"presentation": "markdown",
"type": "text",
"format": {"nully": "N/A"},
}
for i in df.schema.names()
],
style_cell_conditional=[
{
"if": {"column_id": ["nom", "organisation"]},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
],
sort_action="native",
markdown_options={"html": True},
)
datatable.data = df.to_dicts()
return html.Div(children=datatable)
def point_on_map(lat, lon):
lat = float(lat)
lon = float(lon)
# Create a scatter mapbox or choropleth map
fig = px.scatter_map(
lat=[lat], lon=[lon], height=300, width=400, color=[1], size=[1]
)
fig.update_coloraxes(showscale=False)
# Set map style (you can use 'open-street-map', 'carto-positron', etc.)
fig.update_layout(
mapbox_style="light", # Light, clean background
margin={"r": 0, "t": 0, "l": 0, "b": 0},
)
# Optionally, center the map on France
fig.update_geos(
center=dict(lat=46.603354, lon=1.888334), # Center of France
lataxis_range=[41, 51.5], # Latitude range for France
lonaxis_range=[-5, 10], # Longitude range for France
)
# But scatter_mapbox doesn't use geos, so better to control via zoom/center manually
# Let's reset and use proper centering in scatter_mapbox instead:
fig.update_layout(map_center={"lat": 46.6, "lon": 1.89}, map_zoom=4)
graph = dcc.Graph(id="map", figure=fig)
return graph
+119
View File
@@ -0,0 +1,119 @@
import os
from dash import dcc, html, register_page
from src.figures import get_sources_tables
from src.utils import meta_content
name = "À propos"
register_page(
__name__,
path="/a-propos",
title=meta_content["title"],
name=name,
description=meta_content["description"],
image_url=meta_content["image_url"],
order=5,
)
layout = [
html.Div(
className="container",
children=[
html.H2(name),
dcc.Markdown(
"""Outil d'exploration libre et gratuit des données de marchés publics, développé par Colin Maudry.
Ce projet vise à démocratiser l'accès aux données des marchés publics et à un outil performant et gratuit. Si vous le trouvez utile
j'aimerais beaucoup échanger avec vous pour comprendre vos cas d'usages et vos besoins. Cet outil ne peut rester performant que si je comprends les problèmes qu'il peut aider à résoudre. Ce projet ne peut rester gratuit que grâce au financement du développement de nouvelles fonctionnalités.
En effet, le potentiel des données d'attribution de marchés et des données qui peuvent les enrichir est très loin d'être exploité par
les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
- de nombreuses visualisations de données (cartes, graphiques, tableaux) sur des thématiques variées (vivacité de la concurrence, secteurs d'activité, insertion par l'activité économique (IAE), distance acheteur-titulaire...)
- 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("Contact", id="contact"),
dcc.Markdown("""
- Email : [colin+decp@maudry.com](mailto:colin+decp@maudry.com)
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
"""),
html.H4("Pour contribuer", id="contribuer"),
dcc.Markdown("""
- via l'achat d'une prestation de service (devis, prestation, facture), vous pouvez financer le développement de [fonctionnalités prévues](https://github.com/ColinMaudry/decp.info/issues), ou d'autres !
- ma société accepte aussi les dons (pas de réduction d'impôt possible)
- écrivez-moi et on discute !
#### Pour explorer le projet
- ✉️ [inscription à la liste de diffusion](https://6254d9a3.sibforms.com/serve/MUIFAEonUVkoSVrdgey18CTgLyI16xw4yeu-M-YOUzhWE_AgfQfbgkyT7GvA_RYLro9MfuRqkzQxSvu7-uzbMSv2a2ZQPsliM7wtiiqIL8kR2zOvl6m11fb5qjcOxMAYsLiY_YBi3P7NY95CTJ8vRY4CpsDclF2iLooOElKkTgIgi5nePe7zAIrgiYM5v2EuALlGJZMEG9vBP-Cu) (annonces des mises à jour et évènements, maximum une fois par mois)
- 💾 [données consolidées en Open Data](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire/)
- 🗞️ [mon blog](https://colin.maudry.com)
- 📔 [wiki du projet](https://github.com/ColinMaudry/decp-processing/wiki)
- 🚰 code source
- [de decp.info](https://github.com/ColinMaudry/decp.info)
- [du traitement des données](https://github.com/ColinMaudry/decp-processing)
"""),
html.H4("Qualité et exhaustivité des données", id="qualite-exhausitivite"),
dcc.Markdown("""Les données visibles sur ce site proviennent exclusivement de la publication de données ouvertes par les acheteurs publics ou en leur nom, régie par [l'arrêté du 22 décembre 2022](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000046850496). Leur qualité est donc principalement liée à la qualité de leur saisie par les agents publics, parfois peu aidé·es par la qualité des outils à leur disposition. Je pense que l'analyse de marchés individuels et le comptage de marchés sur des critères autres que financiers sont plutôt fiables. En revanche, certains montants de marché estimés à des valeurs farfelues ([1 euro](https://decp.info/marches/432766947000192025S01301), [1 milliard](https://decp.info/marches/2459004280001320210000000271)) faussent les calculs par aggrégation (sommes, moyennes, médianes) et donc la production de statistiques financières fiables. Acheteurs, acheteuses : s'il vous plaît, essayez d'estimer les montants des marchés publics attribués de manière plus précise.
Quant à l'exhaustivité, je consolide toutes les sources de données exploitables que j'ai pu identifier (voir [statistiques](/statistiques)). Certains profils d'acheteurs ne publient pas leurs données malgré l'obligation réglementaire :
- marches-securises.fr
- klekoon.fr (ils y travaillent)
**marches-publics.gouv.fr** (PLACE, [AIFE](https://aife.economie.gouv.fr)), la plateforme des marchés de l'État, ne publie plus de données depuis le 1er janvier 2024 et l'entrée en vigueur de l'arrêté. Nous n'avons donc plus de données sur les marchés publics passés par les ministères, ainsi que sur ceux passés par les acheteurs clients d'[achatpublic.com](https://www.achatpublic.com), qui s'appuie sur les moyens de publication de l'AIFE.
**marches-publics.info** (AWS) publie ses données de manière assez sporadique depuis début 2023. Compte tenu de son poids dans le secteur, c'est assez dommageable pour la transparence des marchés publics.
Au milieu de ces mauvaises nouvelles, je tiens à souligner la belle continuité de la publication par la DGFiP des données des marchés publics remontées via le [protocole PES](https://www.collectivites-locales.gouv.fr/finances-locales/le-protocole-dechange-standard-pes). Merci à leurs équipes."""),
html.H4("Sources de données ", id="qualite-exhausitivite"),
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
html.H4("Mentions légales", id="mentions-legales"),
dcc.Markdown("""
##### Publication
Site Web développé et édité par [SAS Colmo](https://annuaire-entreprises.data.gouv.fr/entreprise/colmo-989393350), 989 393 350 RCS Rennes au capital de 3 000 euros.
Siège social : 1 carrefour Jouaust, 35000 Rennes
Hébergement : serveur situé en France et administré par Scaleway, 8 rue de la Ville lEvêque, 75008 Paris
##### Suivi d'audience
Ce site dépose un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le consultez ([Wikipédia](https://fr.wikipedia.org/wiki/Cookie_(informatique))). Cela me permet de mesurer le nombre de visites, de distinguer les nouveaux visiteurs des utilisateurs réguliers et ainsi de communiquer sur l'impact de decp.info.
**Ce site naffiche pas de bannière de consentement aux cookies, pourquoi ?**
Cest vrai, vous navez pas eu à cliquer sur un bloc qui recouvre la moitié de la page pour dire que vous êtes daccord avec le dépôt de cookies.
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"
# ),
],
)
]
-9
View File
@@ -1,9 +0,0 @@
from dash import register_page, html
title = "À propos"
register_page(
__name__, path="/a-propos", title=f"decp.info - {title}", name=title, order=3
)
layout = [html.H2(title)]
+296
View File
@@ -0,0 +1,296 @@
import datetime
import polars as pl
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
from src.callbacks import get_top_org_table
from src.figures import point_on_map
from src.utils import (
add_links_in_dict,
df,
format_number,
format_values,
get_annuaire_data,
get_departement_region,
meta_content,
setup_table_columns,
)
register_page(
__name__,
path_template="/acheteurs/<acheteur_id>",
title=meta_content["title"],
name="Acheteur",
description=meta_content["description"],
image_url=meta_content["image_url"],
order=5,
)
layout = [
dcc.Store(id="acheteur_data", storage_type="memory"),
dcc.Location(id="url", refresh="callback-nav"),
html.Div(
className="container",
children=[
html.Div(
className="wrapper",
children=[
html.H2(
className="org_title",
children=[
html.Span(id="acheteur_siret"),
" - ",
html.Span(id="acheteur_nom"),
],
),
html.Div(
className="org_year",
children=dcc.Dropdown(
id="acheteur_year",
options=["Toutes"]
+ [
str(year)
for year in range(
2018, int(datetime.date.today().year) + 1
)
],
placeholder="Année",
),
),
html.Div(
className="org_infos",
children=[
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
html.P(["Commune : ", html.Strong(id="acheteur_commune")]),
html.P(
[
"Département : ",
html.Strong(id="acheteur_departement"),
]
),
html.P(["Région : ", html.Strong(id="acheteur_region")]),
html.A(
id="acheteur_lien_annuaire",
children="Plus de détails sur l'Annuaire des entreprises",
target="_blank",
),
],
),
html.Div(
className="org_stats",
children=[
html.P(id="acheteur_titre_stats"),
html.P(id="acheteur_marches_attribues"),
html.P(id="acheteur_titulaires_differents"),
html.Button(
"Téléchargement au format Excel",
id="btn-download-acheteur-data",
),
dcc.Download(id="download-acheteur-data"),
],
),
html.Div(className="org_map", id="acheteur_map"),
html.Div(
className="org_top",
children=[
html.H3("Top titulaires"),
html.Div(className="marches_table", id="top10_titulaires"),
],
),
],
),
# récupérer les données de l'acheteur sur l'api annuaire
html.H3("Derniers marchés publics attribués"),
html.Div(id="acheteur_last_marches", children=""),
],
),
]
@callback(
Output(component_id="acheteur_siret", component_property="children"),
Output(component_id="acheteur_nom", component_property="children"),
Output(component_id="acheteur_commune", component_property="children"),
Output(component_id="acheteur_map", component_property="children"),
Output(component_id="acheteur_departement", component_property="children"),
Output(component_id="acheteur_region", component_property="children"),
Output(component_id="acheteur_lien_annuaire", component_property="href"),
Input(component_id="url", component_property="pathname"),
)
def update_acheteur_infos(url):
acheteur_siret = url.split("/")[-1]
if len(acheteur_siret) != 14:
acheteur_siret = (
f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})"
)
data = get_annuaire_data(acheteur_siret)
data_etablissement = data["matching_etablissements"][0]
acheteur_map = point_on_map(
data_etablissement["latitude"], data_etablissement["longitude"]
)
code_departement, nom_departement, nom_region = get_departement_region(
data_etablissement["code_postal"]
)
departement = f"{nom_departement} ({code_departement})"
lien_annuaire = (
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
)
return (
acheteur_siret,
data["nom_raison_sociale"],
data_etablissement["libelle_commune"],
acheteur_map,
departement,
nom_region,
lien_annuaire,
)
@callback(
Output(component_id="acheteur_marches_attribues", component_property="children"),
Output(
component_id="acheteur_titulaires_differents", component_property="children"
),
Input(component_id="acheteur_data", component_property="data"),
)
def update_acheteur_stats(data):
dff = pl.DataFrame(data)
if dff.height == 0:
dff = pl.DataFrame(schema=df.collect_schema())
df_marches = dff.unique("id")
nb_marches = format_number(df_marches.height)
# somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item()))
marches_attribues = [html.Strong(nb_marches), " marchés et accord-cadres attribués"]
# + ", pour un total de ", html.Strong(somme_marches + " €")]
del df_marches
nb_titulaires = dff.unique("titulaire_id").height
nb_titulaires = [
html.Strong(format_number(nb_titulaires)),
" titulaires (SIRET) différents",
]
del dff
return marches_attribues, nb_titulaires
@callback(
Output(component_id="acheteur_data", component_property="data"),
Input(component_id="url", component_property="pathname"),
Input(component_id="acheteur_year", component_property="value"),
)
def get_acheteur_marches_data(url, acheteur_year: str) -> list[dict]:
acheteur_siret = url.split("/")[-1]
lff = df.lazy()
lff = lff.filter(pl.col("acheteur_id") == acheteur_siret)
lff = lff.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)
data = lff.collect(engine="streaming").to_dicts()
return data
@callback(
Output(component_id="acheteur_last_marches", component_property="children"),
Input(component_id="acheteur_data", component_property="data"),
)
def get_last_marches_table(data) -> html.Div:
dff = pl.DataFrame(data)
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"],
)
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(
Output(component_id="top10_titulaires", component_property="children"),
Input(component_id="acheteur_data", component_property="data"),
)
def get_top_titulaires(data):
return get_top_org_table(data, "titulaire")
@callback(
Output("download-acheteur-data", "data"),
Input("btn-download-acheteur-data", "n_clicks"),
State(component_id="acheteur_data", component_property="data"),
State(component_id="acheteur_nom", component_property="children"),
State(component_id="acheteur_year", component_property="value"),
prevent_initial_call=True,
)
def download_acheteur_data(
n_clicks,
data: [dict],
acheteur_nom: str,
annee: str,
):
df_to_download = pl.DataFrame(data)
def to_bytes(buffer):
df_to_download.write_excel(
buffer, worksheet="DECP" if annee in ["Toutes", None] else annee
)
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{acheteur_nom}_{date}.xlsx")
-132
View File
@@ -1,132 +0,0 @@
from dash import html, dcc, dash_table, register_page, Input, Output, State, callback
from dotenv import load_dotenv
import os
import polars as pl
from src.utils import split_filter_part
load_dotenv()
df = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
title = "Tableau"
register_page(__name__, path="/", title=f"decp.info - {title}", name=title, order=1)
datatable = dash_table.DataTable(
cell_selectable=False,
id="table",
page_size=20,
page_current=0,
page_action="custom",
filter_action="custom",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
columns=[{"name": i, "id": i} for i in df.collect_schema().names()],
selected_columns=[],
selected_rows=[],
# sort_action="native",
# sort_mode="multi",
export_format="xlsx",
export_columns="visible",
export_headers="ids",
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
],
data_timestamp=0,
)
layout = [
html.Div(
html.Details(
children=[
html.Summary(html.H3("Utilisation")),
dcc.Markdown(
"""
**Filtres**
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`.
- Champs textuels : la recherche est insensible à la casse (majuscules/minuscules).
- Champs numériques : possibilité d'ajouter < ou > devant le chiffre recherché pour chercher des valeurs inférieures ou supérieur.
**Télécharger le résultat**
Vous pouvez télécharger le résultat de vos filtres et tris en cliquant sur Télécharger au format Excel.
Les colonnes supprimées seront absentes du fichier téléchargé.
Si vous téléchargez un volume important de données, il se peut que vous attendiez quelques minutes avant le début du téléchargement.
"""
),
],
id="instructions",
),
id="header",
),
# html.Div(
# [
# "Recherche dans objet : ",
# dcc.Input(id="search", value="", type="text"),
# ]
# )]),
dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-1",
type="default",
children=datatable,
),
]
@callback(
Output("table", "data"),
Output("table", "data_timestamp"),
Input("table", "page_current"),
Input("table", "page_size"),
Input("table", "filter_query"),
State("table", "data_timestamp"),
)
def update_table(page_current, page_size, filter_query, data_timestamp):
print(" + + + + + + + + + + + + + + + + + + ")
print("Filter query:", filter_query)
# 1. Apply Filters
dff = df # start from the original data
if filter_query:
filtering_expressions = filter_query.split(" && ")
for filter_part in filtering_expressions:
col_name, operator, filter_value = split_filter_part(filter_part)
print("filter_value:", filter_value)
print("filter_value_type:", type(filter_value))
if operator in ("<", "<=", ">", ">="):
filter_value = int(filter_value)
if operator == "<":
dff = dff.filter(pl.col(col_name) < filter_value)
elif operator == ">":
dff = dff.filter(pl.col(col_name) > filter_value)
elif operator == ">=":
dff = dff.filter(pl.col(col_name) >= filter_value)
elif operator == "<=":
dff = dff.filter(pl.col(col_name) <= filter_value)
# these operators match polars series filter operators
elif operator == "contains":
dff = dff.filter(pl.col(col_name).str.contains("(?i)" + filter_value))
# elif operator == 'datestartswith':
# dff = dff.filter(pl.col(col_name).str.startswith(filter_value)")
# 2. Paginate Data
start_row = page_current * page_size
# end_row = (page_current + 1) * page_size
dff = dff.slice(start_row, page_size).collect()
# print("dff_sliced:", dff.select("titulaire.typeId"))
dff = dff.to_dicts()
return dff, data_timestamp + 1 # update data, update timestamp
+184
View File
@@ -0,0 +1,184 @@
from datetime import datetime
import polars as pl
from dash import Input, Output, callback, dcc, html, register_page
from polars import selectors as cs
from src.utils import data_schema, df, format_values, meta_content
register_page(
__name__,
path_template="/marches/<uid>",
title=meta_content["title"],
name="Marché",
description=meta_content["description"],
image_url=meta_content["image_url"],
order=7,
)
layout = [
dcc.Store(id="marche_data"),
dcc.Store(id="titulaires_data"),
dcc.Location(id="url", refresh="callback-nav"),
html.Div(
className="container marche_infos",
children=[
html.P("Vous consultez un résumé des données de ce marché public"),
html.Ul(
[
html.Li(
"après son attribution aux titulaires qui l'ont remporté à la suite d'un appel d'offres (ou sans appel d'offres via une attribution directe)"
),
html.Li(
"après avoir appliqué les éventuelles modifications de montant, durée ou titulaires renseignées par l'acheteur"
),
]
),
html.P(
"Le montant total payé aux titulaires, la durée du marché et la liste des titulaires peuvent cependant encore évoluer jusqu'à la fin de l'exécution du marché."
),
html.Div(
className="wrapper",
children=[
html.Div(
className="marche_map",
id="marche_map",
children=[
html.H4("Titulaires"),
html.Ul(id="marche_infos_titulaires"),
],
),
html.Div(className="marche_infos_1", id="marche_infos_1"),
html.Div(className="marche_infos_2", id="marche_infos_2"),
],
),
],
),
]
@callback(
Output("marche_data", "data"),
Output("titulaires_data", "data"),
Input(component_id="url", component_property="pathname"),
)
def get_marche_data(url) -> tuple[dict, list]:
marche_uid = url.split("/")[-1]
# Récupération des données du marché à partir du df global
lff = df.lazy()
lff = lff.filter(pl.col("uid") == pl.lit(marche_uid))
# Données des titulaires du marché
dff_titulaires = lff.select(cs.starts_with("titulaire")).collect(engine="streaming")
# Données du marché
dff_marche = lff.unique("uid").collect(engine="streaming")
dff_marche = format_values(dff_marche)
return dff_marche.to_dicts()[0], dff_titulaires.to_dicts()
@callback(
Output("marche_infos_1", "children"),
Output("marche_infos_2", "children"),
Output("marche_infos_titulaires", "children"),
Input("marche_data", "data"),
Input("titulaires_data", "data"),
)
def update_marche_info(marche, titulaires):
def make_parameter(col):
column_object = data_schema.get(col)
column_name = column_object.get("title") if column_object else col
if marche[col]:
if col == "acheteur_nom":
value = html.A(
href=f"/acheteurs/{marche['acheteur_id']}",
children=marche["acheteur_nom"],
)
elif col == "sourceDataset":
value = html.A(
href=marche["sourceFile"], children=marche["sourceDataset"]
)
column_name = "Source des données"
# Dates
elif col in ["dateNotification", "datePublicationDonnees"]:
value = datetime.fromisoformat(marche[col]).strftime("%d/%m/%Y")
# Listes
elif (
col
in [
"techniques",
"typesPrix",
"considerationsSociales",
"considerationsEnvironnementales",
]
and "," in marche[col]
):
col_values = marche[col].split(", ")
lines = []
for val in col_values:
lines.append(html.Li(val))
_content = html.Div(
[html.P([column_name, " : "]), html.Ul(children=lines)]
)
return _content
else:
value = marche.get(col)
else:
value = ""
param_content = html.P([column_name, " : ", html.Strong(value)])
return param_content
marche_infos = [
make_parameter("id"),
make_parameter("objet"),
make_parameter("dateNotification"), # date
make_parameter("nature"),
make_parameter("acheteur_nom"), # lien
make_parameter("montant"),
make_parameter("codeCPV"),
make_parameter("procedure"),
make_parameter("techniques"), # list
make_parameter("dureeMois"),
make_parameter("offresRecues"),
make_parameter("datePublicationDonnees"), # date
make_parameter("formePrix"),
make_parameter("typesPrix"), # list
make_parameter("attributionAvance"),
make_parameter("tauxAvance"),
make_parameter("marcheInnovant"), # label
make_parameter("modalitesExecution"),
make_parameter("considerationsSociales"), # list
make_parameter("considerationsEnvironnementales"), # list
make_parameter("ccag"),
make_parameter("sousTraitanceDeclaree"),
make_parameter("typeGroupementOperateurs"),
make_parameter("origineFrance"),
make_parameter("origineUE"),
make_parameter("idAccordCadre"),
make_parameter("sourceDataset"), # lien
]
half = round(len(marche_infos) / 2)
# pas inclus pour l'instant : lieu d'exécution, modifications
titulaires_lines = []
for titulaire in titulaires:
if titulaire["titulaire_typeIdentifiant"] == "SIRET":
content = html.Li(
html.A(
href=f"/titulaires/{titulaire['titulaire_id']}",
children=titulaire["titulaire_nom"],
)
)
else:
content = html.Li(titulaire["titulaire_nom"])
titulaires_lines.append(content)
return marche_infos[:half], marche_infos[half:], titulaires_lines
+111
View File
@@ -0,0 +1,111 @@
from dash import Input, Output, callback, dash_table, dcc, html, register_page
from src.utils import (
df_acheteurs,
df_titulaires,
meta_content,
search_org,
setup_table_columns,
)
name = "Recherche"
register_page(
__name__,
path="/",
title=meta_content["title"],
name=name,
description=meta_content["description"],
image_url=meta_content["image_url"],
order=0,
)
layout = html.Div(
className="container",
children=[
html.Div(
className="tagline",
children=html.P(
"Exploration et téléchargement des données des marchés publics"
),
),
dcc.Input(
id="search",
type="text",
placeholder="Nom d'acheteur/entreprise, SIREN/SIRET, code département",
autoFocus=True,
),
# html.Div(
# className="search_options",
# children=[dcc.RadioItems(options=["Acheteur(s)"])],
# ),
html.Div(id="search_results", className="wrapper"),
],
)
@callback(
Output("search_results", "children"),
Input("search", "value"),
prevent_initial_call=True,
)
def update_search_results(query):
if len(query) >= 1:
content = []
for org_type in ["acheteur", "titulaire"]:
if org_type == "acheteur":
dff = df_acheteurs
elif org_type == "titulaire":
dff = df_titulaires
else:
raise ValueError(f"{org_type} is not supported")
# Search acheteurs and titulaires using the same function
results = search_org(dff, query, org_type=org_type)
count = results.height
# Format output
columns, tooltip = setup_table_columns(results, hideable=False)
org_content = [
html.Div(
className=f"results_{org_type}",
children=[
html.H3(f"{org_type.title()}s : {count}"),
dash_table.DataTable(
columns=columns,
data=results.to_dicts(),
page_size=10,
# style_table={"overflowX": "auto"},
markdown_options={"html": True},
cell_selectable=False,
style_cell_conditional=[
{
"if": {"column_id": "acheteur_nom"},
"maxWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"maxWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
),
],
)
if count > 0
else html.P(f"Aucun {org_type} trouvé."),
]
content.extend(org_content)
return content
else:
return html.P("")
-9
View File
@@ -1,9 +0,0 @@
from dash import register_page, html
title = "Statistiques"
register_page(
__name__, path="/statistiques", title=f"decp.info - {title}", name=title, order=2
)
layout = [html.H2(title)]
+55
View File
@@ -0,0 +1,55 @@
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"
)
),
],
)
],
),
],
)
]
+263
View File
@@ -0,0 +1,263 @@
import os
from datetime import datetime
import polars as pl
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
from src.utils import (
add_links,
add_resource_link,
df,
filter_table_data,
format_number,
format_values,
get_default_hidden_columns,
meta_content,
setup_table_columns,
sort_table_data,
)
update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y")
schema = df.collect_schema()
name = "Tableau"
register_page(
__name__,
path="/tableau",
title=meta_content["title"],
name=name,
description=meta_content["description"],
image_url=meta_content["image_url"],
order=1,
)
datatable = html.Div(
className="marches_table",
children=dash_table.DataTable(
cell_selectable=False,
id="table",
page_size=20,
page_current=0,
page_action="custom",
filter_action="custom",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
sort_action="custom",
sort_mode="multi",
sort_by=[],
row_deletable=False,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "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),
),
)
layout = [
html.Div(
html.Details(
children=[
html.Summary(
html.H3("Mode d'emploi", style={"text-decoration": "underline"}),
),
dcc.Markdown(
"""
##### Définition des colonnes
Pour voir la définition d'une colonne, passez votre souris sur son en-tête.
##### Filtres
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`.
- Champs textuels : la recherche est insensible à la casse (majuscules/minuscules) et retourne les valeurs qui contiennent
le texte recherché. Exemple : `rennes` retourne "RENNES METROPOLE".
- Champs numériques : vous pouvez soit taper un nombre pour trouver les valeurs égales, soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres.
- Champs date : vous pouvez également utiliser **>** ou **<**. Exemples : `< 2024-01-31` pour "avant le 31 janvier 2024",
`2024` pour "en 2024", `> 2022` pour "à partir de 2022"
Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page.
##### Tri
Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre : tri ascendant, tri descendant, pas de tri.
##### Télécharger le résultat
Vous pouvez télécharger le résultat de vos filtres et tris, pour les colonnes affichées, en cliquant sur **Télécharger au format Excel**.
##### Liens
Les liens dans les colonnes Identifiant unique, Acheteur et Titulaire vous permettent de consulter une vue qui leur est dédiée
(informations, marchés attribués/remportés, etc.)
"""
),
],
id="instructions",
),
id="header",
),
# html.Div(
# [
# "Recherche dans objet : ",
# dcc.Input(id="search", value="", type="text"),
# ]
# )]),
dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-home",
type="default",
children=[
html.Div(
[
html.P("lignes", id="nb_rows"),
html.Button(
"Téléchargement désactivé au-delà de 65 000 lignes",
id="btn-download-data",
disabled=True,
),
dcc.Download(id="download-data"),
dcc.Store(id="filtered_data", storage_type="memory"),
html.P("Données mises à jour le " + str(update_date)),
],
className="table-menu",
),
datatable,
],
),
]
@callback(
Output("table", "data"),
Output("table", "columns"),
Output("table", "tooltip_header"),
Output("table", "data_timestamp"),
Output("nb_rows", "children"),
Output("btn-download-data", "disabled"),
Output("btn-download-data", "children"),
Output("btn-download-data", "title"),
Input("table", "page_current"),
Input("table", "page_size"),
Input("table", "filter_query"),
Input("table", "sort_by"),
State("table", "data_timestamp"),
)
def update_table(page_current, page_size, filter_query, sort_by, data_timestamp):
if os.getenv("DEVELOPMENT").lower() == "true":
print(" + + + + + + + + + + + + + + + + + + ")
# Application des filtres
lff: pl.LazyFrame = df.lazy() # start from the original data
if filter_query:
lff = filter_table_data(lff, filter_query)
if len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
# 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,
)
@callback(
Output("download-data", "data"),
Input("btn-download-data", "n_clicks"),
State("table", "filter_query"),
State("table", "sort_by"),
State("table", "hidden_columns"),
prevent_initial_call=True,
)
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
lff: pl.LazyFrame = df # start from the original data
# Les colonnes masquées sont supprimées
if hidden_columns:
lff = lff.drop(hidden_columns)
if filter_query:
lff = filter_table_data(lff, filter_query)
if len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
def to_bytes(buffer):
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx")
+308
View File
@@ -0,0 +1,308 @@
import datetime
import polars as pl
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
from src.callbacks import get_top_org_table
from src.figures import point_on_map
from src.utils import (
add_links_in_dict,
df,
format_number,
format_values,
get_annuaire_data,
get_departement_region,
meta_content,
setup_table_columns,
)
register_page(
__name__,
path_template="/titulaires/<titulaire_id>",
title=meta_content["title"],
name="Titulaire",
description=meta_content["description"],
image_url=meta_content["image_url"],
order=5,
)
# 21690123100011
layout = [
dcc.Store(id="titulaire_data", storage_type="memory"),
dcc.Location(id="url", refresh="callback-nav"),
html.Div(
className="container",
children=[
html.Div(
className="wrapper",
children=[
html.H2(
className="org_title",
children=[
html.Span(id="titulaire_siret"),
" - ",
html.Span(id="titulaire_nom"),
],
),
html.Div(
className="org_year",
children=dcc.Dropdown(
id="titulaire_year",
options=["Toutes"]
+ [
str(year)
for year in range(
2018, int(datetime.date.today().year) + 1
)
],
placeholder="Année",
),
),
html.Div(
className="org_infos",
children=[
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
html.P(["Commune : ", html.Strong(id="titulaire_commune")]),
html.P(
[
"Département : ",
html.Strong(id="titulaire_departement"),
]
),
html.P(["Région : ", html.Strong(id="titulaire_region")]),
html.A(
id="titulaire_lien_annuaire",
children="Plus de détails sur l'Annuaire des entreprises",
target="_blank",
),
],
),
html.Div(
className="org_stats",
children=[
html.P(id="titulaire_titre_stats"),
html.P(id="titulaire_marches_remportes"),
html.P(id="titulaire_acheteurs_differents"),
html.Button(
"Téléchargement au format Excel",
id="btn-download-titulaire-data",
),
dcc.Download(id="download-titulaire-data"),
],
),
html.Div(className="org_map", id="titulaire_map"),
html.Div(
className="org_top",
children=[
html.H3("Top acheteurs"),
html.Div(className="marches_table", id="top10_acheteurs"),
],
),
],
),
# récupérer les données de l'acheteur sur l'api annuaire
html.H3("Derniers marchés publics remportés"),
html.Div(id="titulaire_last_marches", children=""),
],
),
]
@callback(
Output(component_id="titulaire_siret", component_property="children"),
Output(component_id="titulaire_nom", component_property="children"),
Output(component_id="titulaire_commune", component_property="children"),
Output(component_id="titulaire_map", component_property="children"),
Output(component_id="titulaire_departement", component_property="children"),
Output(component_id="titulaire_region", component_property="children"),
Output(component_id="titulaire_lien_annuaire", component_property="href"),
Input(component_id="url", component_property="pathname"),
)
def update_titulaire_infos(url):
titulaire_siret = url.split("/")[-1]
if len(titulaire_siret) != 14:
titulaire_siret = (
f"Le SIRET renseigné doit faire 14 caractères ({titulaire_siret})"
)
data = get_annuaire_data(titulaire_siret)
data_etablissement = data["matching_etablissements"][0]
titulaire_map = point_on_map(
data_etablissement["latitude"], data_etablissement["longitude"]
)
code_departement, nom_departement, nom_region = get_departement_region(
data_etablissement["code_postal"]
)
departement = f"{nom_departement} ({code_departement})"
lien_annuaire = (
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
)
return (
titulaire_siret,
data["nom_raison_sociale"],
data_etablissement["libelle_commune"],
titulaire_map,
departement,
nom_region,
lien_annuaire,
)
@callback(
Output(component_id="titulaire_marches_remportes", component_property="children"),
Output(
component_id="titulaire_acheteurs_differents", component_property="children"
),
Input(component_id="titulaire_data", component_property="data"),
)
def update_titulaire_stats(data):
dff = pl.DataFrame(data)
if dff.height == 0:
dff = pl.DataFrame(schema=dff.collect_schema())
df_marches = dff.unique("uid")
nb_marches = format_number(df_marches.height)
# somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item()))
marches_remportes = [html.Strong(nb_marches), " marchés et accord-cadres remportés"]
# + ", pour un total de ", html.Strong(somme_marches + " €")]
del df_marches
nb_acheteurs = dff.unique("acheteur_id").height
nb_acheteurs = [
html.Strong(format_number(nb_acheteurs)),
" acheteurs (SIRET) différents",
]
del dff
return marches_remportes, nb_acheteurs
@callback(
Output(component_id="titulaire_data", component_property="data"),
Input(component_id="url", component_property="pathname"),
Input(component_id="titulaire_year", component_property="value"),
)
def get_titulaire_marches_data(url, titulaire_year: str) -> list[dict]:
titulaire_siret = url.split("/")[-1]
lff = df.lazy()
lff = lff.filter(
(pl.col("titulaire_id") == titulaire_siret)
& (pl.col("titulaire_typeIdentifiant") == "SIRET")
)
lff = lff.select(
"id",
"uid",
"objet",
"dateNotification",
"acheteur_id",
"acheteur_nom",
"distance",
"montant",
"codeCPV",
"dureeMois",
)
if titulaire_year and titulaire_year != "Toutes":
lff = lff.filter(
pl.col("dateNotification").cast(pl.String).str.starts_with(titulaire_year)
)
lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True)
data = lff.collect(engine="streaming").to_dicts()
return data
@callback(
Output(component_id="titulaire_last_marches", component_property="children"),
Input(component_id="titulaire_data", component_property="data"),
)
def get_last_marches_table(data) -> html.Div:
columns = [
"uid",
"objet",
"dateNotification",
"acheteur_nom",
"montant",
"codeCPV",
"dureeMois",
]
dff = pl.DataFrame(data)
dff = dff.cast(pl.String)
dff = dff.fill_null("")
dff = format_values(dff)
columns, tooltip = setup_table_columns(
dff, hideable=False, exclude=["acheteur_id", "id"]
)
data = dff.to_dicts()
# Idéalement on utiliserait add_org_links(), mais le résultat attendu
# est différent de home.py (Tableau)
data = add_links_in_dict(data, "acheteur")
table = html.Div(
className="marches_table",
id="titulaire_datatable",
children=dash_table.DataTable(
data=data,
markdown_options={"html": True},
page_action="native",
filter_action="native",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
columns=columns,
tooltip_header=tooltip,
tooltip_duration=8000,
tooltip_delay=350,
cell_selectable=False,
page_size=10,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "300px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_nom"},
"maxWidth": "400px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
),
)
return table
@callback(
Output(component_id="top10_acheteurs", component_property="children"),
Input(component_id="titulaire_data", component_property="data"),
)
def get_top_acheteurs(data):
return get_top_org_table(data, "acheteur")
@callback(
Output("download-titulaire-data", "data"),
Input("btn-download-titulaire-data", "n_clicks"),
State(component_id="titulaire_data", component_property="data"),
State(component_id="titulaire_nom", component_property="children"),
State(component_id="titulaire_year", component_property="value"),
prevent_initial_call=True,
)
def download_titulaire_data(
n_clicks,
data: [dict],
titulaire_nom: str,
annee: str,
):
df_to_download = pl.DataFrame(data)
def to_bytes(buffer):
df_to_download.write_excel(
buffer, worksheet="DECP" if annee in ["Toutes", None] else annee
)
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{titulaire_nom}_{date}.xlsx")
+507 -5
View File
@@ -1,11 +1,34 @@
operators = [ import json
["s<", "<"], import logging
["s>", ">"], import os
["icontains", "contains"], import uuid
] from time import localtime, sleep
import polars as pl
import polars.selectors as cs
from httpx import get, post
from polars import Schema
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",
)
def split_filter_part(filter_part): def split_filter_part(filter_part):
operators = [
["s<", "<"],
["s>", ">"],
["i<", "<"],
["i>", ">"],
["icontains", "contains"],
]
print("filter part", filter_part) print("filter part", filter_part)
for operator_group in operators: for operator_group in operators:
if operator_group[0] in filter_part: if operator_group[0] in filter_part:
@@ -13,7 +36,486 @@ def split_filter_part(filter_part):
name_part = name_part.strip() name_part = name_part.strip()
value = value_part.strip() value = value_part.strip()
name = name_part[name_part.find("{") + 1 : name_part.rfind("}")] name = name_part[name_part.find("{") + 1 : name_part.rfind("}")]
print("=>", name, operator_group[1], value)
return name, operator_group[1], value return name, operator_group[1], value
return [None] * 3 return [None] * 3
def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
dff = dff.with_columns(
(
'<a href="' + pl.col("sourceFile") + '">' + pl.col("sourceDataset") + "</a>"
).alias("sourceDataset")
)
dff = dff.drop(["sourceFile"])
return dff
def add_links(dff: pl.DataFrame, target: str = "_blank"):
for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
if col in dff.columns:
if col.startswith("titulaire_"):
dff = dff.with_columns(
pl.when(
pl.Expr.or_(
pl.col("titulaire_typeIdentifiant").is_null(),
pl.col("titulaire_typeIdentifiant") == "SIRET",
)
)
.then(
'<a href = "/titulaires/'
+ pl.col("titulaire_id")
+ f'" target="{target}">'
+ pl.col(col)
+ "</a>"
)
.otherwise(pl.col(col))
.alias(col)
)
if col.startswith("acheteur_"):
dff = dff.with_columns(
(
'<a href = "/acheteurs/'
+ pl.col("acheteur_id")
+ f'" target="{target}">'
+ pl.col(col)
+ "</a>"
).alias(col)
)
if col == "uid":
dff = dff.with_columns(
(
'<a href = "/marches/'
+ pl.col("uid")
+ f'" target="{target}">'
+ pl.col("uid")
+ "</a>"
).alias("uid")
)
return dff
def add_links_in_dict(data: list[dict], org_type: str) -> list:
new_data = []
for marche in data:
org_id = marche[org_type + "_id"]
marche[org_type + "_nom"] = (
f'<a href="/{org_type}s/{org_id}">{marche[org_type + "_nom"]}</a>'
)
if marche.get("uid"):
marche["id"] = f'<a href="/marches/{marche["uid"]}">{marche["id"]}</a>'
marche["uid"] = f'<a href="/marches/{marche["uid"]}">{marche["uid"]}</a>'
new_data.append(marche)
return new_data
def booleans_to_strings(lff: pl.LazyFrame) -> pl.LazyFrame:
"""
Convert all boolean columns to string type.
"""
lff = lff.with_columns(
pl.col(cs.Boolean)
.cast(pl.String)
.str.replace("true", "oui")
.str.replace("false", "non")
)
return lff
def numbers_to_strings(lff: pl.LazyFrame) -> pl.LazyFrame:
"""
Convert all numeric columns to string type.
"""
lff = lff.with_columns(pl.col(pl.Float64, pl.Int16).cast(pl.String).fill_null(""))
return lff
def dates_to_strings(lff: pl.LazyFrame, column: str) -> pl.LazyFrame:
"""
Convert a date column to string type.
"""
lff = lff.with_columns(pl.col(column).cast(pl.String).fill_null(""))
return lff
def format_number(number) -> str:
number = "{:,}".format(number).replace(",", " ")
return number
def format_values(dff: pl.DataFrame) -> pl.DataFrame:
def format_montant(expr, scale=None):
# https://stackoverflow.com/a/78636786
expr = expr.cast(pl.String)
expr = expr.str.splitn(".", 2)
num = expr.struct[0]
frac = expr.struct[1]
# Ajout des espaces
num = (
num.str.reverse()
.str.replace_all(r"\d{3}", "$0 ")
.str.reverse()
.str.replace(r"^ ", "")
)
frac: pl.Expr = (
pl.when(frac.is_not_null() & ~frac.is_in(["0"]))
.then("," + frac.str.head(2))
.otherwise(pl.lit(""))
)
montant: pl.Expr = (
pl.when((num + frac) == pl.lit(""))
.then(pl.lit(""))
.otherwise(num + frac + pl.lit(""))
)
return montant
def format_distance(expr):
expr = expr.cast(pl.String)
return pl.concat_str(expr, pl.lit(" km"))
if "montant" in dff.columns:
dff = dff.with_columns(pl.col("montant").pipe(format_montant).alias("montant"))
if "distance" in dff.columns:
dff = dff.with_columns(
pl.col("distance").pipe(format_distance).alias("distance")
)
return dff
def get_annuaire_data(siret: str) -> dict:
url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}"
response = get(url)
return response.json()["results"][0]
def get_decp_data() -> pl.DataFrame:
# Chargement du fichier parquet
# Le fichier est chargé en mémoire, ce qui est plus rapide qu'une base de données pour le moment.
# On utilise polars pour la rapidité et la facilité de manipulation des données.
try:
logger.info(
f"Lecture du fichier parquet ({os.getenv('DATA_FILE_PARQUET_PATH')})..."
)
lff: pl.LazyFrame = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
except ComputeError:
# Le fichier est probablement en cours de mise à jour
logger.info("Échec, nouvelle tentative dans 10s...")
sleep(10)
lff: pl.LazyFrame = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
# Tri des marchés par date de notification
lff = lff.sort(by=["dateNotification", "uid"], descending=True, nulls_last=True)
# Uniquement les données actuelles, pas les anciennes versions de marchés
lff = lff.filter(pl.col("donneesActuelles")).drop("donneesActuelles")
# Convertir les colonnes booléennes en chaînes de caractères
lff = booleans_to_strings(lff)
# Bizarrement je ne peux pas faire lff = lff.fill_null("") ici
# ça génère une erreur dans la page acheteur (acheteur_data.table) :
# AttributeError: partially initialized module 'pandas' has no attribute 'NaT' (most likely due to a circular import)
return lff.collect()
def get_org_data(dff: pl.DataFrame, org_type: str) -> pl.DataFrame:
lff = dff.lazy()
lff = lff.select(
"uid",
cs.starts_with(org_type).exclude(
f"{org_type}_latitude", f"{org_type}_longitude"
),
)
lff = lff.group_by(cs.starts_with(org_type)).len("Marchés")
return lff.collect()
def get_departements() -> dict:
with open("data/departements.json", "rb") as f:
data = json.load(f)
return data
def get_departement_region(code_postal):
if code_postal > "97000":
code_departement = code_postal[:3]
else:
code_departement = code_postal[:2]
nom_departement = departements[code_departement]["departement"]
nom_region = departements[code_departement]["region"]
return code_departement, nom_departement, nom_region
def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
debug = os.getenv("DEVELOPMENT", "False").lower() == "true"
schema = lff.collect_schema()
filtering_expressions = filter_query.split(" && ")
for filter_part in filtering_expressions:
col_name, operator, filter_value = split_filter_part(filter_part)
col_type = str(schema[col_name])
if debug:
print("filter_value:", filter_value)
print("filter_value_type:", type(filter_value))
print("operator:", operator)
print("col_type:", col_type)
lff = lff.filter(pl.col(col_name).is_not_null())
if col_type == "Date":
# Convertir la colonne date en chaînes de caractères
lff = dates_to_strings(lff, col_name)
col_type = "String"
if col_type == "String":
lff = lff.filter(pl.col(col_name) != pl.lit(""))
elif col_type.startswith("Int") or col_type.startswith("Float"):
try:
filter_value = int(filter_value)
except ValueError:
logger.error(f"Invalid numeric filter value: {filter_value}")
continue
if operator in ("contains", "<", "<=", ">", ">="):
if operator == "<":
lff = lff.filter(pl.col(col_name) < filter_value)
elif operator == ">":
lff = lff.filter(pl.col(col_name) > filter_value)
elif operator == ">=":
lff = lff.filter(pl.col(col_name) >= filter_value)
elif operator == "<=":
lff = lff.filter(pl.col(col_name) <= filter_value)
elif operator == "contains":
if col_type in ["String", "Date"]:
lff = lff.filter(
pl.col(col_name).str.contains("(?i)" + filter_value)
)
elif col_type.startswith("Int") or col_type.startswith("Float"):
lff = lff.filter(pl.col(col_name) == filter_value)
else:
logger.error(f"Invalid column type: {col_type}")
else:
logger.error(f"Invalid operator: {operator}")
# elif operator == 'datestartswith':
# lff = lff.filter(pl.col(col_name).str.startswith(filter_value)")
return lff
def sort_table_data(lff: pl.LazyFrame, sort_by: list) -> pl.LazyFrame:
lff = lff.sort(
[col["column_id"] for col in sort_by],
descending=[col["direction"] == "desc" for col in sort_by],
nulls_last=True,
)
print(sort_by)
return lff
def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tuple:
# Liste finale de colonnes
columns = []
tooltip = {}
for column_id in dff.columns:
if exclude and column_id in exclude:
continue
column_object = data_schema.get(column_id)
if column_object:
column_name = column_object.get("title", column_id)
else:
column_name = column_id
column = {
"name": column_name,
"id": column_id,
"presentation": "markdown",
"type": "text",
"format": {"nully": "N/A"},
"hideable": hideable,
}
columns.append(column)
if column_object:
tooltip[column_id] = {
"value": f"""**{column_object.get("title")}** ({column_id})
"""
+ column_object["description"],
"type": "markdown",
}
return columns, tooltip
def get_default_hidden_columns(schema: Schema):
displayed_columns = os.getenv("DISPLAYED_COLUMNS")
hidden_columns = []
if displayed_columns:
displayed_columns = displayed_columns.replace(" ", "").split(",")
for col in schema.names():
if col in displayed_columns:
continue
else:
hidden_columns.append(col)
return hidden_columns
raise ValueError("DISPLAYED_COLUMNS n'est pas configuré")
def get_data_schema() -> dict:
# Récupération du schéma des données tabulaires
path = os.getenv("DATA_SCHEMA_PATH")
if path.startswith("http"):
original_schema: dict = get(
os.getenv("DATA_SCHEMA_PATH"), follow_redirects=True
).json()
elif os.path.exists(path):
with open(path) as f:
original_schema: dict = json.load(f)
else:
raise Exception(f"Chemin vers le schéma invalide: {path}")
new_schema = {}
for col in original_schema["fields"]:
new_schema[col["name"]] = col
new_schema["sourceDataset"] = {
"description": "Code de la source des données, avec un lien vers le fichier Open Data dont proviennent les données de ce marché public.",
"title": "Source des données",
"short_name": "Source",
}
return new_schema
def track_search(query):
if (
len(query) >= 4
and os.getenv("DEVELOPMENT").lower != "true"
and os.getenv("MATOMO_DOMAIN")
):
if os.getenv("DEVELOPMENT").lower() == "true":
url = "https://test.decp.info"
else:
url = "https://decp.info"
params = {
"idsite": os.getenv("MATOMO_ID_SITE"),
"url": url,
"rec": "1",
"action_name": "front_page_search",
"rand": uuid.uuid4().hex,
"apiv": "1",
"h": localtime().tm_hour,
"m": localtime().tm_min,
"s": localtime().tm_sec,
"search": query,
"token_auth": os.getenv("MATOMO_TOKEN"),
}
post(
url=f"https://{os.getenv('MATOMO_DOMAIN')}/matomo.php",
params=params,
).raise_for_status()
def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
"""
Search in either 'acheteur' or 'titulaire' DataFrame.
:param dff: Polars DataFrame with acheteur or titulaire columns
:param query: User search string
:param org_type: 'acheteur' or 'titulaire'
:return: Filtered DataFrame with 'matches' column
"""
if not query.strip():
return dff.select(pl.lit(False).alias("matches"))
# Enregistrement des recherche dans Matomo
track_search(query)
# Normalize query
normalized_query = unidecode(query.strip()).upper()
tokens = [" " + t.strip() for t in normalized_query.split() if t.strip()]
# Define columns based on entity type
cols = [
f"{org_type}_id",
f"{org_type}_nom",
f"{org_type}_departement_nom",
f"{org_type}_departement_code",
f"{org_type}_commune_nom",
]
# Concatenate all fields into one string per row
org_str = pl.concat_str(pl.lit(" "), pl.col(cols), separator=" ").str.replace(
"-", " "
)
# For each token, create a boolean column: True if token is found
token_matches = []
for token in tokens:
token_match = org_str.str.contains(token).alias(f"token_{token}")
token_matches.append(token_match)
# Count how many tokens match per row
match_score = pl.sum_horizontal(token_matches).alias("match_score")
# For each token, create a boolean column: True if token is found
token_matches = []
for token in tokens:
token_match = org_str.str.contains(token).alias(f"token_{token}")
token_matches.append(token_match)
# Sélection des colonnes
if org_type == "acheteur":
dff = dff.select(cols + ["Marchés"])
if org_type == "titulaire":
dff = dff.select(cols + ["Marchés", "titulaire_typeIdentifiant"])
# Apply and filter
dff = (
dff.with_columns(token_matches + [match_score])
.filter(pl.col("match_score") == len(tokens))
.sort("Marchés", descending=True)
.drop([f"token_{token}" for token in tokens])
)
# Format result
dff = add_links(dff, target="")
dff = dff.with_columns(
pl.concat_str(
pl.col(f"{org_type}_departement_nom"),
pl.lit(" ("),
pl.col(f"{org_type}_departement_code"),
pl.lit(")"),
).alias("Département")
)
dff = dff.select(f"{org_type}_id", f"{org_type}_nom", "Département", "Marchés")
return dff
df: pl.DataFrame = get_decp_data()
df_acheteurs = get_org_data(df, "acheteur")
df_titulaires = get_org_data(df, "titulaire")
departements = get_departements()
domain_name = (
"test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"
)
meta_content = {
"image_url": f"https://{domain_name}/assets/decp.info.png",
"title": "decp.info - exploration des marchés publics français",
"description": (
"Explorez et analysez les données des marchés publics français avec cet outil libre et gratuit. "
"Pour une commande publique accessible à toutes et tous."
),
}
data_schema = get_data_schema()