Compare commits

...

154 Commits

Author SHA1 Message Date
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
22 changed files with 2608 additions and 132 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
+11
View File
@@ -1,3 +1,14 @@
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
# 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)
+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/>.
+43 -3
View File
@@ -1,5 +1,7 @@
# decp.info # decp.info
> v2.1.0
Outil d'exploration et de téléchargement des données essentielles de la commande publique. Outil d'exploration et de téléchargement des données essentielles de la commande publique.
=> [decp.info](https://decp.info) => [decp.info](https://decp.info)
@@ -10,16 +12,54 @@ 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.
## 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.1.0
- 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"
}
}
+8 -3
View File
@@ -1,17 +1,22 @@
[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.1.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
] ]
[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)
+84 -12
View File
@@ -1,21 +1,102 @@
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( 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 +105,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)
+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: /
+192 -16
View File
@@ -1,15 +1,54 @@
/* 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 */
@@ -18,12 +57,15 @@ td[data-dash-column="objet"] {
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: sans-serif;
text-align: left;
} }
.dash-table-container .dash-table-container
@@ -33,8 +75,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: 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: sans-serif;
}
.marches_table .cell-table tr:nth-child(even) td {
background-color: #feeeee; background-color: #feeeee;
font-family: sans-serif;
} }
#header > *, #header > *,
@@ -42,10 +142,14 @@ td[data-dash-column="objet"] {
margin: 0 0 20px 20px; margin: 0 0 20px 20px;
} }
/* 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 +158,83 @@ 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 */
.wrapper {
display: grid;
grid-gap: 10px;
margin-bottom: 50px;
justify-content: space-between;
}
.wrapper > div {
}
.org_title {
grid-column: 1 / 3;
grid-row: 1;
}
.org_year {
grid-column: 3;
grid-row: 1;
}
.org_infos {
grid-column: 1;
grid-row: 2;
}
.org_infos > p {
margin: 8px 0;
}
.org_stats {
grid-column: 2;
grid-row: 2;
}
.org_map {
grid-column: 3;
grid-row: 2;
}
/* Vue marché */
.marche_infos p {
margin-top: 6px;
margin-bottom: 6px;
}
.marche_title {
grid-column: 1/3;
grid-row: 1;
}
.marche_map {
grid-column: 3;
grid-row: 2;
width: 400px;
}
.marche_infos_1 {
grid-column: 1;
grid-row: 2;
}
.marche_infos_2 {
grid-column: 2;
grid-row: 2;
}
+192
View File
@@ -0,0 +1,192 @@
import json
import plotly.express as px
import polars as pl
from dash import dash_table, dcc, html
def get_map_count_marches(lf: pl.LazyFrame):
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()
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=1000,
height=800,
)
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(lf: pl.LazyFrame, type_date: str):
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()
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
+91 -4
View File
@@ -1,9 +1,96 @@
from dash import register_page, html import os
title = "À propos" from dash import dcc, html, register_page
from src.figures import get_sources_tables
from src.utils import meta_content
name = "À propos"
register_page( register_page(
__name__, path="/a-propos", title=f"decp.info - {title}", name=title, order=3 __name__,
path="/a-propos",
title=meta_content["title"],
name=name,
description=meta_content["description"],
image_url=meta_content["image_url"],
order=5,
) )
layout = [html.H2(title)] 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("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](/contact) et on discute !
#### Pour explorer le projet
- ✉️ [inscription à la liste de diffusion](https://6254d9a3.sibforms.com/serve/MUIFAEonUVkoSVrdgey18CTgLyI16xw4yeu-M-YOUzhWE_AgfQfbgkyT7GvA_RYLro9MfuRqkzQxSvu7-uzbMSv2a2ZQPsliM7wtiiqIL8kR2zOvl6m11fb5qjcOxMAYsLiY_YBi3P7NY95CTJ8vRY4CpsDclF2iLooOElKkTgIgi5nePe7zAIrgiYM5v2EuALlGJZMEG9vBP-Cu) (annonces des mises à jour et évènements, maximum une fois par mois)
- 💾 [données consolidées en Open Data](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire/)
- 🗞️ [mon blog](https://colin.maudry.com)
- 📔 [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("Sources de données", id="sources"),
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
html.H4("Mentions légales", id="mentions-legales"),
dcc.Markdown("""
##### Publication
Site Web développé et édité par [SAS Colmo](https://annuaire-entreprises.data.gouv.fr/entreprise/colmo-989393350), 989 393 350 RCS Rennes au capital de 3 000 euros.
Siège social : 1 carrefour Jouaust, 35000 Rennes
Hébergement : serveur situé en France et administré par Scaleway, 8 rue de la Ville 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"
# ),
],
)
]
+275
View File
@@ -0,0 +1,275 @@
import datetime
import polars as pl
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
from src.figures import point_on_map
from src.utils import (
add_links_in_dict,
format_montant,
format_number,
get_annuaire_data,
get_departement_region,
lf,
meta_content,
setup_table_columns,
)
register_page(
__name__,
path_template="/acheteurs/<acheteur_id>",
title=meta_content["title"],
name="Acheteur",
description=meta_content["description"],
image_url=meta_content["image_url"],
order=5,
)
layout = [
dcc.Store(id="acheteur_data", storage_type="memory"),
dcc.Location(id="url", refresh="callback-nav"),
html.Div(
className="container",
children=[
html.Div(
className="wrapper",
children=[
html.H2(
className="org_title",
children=[
html.Span(id="acheteur_siret"),
" - ",
html.Span(id="acheteur_nom"),
],
),
html.Div(
className="org_year",
children=dcc.Dropdown(
id="acheteur_year",
options=["Toutes"]
+ [
str(year)
for year in range(
2018, int(datetime.date.today().year) + 1
)
],
placeholder="Année",
),
),
html.Div(
className="org_infos",
children=[
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
html.P(["Commune : ", html.Strong(id="acheteur_commune")]),
html.P(
[
"Département : ",
html.Strong(id="acheteur_departement"),
]
),
html.P(["Région : ", html.Strong(id="acheteur_region")]),
html.A(
id="acheteur_lien_annuaire",
children="Plus de détails sur l'Annuaire des entreprises",
target="_blank",
),
],
),
html.Div(
className="org_stats",
children=[
html.P(id="acheteur_titre_stats"),
html.P(id="acheteur_marches_attribues"),
html.P(id="acheteur_titulaires_differents"),
html.Button(
"Téléchargement au format Excel",
id="btn-download-acheteur-data",
),
dcc.Download(id="download-acheteur-data"),
],
),
html.Div(className="org_map", id="acheteur_map"),
],
),
# récupérer les données de l'acheteur sur l'api annuaire
html.H3("Derniers marchés publics attribués"),
html.Div(id="acheteur_last_marches", children=""),
],
),
]
@callback(
Output(component_id="acheteur_siret", component_property="children"),
Output(component_id="acheteur_nom", component_property="children"),
Output(component_id="acheteur_commune", component_property="children"),
Output(component_id="acheteur_map", component_property="children"),
Output(component_id="acheteur_departement", component_property="children"),
Output(component_id="acheteur_region", component_property="children"),
Output(component_id="acheteur_lien_annuaire", component_property="href"),
Input(component_id="url", component_property="pathname"),
)
def update_acheteur_infos(url):
acheteur_siret = url.split("/")[-1]
if len(acheteur_siret) != 14:
acheteur_siret = (
f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})"
)
data = get_annuaire_data(acheteur_siret)
data_etablissement = data["matching_etablissements"][0]
acheteur_map = point_on_map(
data_etablissement["latitude"], data_etablissement["longitude"]
)
code_departement, nom_departement, nom_region = get_departement_region(
data_etablissement["code_postal"]
)
departement = f"{nom_departement} ({code_departement})"
lien_annuaire = (
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
)
return (
acheteur_siret,
data["nom_raison_sociale"],
data_etablissement["libelle_commune"],
acheteur_map,
departement,
nom_region,
lien_annuaire,
)
@callback(
Output(component_id="acheteur_marches_attribues", component_property="children"),
Output(
component_id="acheteur_titulaires_differents", component_property="children"
),
Input(component_id="acheteur_data", component_property="data"),
)
def update_acheteur_stats(data):
df = pl.DataFrame(data)
if df.height == 0:
df = pl.DataFrame(schema=lf.collect_schema())
df_marches = df.unique("id")
nb_marches = format_number(df_marches.height)
# somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item()))
marches_attribues = [html.Strong(nb_marches), " marchés et accord-cadres attribués"]
# + ", pour un total de ", html.Strong(somme_marches + " €")]
del df_marches
nb_titulaires = df.unique("titulaire_id").height
nb_titulaires = [
html.Strong(format_number(nb_titulaires)),
" titulaires (SIRET) différents",
]
del df
return marches_attribues, nb_titulaires
@callback(
Output(component_id="acheteur_data", component_property="data"),
Input(component_id="url", component_property="pathname"),
Input(component_id="acheteur_year", component_property="value"),
)
def get_acheteur_marches_data(url, acheteur_year: str) -> pl.LazyFrame:
acheteur_siret = url.split("/")[-1]
lff = lf.filter(pl.col("acheteur_id") == acheteur_siret)
lff = lff.fill_null("")
lff = lff.select(
"id",
"uid",
"objet",
"dateNotification",
"titulaire_id",
"titulaire_typeIdentifiant",
"titulaire_nom",
"montant",
"codeCPV",
"dureeMois",
)
if acheteur_year and acheteur_year != "Toutes":
lff = lff.filter(
pl.col("dateNotification").cast(pl.String).str.starts_with(acheteur_year)
)
lff = lff.sort(["dateNotification", "id"], descending=True, nulls_last=True)
data = lff.collect(engine="streaming").to_dicts()
return data
@callback(
Output(component_id="acheteur_last_marches", component_property="children"),
Input(component_id="acheteur_data", component_property="data"),
)
def get_last_marches_table(data) -> html.Div:
dff = pl.DataFrame(data)
dff = format_montant(dff)
columns, tooltip = setup_table_columns(
dff,
hideable=False,
exclude=["titulaire_id", "titulaire_typeIdentifiant", "uid"],
)
data = dff.to_dicts()
data = add_links_in_dict(data, "titulaire")
table = html.Div(
className="marches_table",
id="acheteur_datatable",
children=dash_table.DataTable(
data=data,
markdown_options={"html": True},
page_action="native",
filter_action="native",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
columns=columns,
tooltip_header=tooltip,
tooltip_duration=8000,
tooltip_delay=350,
cell_selectable=False,
page_size=10,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "300px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "200px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
),
)
return table
@callback(
Output("download-acheteur-data", "data"),
Input("btn-download-acheteur-data", "n_clicks"),
State(component_id="acheteur_data", component_property="data"),
State(component_id="acheteur_nom", component_property="children"),
State(component_id="acheteur_year", component_property="value"),
prevent_initial_call=True,
)
def download_acheteur_data(
n_clicks,
data: [dict],
acheteur_nom: str,
annee: str,
):
df_to_download = pl.DataFrame(data)
def to_bytes(buffer):
df_to_download.write_excel(
buffer, worksheet="DECP" if annee in ["Toutes", None] else annee
)
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{acheteur_nom}_{date}.xlsx")
+170
View File
@@ -0,0 +1,170 @@
import os
import re
import smtplib
import time
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from dash import Input, Output, State, callback, dcc, html, register_page
from flask import request
from src.utils import meta_content
name = "Contact"
register_page(
__name__,
path="/contact",
title=meta_content["title"],
name=name,
description=meta_content["description"],
image_url=meta_content["image_url"],
order=6,
)
layout = html.Div(
className="container",
children=[
html.H2("Contact", id="contact"),
html.P(
"Votre message arrivera directement dans ma boîte mail, et je reviendrai vers vous rapidement."
),
html.Div(
[
html.Label("Votre nom"),
dcc.Input(
id="input-name",
type="text",
style={"width": "100%", "padding": "8px", "margin": "8px 0"},
),
html.Label("Votre adresse email"),
dcc.Input(
id="input-email",
type="email",
style={"width": "100%", "padding": "8px", "margin": "8px 0"},
),
html.Label("Message"),
dcc.Textarea(
id="input-message",
style={
"width": "100%",
"height": 200,
"padding": "8px",
"margin": "8px 0",
},
),
html.Button(
"Envoyer ",
id="submit-button",
n_clicks=0,
style={"marginTop": "10px"},
),
html.Div(
id="output-message", style={"marginTop": "10px", "color": "green"}
),
],
style={
"maxWidth": "600px",
"margin": "auto",
"padding": "20px",
"lineHeight": "20px",
},
),
dcc.Markdown("""
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
- venez discuter de la transparence de la commande publique [sur le forum teamopendata.org](https://teamopendata.org/c/commande-publique/101)
"""),
],
)
rate_limit_store = {} # {ip: last_timestamp}
RATE_LIMIT_WINDOW = 300 # 5 minutes
def is_rate_limited():
ip = request.remote_addr
now = time.time()
last_time = rate_limit_store.get(ip)
if last_time and (now - last_time) < RATE_LIMIT_WINDOW:
return True # rate limited !
rate_limit_store[ip] = now # màj du timestamp
return False
def sanitize_email(email):
return re.match(r"[^@]+@[^@]+\.[^@]+", email)
@callback(
Output("output-message", "children"),
Input("submit-button", "n_clicks"),
State("input-name", "value"),
State("input-email", "value"),
State("input-message", "value"),
prevent_initial_call=True,
)
def send_email(n_clicks, form_name, form_email, form_message):
if not all([form_name, form_email, form_message]):
return html.Div(
"Veuillez s'il vous plaît remplir tous les champs.", style={"color": "red"}
)
client_ip = request.remote_addr
if is_rate_limited():
wait_time = int(RATE_LIMIT_WINDOW - (time.time() - rate_limit_store[client_ip]))
return html.Div(
f"⏳ J'ai mis en place une protection contre le spam, et vous m'avez écrit il y a moins de 5 minutes. "
f"Veuillez s'il vous plaît attendre {wait_time} secondes avant de renvoyer un message.",
style={"color": "black"},
)
try:
# Configuration du serveur SMTP
smtp_server = os.getenv("SENDER_SERVER_DOMAIN")
smtp_port = 587
login_email = os.getenv("LOGIN_EMAIL")
from_email = os.getenv("FROM_EMAIL")
to_email = os.getenv("TO_EMAIL", from_email)
login_password = os.getenv("LOGIN_PASSWORD")
print(
smtp_server,
smtp_port,
login_email,
from_email,
to_email,
login_password,
sep="\n",
)
# Création de l'email
email = MIMEMultipart()
email["From"] = from_email
email["To"] = to_email
email["Subject"] = f"[decp.info] Message de {form_name}"
body = f"""
Nom : {form_name}
Email : {form_email}
Message :
{form_message}
"""
email.attach(MIMEText(body, "plain"))
# Send email
server = smtplib.SMTP(smtp_server, smtp_port)
server.starttls()
server.login(login_email, login_password)
server.sendmail(from_email, to_email, email.as_string())
server.quit()
return html.Div("✅ Envoi réussi", style={"color": "green"})
except Exception as e:
print(e)
return html.Div(
f"❌ Échec de l'envoi du message : {str(e)}", style={"color": "red"}
)
+181 -55
View File
@@ -1,17 +1,40 @@
from dash import html, dcc, dash_table, register_page, Input, Output, State, callback
from dotenv import load_dotenv
import os import os
from datetime import datetime
import polars as pl import polars as pl
from src.utils import split_filter_part from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
load_dotenv() from src.utils import (
add_links,
add_resource_link,
filter_table_data,
format_montant,
format_number,
lf,
meta_content,
setup_table_columns,
sort_table_data,
)
df = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH")) update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y")
title = "Tableau" schema = lf.collect_schema()
register_page(__name__, path="/", title=f"decp.info - {title}", name=title, order=1)
datatable = dash_table.DataTable( name = "Tableau"
register_page(
__name__,
path="/",
title=meta_content["title"],
name=name,
description=meta_content["description"],
image_url=meta_content["image_url"],
order=1,
)
datatable = html.Div(
className="marches_table",
children=dash_table.DataTable(
cell_selectable=False, cell_selectable=False,
id="table", id="table",
page_size=20, page_size=20,
@@ -19,14 +42,10 @@ datatable = dash_table.DataTable(
page_action="custom", page_action="custom",
filter_action="custom", filter_action="custom",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."}, filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
columns=[{"name": i, "id": i} for i in df.collect_schema().names()], sort_action="custom",
selected_columns=[], sort_mode="multi",
selected_rows=[], sort_by=[],
# sort_action="native", row_deletable=False,
# sort_mode="multi",
export_format="xlsx",
export_columns="visible",
export_headers="ids",
style_cell_conditional=[ style_cell_conditional=[
{ {
"if": {"column_id": "objet"}, "if": {"column_id": "objet"},
@@ -36,32 +55,68 @@ datatable = dash_table.DataTable(
"lineHeight": "14px", "lineHeight": "14px",
"whiteSpace": "normal", "whiteSpace": "normal",
}, },
{
"if": {"column_id": "acheteur_nom"},
"minWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
], ],
data_timestamp=0, data_timestamp=0,
markdown_options={"html": True},
tooltip_duration=8000,
tooltip_delay=350,
),
) )
layout = [ layout = [
html.Div( html.Div(
html.Details( html.Details(
children=[ children=[
html.Summary(html.H3("Utilisation")), html.Summary(
html.H3("Mode d'emploi", style={"text-decoration": "underline"}),
),
dcc.Markdown( dcc.Markdown(
""" """
##### Définition des colonnes
**Filtres** 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`. 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 textuels : la recherche est insensible à la casse (majuscules/minuscules) et retourne les valeurs qui contiennent
- Champs numériques : possibilité d'ajouter < ou > devant le chiffre recherché pour chercher des valeurs inférieures ou supérieur. 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"
**Télécharger le résultat** Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page.
Vous pouvez télécharger le résultat de vos filtres et tris en cliquant sur Télécharger au format Excel. ##### Tri
Les colonnes supprimées seront absentes du fichier téléchargé. 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.)
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.
""" """
), ),
], ],
@@ -77,56 +132,127 @@ layout = [
# )]), # )]),
dcc.Loading( dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"}, overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-1", id="loading-home",
type="default", type="default",
children=datatable, 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( @callback(
Output("table", "data"), Output("table", "data"),
Output("table", "columns"),
Output("table", "tooltip_header"),
Output("table", "data_timestamp"), 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_current"),
Input("table", "page_size"), Input("table", "page_size"),
Input("table", "filter_query"), Input("table", "filter_query"),
Input("table", "sort_by"),
State("table", "data_timestamp"), State("table", "data_timestamp"),
) )
def update_table(page_current, page_size, filter_query, data_timestamp): def update_table(page_current, page_size, filter_query, sort_by, data_timestamp):
print(" + + + + + + + + + + + + + + + + + + ") print(" + + + + + + + + + + + + + + + + + + ")
print("Filter query:", filter_query)
# 1. Apply Filters # Application des filtres
dff = df # start from the original data lff: pl.LazyFrame = lf # start from the original data
if filter_query: if filter_query:
filtering_expressions = filter_query.split(" && ") lff = filter_table_data(lff, filter_query)
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 ("<", "<=", ">", ">="): if len(sort_by) > 0:
filter_value = int(filter_value) lff = sort_table_data(lff, sort_by)
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": # Remplace les strings null par "", mais pas les numeric null
dff = dff.filter(pl.col(col_name).str.contains("(?i)" + filter_value)) lff = lff.fill_null("")
# elif operator == 'datestartswith':
# dff = dff.filter(pl.col(col_name).str.startswith(filter_value)")
# 2. Paginate Data # Matérialisation des filtres
dff: pl.DataFrame = lff.collect()
height = dff.height
nb_rows = f"{format_number(height)} lignes"
# Pagination des données
start_row = page_current * page_size start_row = page_current * page_size
# end_row = (page_current + 1) * page_size # end_row = (page_current + 1) * page_size
dff = dff.slice(start_row, page_size)
dff = dff.slice(start_row, page_size).collect() # Ajout des liens vers l'annuaire des entreprises
# print("dff_sliced:", dff.select("titulaire.typeId")) dff = add_links(dff)
dff = dff.to_dicts()
return dff, data_timestamp + 1 # update data, update timestamp # Ajout des liens vers les fichiers Open Data
dff = add_resource_link(dff)
# Formatage des montants
dff = format_montant(dff)
columns, tooltip = setup_table_columns(dff)
dicts = dff.to_dicts()
if height > 65000:
download_disabled = True
download_text = "Téléchargement désactivé au-delà de 65 000 lignes"
download_title = "Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul. Contactez-moi pour me présenter votre besoin en téléchargement afin que je puisse adapter la solution."
else:
download_disabled = False
download_text = "Télécharger au format Excel"
download_title = ""
return (
dicts,
columns,
tooltip,
data_timestamp + 1,
nb_rows,
download_disabled,
download_text,
download_title,
)
@callback(
Output("download-data", "data"),
Input("btn-download-data", "n_clicks"),
State("table", "filter_query"),
State("table", "sort_by"),
State("table", "hidden_columns"),
prevent_initial_call=True,
)
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
lff: pl.LazyFrame = lf # 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")
+188
View File
@@ -0,0 +1,188 @@
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, format_montant, lf, 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):
marche_uid = url.split("/")[-1]
# Récupération des données du marché à partir du lf global
lff = lf.filter(pl.col("uid") == pl.lit(marche_uid))
# Données des titulaires du marché
dff_titulaires = lff.select(cs.starts_with("titulaire")).collect(engine="streaming")
# Données du marché
dff_marche = (
lff.select(~cs.starts_with("titulaires")).unique().collect(engine="streaming")
)
dff_marche = format_montant(dff_marche)
assert dff_marche.height == 1
return dff_marche.to_dicts()[0], dff_titulaires.to_dicts()
@callback(
Output("marche_infos_1", "children"),
Output("marche_infos_2", "children"),
Output("marche_infos_titulaires", "children"),
Input("marche_data", "data"),
Input("titulaires_data", "data"),
)
def update_marche_info(marche, titulaires):
def make_parameter(col):
column_object = data_schema.get(col)
column_name = column_object.get("friendly_name") if column_object else col
if marche[col]:
if col == "acheteur_nom":
value = html.A(
href=f"/acheteurs/{marche['acheteur_id']}",
children=marche["acheteur_nom"],
)
elif col == "sourceDataset":
value = html.A(
href=marche["sourceFile"], children=marche["sourceDataset"]
)
column_name = "Source des données"
# Dates
elif col in ["dateNotification", "datePublicationDonnees"]:
print(marche[col])
value = datetime.fromisoformat(marche[col]).strftime("%d/%m/%Y")
# Listes
elif (
col
in [
"techniques",
"typesPrix",
"considerationsSociales",
"considerationsEnvironnementales",
]
and "," in marche[col]
):
col_values = marche[col].split(", ")
lines = []
for val in col_values:
lines.append(html.Li(val))
_content = html.Div(
[html.P([column_name, " : "]), html.Ul(children=lines)]
)
return _content
else:
value = marche.get(col)
else:
value = ""
param_content = html.P([column_name, " : ", html.Strong(value)])
return param_content
marche_infos = [
make_parameter("id"),
make_parameter("objet"),
make_parameter("dateNotification"), # date
make_parameter("nature"),
make_parameter("acheteur_nom"), # lien
make_parameter("montant"),
make_parameter("codeCPV"),
make_parameter("procedure"),
make_parameter("techniques"), # list
make_parameter("dureeMois"),
make_parameter("offresRecues"),
make_parameter("datePublicationDonnees"), # date
make_parameter("formePrix"),
make_parameter("typesPrix"), # list
make_parameter("attributionAvance"),
make_parameter("tauxAvance"),
make_parameter("marcheInnovant"), # label
make_parameter("modalitesExecution"),
make_parameter("considerationsSociales"), # list
make_parameter("considerationsEnvironnementales"), # list
make_parameter("ccag"),
make_parameter("sousTraitanceDeclaree"),
make_parameter("typeGroupementOperateurs"),
make_parameter("origineFrance"),
make_parameter("origineUE"),
make_parameter("idAccordCadre"),
make_parameter("sourceDataset"), # lien
]
half = round(len(marche_infos) / 2)
# pas inclus pour l'instant : lieu d'exécution, modifications
titulaires_lines = []
for titulaire in titulaires:
if titulaire["titulaire_typeIdentifiant"] == "SIRET":
content = html.Li(
html.A(
href=f"/titulaires/{titulaire['titulaire_id']}",
children=titulaire["titulaire_nom"],
)
)
else:
content = html.Li(titulaire["titulaire_nom"])
titulaires_lines.append(content)
return marche_infos[:half], marche_infos[half:], titulaires_lines
-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 lf, 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(lf)),
dcc.Graph(
figure=get_barchart_sources(lf, "dateNotification")
),
dcc.Graph(
figure=get_barchart_sources(
lf, "datePublicationDonnees"
)
),
],
)
],
),
],
)
]
+289
View File
@@ -0,0 +1,289 @@
import datetime
import polars as pl
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
from src.figures import point_on_map
from src.utils import (
add_links_in_dict,
format_montant,
format_number,
get_annuaire_data,
get_departement_region,
lf,
meta_content,
setup_table_columns,
)
register_page(
__name__,
path_template="/titulaires/<titulaire_id>",
title=meta_content["title"],
name="Titulaire",
description=meta_content["description"],
image_url=meta_content["image_url"],
order=5,
)
# 21690123100011
layout = [
dcc.Store(id="titulaire_data", storage_type="memory"),
dcc.Location(id="url", refresh="callback-nav"),
html.Div(
className="container",
children=[
html.Div(
className="wrapper",
children=[
html.H2(
className="org_title",
children=[
html.Span(id="titulaire_siret"),
" - ",
html.Span(id="titulaire_nom"),
],
),
html.Div(
className="org_year",
children=dcc.Dropdown(
id="titulaire_year",
options=["Toutes"]
+ [
str(year)
for year in range(
2018, int(datetime.date.today().year) + 1
)
],
placeholder="Année",
),
),
html.Div(
className="org_infos",
children=[
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
html.P(["Commune : ", html.Strong(id="titulaire_commune")]),
html.P(
[
"Département : ",
html.Strong(id="titulaire_departement"),
]
),
html.P(["Région : ", html.Strong(id="titulaire_region")]),
html.A(
id="titulaire_lien_annuaire",
children="Plus de détails sur l'Annuaire des entreprises",
target="_blank",
),
],
),
html.Div(
className="org_stats",
children=[
html.P(id="titulaire_titre_stats"),
html.P(id="titulaire_marches_remportes"),
html.P(id="titulaire_acheteurs_differents"),
html.Button(
"Téléchargement au format Excel",
id="btn-download-titulaire-data",
),
dcc.Download(id="download-titulaire-data"),
],
),
html.Div(className="org_map", id="titulaire_map"),
],
),
# récupérer les données de l'acheteur sur l'api annuaire
html.H3("Derniers marchés publics remportés"),
html.Div(id="titulaire_last_marches", children=""),
],
),
]
@callback(
Output(component_id="titulaire_siret", component_property="children"),
Output(component_id="titulaire_nom", component_property="children"),
Output(component_id="titulaire_commune", component_property="children"),
Output(component_id="titulaire_map", component_property="children"),
Output(component_id="titulaire_departement", component_property="children"),
Output(component_id="titulaire_region", component_property="children"),
Output(component_id="titulaire_lien_annuaire", component_property="href"),
Input(component_id="url", component_property="pathname"),
)
def update_titulaire_infos(url):
titulaire_siret = url.split("/")[-1]
if len(titulaire_siret) != 14:
titulaire_siret = (
f"Le SIRET renseigné doit faire 14 caractères ({titulaire_siret})"
)
data = get_annuaire_data(titulaire_siret)
data_etablissement = data["matching_etablissements"][0]
titulaire_map = point_on_map(
data_etablissement["latitude"], data_etablissement["longitude"]
)
code_departement, nom_departement, nom_region = get_departement_region(
data_etablissement["code_postal"]
)
departement = f"{nom_departement} ({code_departement})"
lien_annuaire = (
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
)
return (
titulaire_siret,
data["nom_raison_sociale"],
data_etablissement["libelle_commune"],
titulaire_map,
departement,
nom_region,
lien_annuaire,
)
@callback(
Output(component_id="titulaire_marches_remportes", component_property="children"),
Output(
component_id="titulaire_acheteurs_differents", component_property="children"
),
Input(component_id="titulaire_data", component_property="data"),
)
def update_titulaire_stats(data):
df = pl.DataFrame(data)
if df.height == 0:
df = pl.DataFrame(schema=lf.collect_schema())
df_marches = df.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 = df.unique("acheteur_id").height
nb_acheteurs = [
html.Strong(format_number(nb_acheteurs)),
" titulaires (SIRET) différents",
]
del df
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) -> pl.LazyFrame:
titulaire_siret = url.split("/")[-1]
lff = lf.filter(
(pl.col("titulaire_id") == titulaire_siret)
& (pl.col("titulaire_typeIdentifiant") == "SIRET")
)
lff = lff.fill_null("")
lff = lff.select(
"id",
"uid",
"objet",
"dateNotification",
"acheteur_id",
"acheteur_nom",
"montant",
"codeCPV",
"dureeMois",
)
if titulaire_year and titulaire_year != "Toutes":
lff = lff.filter(
pl.col("dateNotification").cast(pl.String).str.starts_with(titulaire_year)
)
lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True)
data = lff.collect(engine="streaming").to_dicts()
return data
@callback(
Output(component_id="titulaire_last_marches", component_property="children"),
Input(component_id="titulaire_data", component_property="data"),
)
def get_last_marches_table(data) -> html.Div:
columns = [
"uid",
"objet",
"dateNotification",
"acheteur_nom",
"montant",
"codeCPV",
"dureeMois",
]
dff = pl.DataFrame(data)
dff = format_montant(dff)
columns, tooltip = setup_table_columns(
dff, hideable=False, exclude=["acheteur_id", "id"]
)
data = dff.to_dicts()
# Idéalement on utiliserait add_org_links(), mais le résultat attendu
# est différent de home.py (Tableau)
data = add_links_in_dict(data, "acheteur")
table = html.Div(
className="marches_table",
id="titulaire_datatable",
children=dash_table.DataTable(
data=data,
markdown_options={"html": True},
page_action="native",
filter_action="native",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
columns=columns,
tooltip_header=tooltip,
tooltip_duration=8000,
tooltip_delay=350,
cell_selectable=False,
page_size=10,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "300px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_nom"},
"maxWidth": "400px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
),
)
return table
@callback(
Output("download-titulaire-data", "data"),
Input("btn-download-titulaire-data", "n_clicks"),
State(component_id="titulaire_data", component_property="data"),
State(component_id="titulaire_nom", component_property="children"),
State(component_id="titulaire_year", component_property="value"),
prevent_initial_call=True,
)
def download_titulaire_data(
n_clicks,
data: [dict],
titulaire_nom: str,
annee: str,
):
df_to_download = pl.DataFrame(data)
def to_bytes(buffer):
df_to_download.write_excel(
buffer, worksheet="DECP" if annee in ["Toutes", None] else annee
)
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{titulaire_nom}_{date}.xlsx")
+296
View File
@@ -1,9 +1,28 @@
import json
import logging
import os
from time import sleep
import polars as pl
import polars.selectors as cs
from httpx import get
from polars.exceptions import ComputeError
operators = [ operators = [
["s<", "<"], ["s<", "<"],
["s>", ">"], ["s>", ">"],
["i<", "<"],
["i>", ">"],
["icontains", "contains"], ["icontains", "contains"],
] ]
logger = logging.getLogger("decp.info")
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):
print("filter part", filter_part) print("filter part", filter_part)
@@ -13,7 +32,284 @@ 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("source")
)
dff = dff.drop(["sourceFile", "sourceDataset"])
return dff
def add_links(dff: pl.DataFrame):
dff = dff.with_columns(
pl.when(pl.col("titulaire_typeIdentifiant") == "SIRET")
.then(
'<a href = "/titulaires/'
+ pl.col("titulaire_id")
+ '">'
+ pl.col("titulaire_id")
+ "</a>"
)
.otherwise(pl.col("titulaire_id"))
.alias("titulaire_id")
)
for column, path in [("acheteur_id", "acheteurs"), ("uid", "marches")]:
dff = dff.with_columns(
(
f'<a href = "/{path}/'
+ pl.col(column)
+ '" target="_blank">'
+ pl.col(column)
+ "</a>"
).alias(column)
)
return dff
def add_links_in_dict(data: list, org_type: str) -> list:
new_data = []
for marche in data:
org_id = marche[org_type + "_id"]
marche[org_type + "_nom"] = (
f'<a href="/{org_type}s/{org_id}">{marche[org_type + "_nom"]}</a>'
)
marche["id"] = f'<a href="/marches/{marche["uid"]}">{marche["id"]}</a>'
marche["uid"] = f'<a href="/marches/{marche["uid"]}">{marche["uid"]}</a>'
new_data.append(marche)
return new_data
def booleans_to_strings(lff: pl.LazyFrame) -> pl.LazyFrame:
"""
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_montant(dff: pl.DataFrame) -> pl.DataFrame:
def format_function(expr, scale=None):
# https://stackoverflow.com/a/78636786
expr = expr.cast(pl.String).str.splitn(".", 2)
num = expr.struct[0]
frac = expr.struct[1]
# Ajout des espaces
num = (
num.str.reverse()
.str.replace_all(r"\d{3}", "$0 ")
.str.reverse()
.str.replace(r"^ ", "")
)
frac: pl.Expr = (
pl.when(frac.is_not_null() & ~frac.is_in(["0"]))
.then("," + frac)
.otherwise(pl.lit(""))
)
return num + frac + pl.lit("")
dff = dff.with_columns(pl.col("montant").pipe(format_function).alias("montant"))
return dff
def get_annuaire_data(siret: str) -> dict:
url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}"
response = get(url)
return response.json()["results"][0]
def get_decp_data() -> pl.LazyFrame:
# 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
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:
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])
print("filter_value:", filter_value)
print("filter_value_type:", type(filter_value))
print("col_type:", col_type)
if col_type == "Date":
# Convertir la colonne en chaînes de caractères
lff = dates_to_strings(lff, col_name)
if operator in ("<", "<=", ">", ">="):
lff = lff.filter(
pl.col(col_name).is_not_null() & (pl.col(col_name) != pl.lit(""))
)
if operator == "<":
lff = lff.filter(pl.col(col_name) < filter_value)
elif operator == ">":
lff = lff.filter(pl.col(col_name) > filter_value)
elif operator == ">=":
lff = lff.filter(pl.col(col_name) >= filter_value)
elif operator == "<=":
lff = lff.filter(pl.col(col_name) <= filter_value)
elif col_type.startswith("Int") or col_type.startswith("Float"):
try:
filter_value = int(filter_value)
except ValueError:
logger.error(f"Invalid numeric filter value: {filter_value}")
continue
lff = lff.filter(pl.col(col_name) == filter_value)
elif operator == "contains" and col_type in ["String", "Date"]:
lff = lff.filter(pl.col(col_name).str.contains("(?i)" + filter_value))
# elif operator == 'datestartswith':
# lff = lff.filter(pl.col(col_name).str.startswith(filter_value)")
return lff
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("friendly_name", 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("friendly_name")}** ({column_id})
"""
+ column_object["description"],
"type": "markdown",
}
return columns, tooltip
lf = get_decp_data()
departements = get_departements()
domain_name = (
"test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"
)
meta_content = {
"image_url": f"https://{domain_name}/assets/decp.info.png",
"title": "decp.info - exploration des marchés publics français",
"description": (
"Explorez et analysez les données des marchés publics français avec cet outil libre et gratuit. "
"Pour une commande publique accessible à toutes et tous."
),
}
# Récupération du schéma des données tabulaires
data_schema: dict = get(os.getenv("DATA_SCHEMA_PATH"), follow_redirects=True).json()
data_schema["source"] = {
"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.",
"friendly_name": "Source des données",
}