Compare commits
187 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f3ce6a467 | |||
| efd9bd4238 | |||
| 39844071ad | |||
| 5c64177c6a | |||
| ee91e7a91f | |||
| 392f862baa | |||
| 199476ec30 | |||
| c98a36f759 | |||
| 65e0f4c5f0 | |||
| bc7652f336 | |||
| 188383a882 | |||
| 7f3a0bcb21 | |||
| 3270abb7f6 | |||
| 2eb984a95f | |||
| 776126a481 | |||
| 59abf35a12 | |||
| a40eb64245 | |||
| acae1517cb | |||
| 9a9decde6e | |||
| a1ae962c73 | |||
| 0404aa0973 | |||
| effe01dedf | |||
| aaa70039d2 | |||
| 7dc5f09545 | |||
| 91e7f373ec | |||
| c7768ea8f1 | |||
| 816d0de324 | |||
| c2fab377d5 | |||
| d7cdf38cda | |||
| 85f0084b7b | |||
| 712dc5a7f8 | |||
| 84b94fd882 | |||
| 90e9bc3c8c | |||
| 442f1ded27 | |||
| e4dcc19e8b | |||
| 0b828c3d67 | |||
| bc930bf6c9 | |||
| 070e8c98b9 | |||
| 7c157d9d71 | |||
| 79d11b326e | |||
| 51c023a803 | |||
| 819f2e315f | |||
| 928f75f504 | |||
| 1d21cbb69d | |||
| ee2bae190d | |||
| ad6005259e | |||
| f01a991f4f | |||
| a44e1a37cb | |||
| 502f652791 | |||
| fdb38b3b9b | |||
| 4394f38e35 | |||
| ce9d23100d | |||
| 70c5b91e6d | |||
| 4895bfc5ac | |||
| d9db156626 | |||
| d00efcd500 | |||
| 41df3f6d8e | |||
| 0db814710b | |||
| c1955ac807 | |||
| 4081b369d9 | |||
| 8a63f72bee | |||
| cc05ac0d99 | |||
| 8d236ab8dc | |||
| 0e20b0012f | |||
| 5122bb7f3a | |||
| 4b8565dcf2 | |||
| 98b8699afb | |||
| 1c5868d75f | |||
| bfa181cefd | |||
| 944f207d9b | |||
| 8f335a52ea | |||
| e88992a470 | |||
| 9e5673c28f | |||
| 2fe92d8a14 | |||
| b88d1c9f93 | |||
| 59321fc80a | |||
| df04286a4a | |||
| 2fc319d1bc | |||
| 29428a8706 | |||
| dbc80ad94e | |||
| ec877669d4 | |||
| a41b70d5af | |||
| e41dbe772c | |||
| 5a3770027f | |||
| 5c90b5f350 | |||
| dd229a212f | |||
| 8bcf57ce83 | |||
| 0fea49109a | |||
| 8337f7cbf1 | |||
| 159d409237 | |||
| 1586f9b7e4 | |||
| 46dfc23d42 | |||
| 8c3642e5d6 | |||
| 0c99f037b5 | |||
| 929b836cb0 | |||
| 73a10e20a6 | |||
| 48da8cde5c | |||
| 36ea24278f | |||
| 000e4e89db | |||
| 3396986b2d | |||
| f3305d42cd | |||
| f56f5bfc94 | |||
| 5a9b585b44 | |||
| 89a419a8d2 | |||
| 90af1f6eb4 | |||
| e9db143f45 | |||
| 6a5b4aa2aa | |||
| 56b7a8a0a1 | |||
| 5bd519f9e5 | |||
| 5ca5fc300f | |||
| b93b290f97 | |||
| e8eba9ba66 | |||
| 41d3ca7539 | |||
| 636e0b059d | |||
| 87d657abf8 | |||
| 5a89f70d4c | |||
| 6e37e59c4c | |||
| 3f931313a6 | |||
| 815d25519f | |||
| cd45c364e6 | |||
| b423c01068 | |||
| 0729ebb23e | |||
| c682f16c40 | |||
| 94f9192760 | |||
| f490e099ea | |||
| 8a9f135662 | |||
| b12a5b72c2 | |||
| c2ab1f49de | |||
| 81526ae2b4 | |||
| e41351dfc1 | |||
| b854d77d2f | |||
| 3db5b804e4 | |||
| 89b0666e70 | |||
| 95bc323d63 | |||
| 8376295464 | |||
| 1e194f52a6 | |||
| ff1402d427 | |||
| 1ab5c639f0 | |||
| 637c35522f | |||
| 83744cea65 | |||
| 23932c8dd3 | |||
| 8e216124f1 | |||
| b0e440595a | |||
| 88f37c199a | |||
| 68b3072aa3 | |||
| 9f74aaea69 | |||
| 89f95904b8 | |||
| 8e7048490e | |||
| 59fb9e7b3a | |||
| 01e714dba5 | |||
| 859506c4aa | |||
| 4c6f38f41e | |||
| 1171708d9e | |||
| c66bf97572 | |||
| 804f6e7883 | |||
| 12d01bb3e0 | |||
| 9d37e0c59b | |||
| 5cd540a8d0 | |||
| 1717c118ab | |||
| 87f336a457 | |||
| 0e9597de54 | |||
| 972d7f55b2 | |||
| 17df388035 | |||
| 21f0be3265 | |||
| c8b2cc3683 | |||
| ad19744a47 | |||
| 59fe1df1e7 | |||
| 313a7ba230 | |||
| 5c9fd6b4ef | |||
| 74b122e75c | |||
| a9b37c1907 | |||
| 8ad69b4bae | |||
| 10ec2c6649 | |||
| fadb4b1930 | |||
| e595128c13 | |||
| f9b46eea36 | |||
| 5ed704a497 | |||
| 64001959c3 | |||
| ec8b109258 | |||
| cd25673a5f | |||
| 459984bcbf | |||
| a2f9b2dca0 | |||
| cd208ee57b | |||
| 5ec550373d | |||
| d1da7c321c | |||
| e4a670948d | |||
| 2034577eb2 |
@@ -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 }}
|
||||
@@ -0,0 +1,29 @@
|
||||
on:
|
||||
push:
|
||||
# Sequence of patterns matched against refs/tags
|
||||
tags:
|
||||
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
|
||||
|
||||
name: Auto-release d'un tag
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: auto-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@master
|
||||
- name: Get tag message
|
||||
run: echo "TAG_MESSAGE=`git show ${{ github.ref_name }} | grep '^\- '`" >> "$GITHUB_ENV"
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
|
||||
TAG_MESSAGE: ${{ env.TAG_MESSAGE }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref_name }}
|
||||
body: ${{ env.TAG_MESSAGE }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
+8
-11
@@ -2,23 +2,20 @@ repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: check-ast
|
||||
- id: check-case-conflict
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- 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
|
||||
rev: v2.5.1
|
||||
hooks:
|
||||
- id: prettier
|
||||
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
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432
|
||||
PORT=8050
|
||||
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)
|
||||
|
||||
@@ -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/>.
|
||||
@@ -1,5 +1,7 @@
|
||||
# decp.info
|
||||
|
||||
> v2.1.7
|
||||
|
||||
Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
||||
|
||||
=> [decp.info](https://decp.info)
|
||||
@@ -10,16 +12,88 @@ Outil d'exploration et de téléchargement des données essentielles de la comma
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install .
|
||||
|
||||
# Copie et personnalisation du .env
|
||||
cp template.env .env
|
||||
nano .env
|
||||
|
||||
# Pour la production
|
||||
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-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
|
||||
|
||||
### 2.0.0-alpha
|
||||
##### 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
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
@@ -1,17 +1,22 @@
|
||||
[project]
|
||||
name = "decp.info"
|
||||
description = ""
|
||||
version = "2.0.0"
|
||||
description = "Interface d'exploration et d'analyse des marchés publics français."
|
||||
version = "2.1.7"
|
||||
requires-python = ">= 3.10"
|
||||
authors = [
|
||||
{ name = "Colin Maudry", email = "colin+decp@maudry.com" }
|
||||
]
|
||||
dependencies = [
|
||||
"dash",
|
||||
"dash==3.2.0",
|
||||
"dash[compress]",
|
||||
"polars",
|
||||
"gunicorn",
|
||||
"dash-bootstrap-components",
|
||||
"python-dotenv",
|
||||
"xlsxwriter",
|
||||
"plotly[express]",
|
||||
"httpx",
|
||||
"pandas" # utilisé pour la création de certains graphiques
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from os import getenv
|
||||
from src.app import app
|
||||
|
||||
# To use `gunicorn run:server` (prod)
|
||||
|
||||
+90
-12
@@ -1,21 +1,108 @@
|
||||
from dash import Dash, html, page_container, page_registry, dcc
|
||||
import logging
|
||||
|
||||
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(
|
||||
[
|
||||
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(
|
||||
"ℹ️ Le formulaire de contact, défectueux, a été remplacé par [une adresse email](/contact)."
|
||||
),
|
||||
),
|
||||
html.Div(
|
||||
[
|
||||
dcc.Link(
|
||||
page["name"], href=page["relative_path"], className="nav"
|
||||
)
|
||||
for page in page_registry.values()
|
||||
if page["name"] not in ["Acheteur", "Titulaire", "Marché"]
|
||||
]
|
||||
),
|
||||
],
|
||||
@@ -24,15 +111,6 @@ app.layout = html.Div(
|
||||
page_container,
|
||||
]
|
||||
)
|
||||
# @callback(
|
||||
# Output(component_id="table", component_property="data", allow_duplicate=True),
|
||||
# Input(component_id="search", component_property="value"),
|
||||
# prevent_initial_call=True,
|
||||
# )
|
||||
# def global_search(text):
|
||||
# new_df = df
|
||||
# new_df = new_df.filter(pl.col("objet").str.contains("(?i)" + text))
|
||||
# return new_df.to_dicts()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 333 KiB |
@@ -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: /
|
||||
+199
-16
@@ -1,15 +1,54 @@
|
||||
/* Change le texte du bout d'export */
|
||||
button.export {
|
||||
font-size: 0;
|
||||
}
|
||||
button.export:before {
|
||||
content: "Télécharger au format Excel";
|
||||
font-size: 14px;
|
||||
/* Change la marge bout d'export */
|
||||
.table-menu {
|
||||
font-size: 16px;
|
||||
margin: 12px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
/* Masquer le bouton de configuration de la casse dans les filtres */
|
||||
input.dash-filter--case {
|
||||
display: none;
|
||||
.table-menu > * {
|
||||
margin: 8px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
#source_table p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#source_table {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
#instructions {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
details > div {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
/* Logo et version */
|
||||
|
||||
div.logo {
|
||||
width: 230px;
|
||||
}
|
||||
|
||||
p.version {
|
||||
float: left;
|
||||
margin-top: 21px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
p.version > a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
div.logo h1 {
|
||||
float: left;
|
||||
}
|
||||
|
||||
div.logo > a {
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
|
||||
/* Réduire la taille du texte de la colonne Objet */
|
||||
@@ -18,12 +57,15 @@ td[data-dash-column="objet"] {
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
/* Couleur des en-têtes */
|
||||
.dash-table-container
|
||||
.dash-spreadsheet-container
|
||||
.dash-spreadsheet-inner
|
||||
th.dash-header {
|
||||
background-color: #b33821;
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dash-table-container
|
||||
@@ -33,8 +75,66 @@ td[data-dash-column="objet"] {
|
||||
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;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
#header > *,
|
||||
@@ -42,10 +142,21 @@ td[data-dash-column="objet"] {
|
||||
margin: 0 0 20px 20px;
|
||||
}
|
||||
|
||||
/* Annonces */
|
||||
#announcements {
|
||||
margin-top: 25px;
|
||||
max-width: 30%;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
/* Menu de navigation */
|
||||
|
||||
.navbar {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
padding: 10px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-bottom: solid 1px black;
|
||||
}
|
||||
|
||||
a.nav {
|
||||
@@ -54,11 +165,83 @@ a.nav {
|
||||
font-size: 120%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
float: left;
|
||||
width: 50%;
|
||||
h3 {
|
||||
margin: 36px 0 24px 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
summary > h3 {
|
||||
margin: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
+194
@@ -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
|
||||
@@ -0,0 +1,109 @@
|
||||
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("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("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 l’Evêque, 75008 Paris
|
||||
|
||||
##### Suivi d'audience
|
||||
|
||||
Ce site dépose un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le consultez ([Wikipédia](https://fr.wikipedia.org/wiki/Cookie_(informatique))). Cela me permet de mesurer le nombre de visites, de distinguer les nouveaux visiteurs des utilisateurs réguliers et ainsi de communiquer sur l'impact de decp.info.
|
||||
|
||||
**Ce site n’affiche pas de bannière de consentement aux cookies, pourquoi ?**
|
||||
|
||||
C’est vrai, vous n’avez pas eu à cliquer sur un bloc qui recouvre la moitié de la page pour dire que vous êtes d’accord avec le dépôt de cookies.
|
||||
|
||||
Rien d’exceptionnel, je respecte simplement la loi, qui dit que certains outils de suivi d’audience, correctement configurés pour respecter la vie privée, sont exemptés d’autorisation préalable.
|
||||
|
||||
J’utilise pour cela [Matomo](https://matomo.org/), un outil [libre](https://matomo.org/free-software/), paramétré pour être en conformité avec [la recommandation « Cookies »](https://www.cnil.fr/fr/solutions-pour-les-cookies-de-mesure-daudience) de la CNIL. Cela signifie que votre adresse IP, par exemple, est anonymisée avant d’être enregistrée. Il m’est donc impossible d’associer vos visites sur ce site à votre personne."""),
|
||||
# Matomo propose cependant ce formulaire si vous souhaitez totalement désactiver le suivi de vos sessions sur ce site :"""),
|
||||
# html.Div(
|
||||
# id="matomo-opt-out",
|
||||
# style={
|
||||
# "border": "1pt solid lightgrey",
|
||||
# "padding": "12px",
|
||||
# "margin": "auto 0 auto 12px",
|
||||
# "width": "80%",
|
||||
# },
|
||||
# children=["Vous utilisez un bloqueur de suivi de trafic."],
|
||||
# ),
|
||||
# html.Script(
|
||||
# src="https://analytics.maudry.com/index.php?module=CoreAdminHome&action=optOutJS&divId=matomo-opt-out&language=auto&showIntro=1"
|
||||
# ),
|
||||
],
|
||||
)
|
||||
]
|
||||
@@ -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)]
|
||||
@@ -0,0 +1,277 @@
|
||||
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,
|
||||
df,
|
||||
format_montant,
|
||||
format_number,
|
||||
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"),
|
||||
],
|
||||
),
|
||||
# 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=df.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) -> 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",
|
||||
"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 = dff.cast(pl.String)
|
||||
dff = dff.fill_null("")
|
||||
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")
|
||||
@@ -0,0 +1,167 @@
|
||||
import re
|
||||
import time
|
||||
|
||||
from dash import 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("""
|
||||
- Email : [colin+decp@maudry.com](mailto:colin+decp@maudry.com)
|
||||
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
|
||||
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
|
||||
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
|
||||
- venez discuter de la transparence de la commande publique [sur le forum teamopendata.org](https://teamopendata.org/c/commande-publique/101)
|
||||
"""),
|
||||
],
|
||||
)
|
||||
|
||||
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"}
|
||||
# )
|
||||
@@ -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
|
||||
@@ -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_montant, 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_montant(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
|
||||
@@ -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)]
|
||||
@@ -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"
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,261 @@
|
||||
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_montant,
|
||||
format_number,
|
||||
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="/",
|
||||
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": "14px",
|
||||
"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,
|
||||
markdown_options={"html": True},
|
||||
tooltip_duration=8000,
|
||||
tooltip_delay=350,
|
||||
),
|
||||
)
|
||||
|
||||
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_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 = 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")
|
||||
@@ -0,0 +1,291 @@
|
||||
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,
|
||||
df,
|
||||
format_montant,
|
||||
format_number,
|
||||
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"),
|
||||
],
|
||||
),
|
||||
# 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)),
|
||||
" titulaires (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",
|
||||
"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_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")
|
||||
+335
@@ -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 = [
|
||||
["s<", "<"],
|
||||
["s>", ">"],
|
||||
["i<", "<"],
|
||||
["i>", ">"],
|
||||
["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):
|
||||
print("filter part", filter_part)
|
||||
@@ -13,7 +32,323 @@ def split_filter_part(filter_part):
|
||||
name_part = name_part.strip()
|
||||
value = value_part.strip()
|
||||
name = name_part[name_part.find("{") + 1 : name_part.rfind("}")]
|
||||
print("=>", name, operator_group[1], value)
|
||||
|
||||
return name, operator_group[1], value
|
||||
|
||||
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):
|
||||
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)
|
||||
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)
|
||||
.otherwise(pl.lit(""))
|
||||
)
|
||||
|
||||
montant: pl.Expr = (
|
||||
pl.when((num + frac) == pl.lit(""))
|
||||
.then(pl.lit(""))
|
||||
.otherwise(num + frac + pl.lit(" €"))
|
||||
)
|
||||
|
||||
return montant
|
||||
|
||||
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.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_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_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
|
||||
|
||||
|
||||
df: pl.DataFrame = 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."
|
||||
),
|
||||
}
|
||||
data_schema = get_data_schema()
|
||||
|
||||
Reference in New Issue
Block a user