Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 41d3ca7539 | |||
| 636e0b059d |
@@ -40,4 +40,12 @@ jobs:
|
|||||||
key: ${{ secrets.ARTIFACT_SSH_KEY }}
|
key: ${{ secrets.ARTIFACT_SSH_KEY }}
|
||||||
passphrase: ${{ secrets.SSH_PSWD }}
|
passphrase: ${{ secrets.SSH_PSWD }}
|
||||||
command_timeout: 5m
|
command_timeout: 5m
|
||||||
script: ${{ secrets.APP_PATH }}/deploy.sh ${{ env.APP_NAME }}
|
script: |
|
||||||
|
systemctl stop ${{ vars.APP_NAME }}
|
||||||
|
cd /var/www/${{ vars.APP_NAME }}
|
||||||
|
git pull
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install .
|
||||||
|
deactivate
|
||||||
|
chown -R ${{ vars.APP_NAME }}:www-data *
|
||||||
|
systemctl start ${{ vars.APP_NAME }}
|
||||||
|
|||||||
@@ -2,3 +2,13 @@ DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1
|
|||||||
PORT=8050
|
PORT=8050
|
||||||
DEVELOPMENT=True
|
DEVELOPMENT=True
|
||||||
SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234"
|
SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234"
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# decp.info
|
# decp.info
|
||||||
|
|
||||||
> v2.0.1
|
> v2.1.0
|
||||||
|
|
||||||
Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
||||||
|
|
||||||
@@ -36,7 +36,17 @@ python run.py
|
|||||||
|
|
||||||
## Notes de version
|
## Notes de version
|
||||||
|
|
||||||
#### 2.0.1 (23 septembre 2025)
|
#### 2.1.0
|
||||||
|
|
||||||
|
- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/decp.info/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786) ([#35](https://github.com/ColinMaudry/decp.info/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/decp.info/issues/40)) 🔎
|
||||||
|
- Ajout des balises HTML meta Open Graph et Twitter ([#39](https://github.com/ColinMaudry/decp.info/issues/39)) pour de beaux aperçus de liens 🖼️
|
||||||
|
- Formulaire de contact ([#48](https://github.com/ColinMaudry/decp.info/issues/48)) 📨
|
||||||
|
- Nom de colonnes plus_agréables ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 💅
|
||||||
|
- Définition des colonnes quand vous passez votre souris sur les en-têtes ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 📖
|
||||||
|
- Affichage du numéro de version près du logo et lien vers ici 🤓
|
||||||
|
- Variables globales uniquement en lecture (😁)
|
||||||
|
|
||||||
|
##### 2.0.1 (23 septembre 2025)
|
||||||
|
|
||||||
- Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/decp.info/issues/38)
|
- 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)
|
- Amélioration du script de déploiement (deploy.sh)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!bin/bash
|
|
||||||
|
|
||||||
# Utilisé principalement avec les Github Actions
|
|
||||||
# cf. .github/workflows/deploy.yaml
|
|
||||||
|
|
||||||
appname = "$1"
|
|
||||||
|
|
||||||
systemctl stop $appname
|
|
||||||
cd /var/www/$appname
|
|
||||||
git pull
|
|
||||||
source .venv/bin/activate
|
|
||||||
pip install .
|
|
||||||
deactivate
|
|
||||||
chown -R $appname:www-data *
|
|
||||||
systemctl start $appname
|
|
||||||
+4
-2
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "decp.info"
|
name = "decp.info"
|
||||||
description = ""
|
description = "Interface d'exploration et d'analyse des marchés publics français."
|
||||||
version = "2.0.1"
|
version = "2.1.0"
|
||||||
requires-python = ">= 3.10"
|
requires-python = ">= 3.10"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Colin Maudry", email = "colin+decp@maudry.com" }
|
{ name = "Colin Maudry", email = "colin+decp@maudry.com" }
|
||||||
@@ -15,6 +15,8 @@ dependencies = [
|
|||||||
"python-dotenv",
|
"python-dotenv",
|
||||||
"xlsxwriter",
|
"xlsxwriter",
|
||||||
"plotly[express]",
|
"plotly[express]",
|
||||||
|
"httpx",
|
||||||
|
"pandas" # utilisé pour la création de certains graphiques
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
+25
-11
@@ -1,9 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
|
import tomllib
|
||||||
from dash import Dash, dcc, html, page_container, page_registry
|
from dash import Dash, dcc, html, page_container, page_registry
|
||||||
|
from dotenv import load_dotenv
|
||||||
from flask import send_from_directory
|
from flask import send_from_directory
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
app = Dash(
|
app = Dash(
|
||||||
external_stylesheets=[dbc.themes.SIMPLEX],
|
external_stylesheets=[dbc.themes.SIMPLEX],
|
||||||
title="decp.info",
|
title="decp.info",
|
||||||
@@ -29,9 +33,14 @@ logging.basicConfig(
|
|||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with open("./pyproject.toml", "rb") as f:
|
||||||
|
pyproject = tomllib.load(f)
|
||||||
|
version = "v" + pyproject["project"]["version"]
|
||||||
|
|
||||||
|
|
||||||
app.index_string = """
|
app.index_string = """
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
{%metas%}
|
{%metas%}
|
||||||
<title>{%title%}</title>
|
<title>{%title%}</title>
|
||||||
@@ -67,13 +76,27 @@ app.layout = html.Div(
|
|||||||
[
|
[
|
||||||
html.Div(
|
html.Div(
|
||||||
[
|
[
|
||||||
html.H1("decp.info"),
|
html.Div(
|
||||||
|
[
|
||||||
|
html.A(children=html.H1("decp.info"), href="/"),
|
||||||
|
html.P(
|
||||||
|
children=html.A(
|
||||||
|
version,
|
||||||
|
href="https://github.com/ColinMaudry/decp.info?tab=readme-ov-file#notes-de-version",
|
||||||
|
target="_blank",
|
||||||
|
),
|
||||||
|
className="version",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
className="logo",
|
||||||
|
),
|
||||||
html.Div(
|
html.Div(
|
||||||
[
|
[
|
||||||
dcc.Link(
|
dcc.Link(
|
||||||
page["name"], href=page["relative_path"], className="nav"
|
page["name"], href=page["relative_path"], className="nav"
|
||||||
)
|
)
|
||||||
for page in page_registry.values()
|
for page in page_registry.values()
|
||||||
|
if page["name"] not in ["Acheteur", "Titulaire", "Marché"]
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -82,15 +105,6 @@ app.layout = html.Div(
|
|||||||
page_container,
|
page_container,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
# @callback(
|
|
||||||
# Output(component_id="table", component_property="data", allow_duplicate=True),
|
|
||||||
# Input(component_id="search", component_property="value"),
|
|
||||||
# prevent_initial_call=True,
|
|
||||||
# )
|
|
||||||
# def global_search(text):
|
|
||||||
# new_df = df
|
|
||||||
# new_df = new_df.filter(pl.col("objet").str.contains("(?i)" + text))
|
|
||||||
# return new_df.to_dicts()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True)
|
app.run(debug=True)
|
||||||
|
|||||||
+136
-6
@@ -18,6 +18,39 @@
|
|||||||
margin-bottom: 25px;
|
margin-bottom: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#instructions {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
details > div {
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo et version */
|
||||||
|
|
||||||
|
div.logo {
|
||||||
|
width: 230px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.version {
|
||||||
|
float: left;
|
||||||
|
margin-top: 21px;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.version > a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.logo h1 {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.logo > a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
/* Réduire la taille du texte de la colonne Objet */
|
/* Réduire la taille du texte de la colonne Objet */
|
||||||
|
|
||||||
td[data-dash-column="objet"] {
|
td[data-dash-column="objet"] {
|
||||||
@@ -31,6 +64,8 @@ td[data-dash-column="objet"] {
|
|||||||
th.dash-header {
|
th.dash-header {
|
||||||
background-color: #b33821;
|
background-color: #b33821;
|
||||||
color: white;
|
color: white;
|
||||||
|
font-family: sans-serif;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-table-container
|
.dash-table-container
|
||||||
@@ -48,10 +83,33 @@ td[data-dash-column="objet"] {
|
|||||||
display: none;
|
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 */
|
/* Menu de masquage des colonnes */
|
||||||
.column-header--hide svg {
|
.column-actions {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-header--hide {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-hide {
|
.show-hide {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 180px;
|
width: 180px;
|
||||||
@@ -71,8 +129,12 @@ td[data-dash-column="objet"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Alternance des couleurs pour les lignes */
|
/* Alternance des couleurs pour les lignes */
|
||||||
#table tr:nth-child(even) td {
|
.marches_table {
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
.marches_table .cell-table tr:nth-child(even) td {
|
||||||
background-color: #feeeee;
|
background-color: #feeeee;
|
||||||
|
font-family: sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header > *,
|
#header > *,
|
||||||
@@ -96,15 +158,83 @@ a.nav {
|
|||||||
font-size: 120%;
|
font-size: 120%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar.h1 {
|
h3 {
|
||||||
float: left;
|
margin: 36px 0 24px 0;
|
||||||
width: 50%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
summary > h3 {
|
||||||
|
margin: 0;
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
#_pages_content {
|
#_pages_content {
|
||||||
padding-top: 28px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+34
-1
@@ -2,7 +2,7 @@ import json
|
|||||||
|
|
||||||
import plotly.express as px
|
import plotly.express as px
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from dash import dash_table, html
|
from dash import dash_table, dcc, html
|
||||||
|
|
||||||
|
|
||||||
def get_map_count_marches(lf: pl.LazyFrame):
|
def get_map_count_marches(lf: pl.LazyFrame):
|
||||||
@@ -157,3 +157,36 @@ def get_sources_tables(source_path) -> html.Div:
|
|||||||
datatable.data = df.to_dicts()
|
datatable.data = df.to_dicts()
|
||||||
|
|
||||||
return html.Div(children=datatable)
|
return html.Div(children=datatable)
|
||||||
|
|
||||||
|
|
||||||
|
def point_on_map(lat, lon):
|
||||||
|
lat = float(lat)
|
||||||
|
lon = float(lon)
|
||||||
|
|
||||||
|
# Create a scatter mapbox or choropleth map
|
||||||
|
fig = px.scatter_map(
|
||||||
|
lat=[lat], lon=[lon], height=300, width=400, color=[1], size=[1]
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.update_coloraxes(showscale=False)
|
||||||
|
|
||||||
|
# Set map style (you can use 'open-street-map', 'carto-positron', etc.)
|
||||||
|
fig.update_layout(
|
||||||
|
mapbox_style="light", # Light, clean background
|
||||||
|
margin={"r": 0, "t": 0, "l": 0, "b": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optionally, center the map on France
|
||||||
|
fig.update_geos(
|
||||||
|
center=dict(lat=46.603354, lon=1.888334), # Center of France
|
||||||
|
lataxis_range=[41, 51.5], # Latitude range for France
|
||||||
|
lonaxis_range=[-5, 10], # Longitude range for France
|
||||||
|
)
|
||||||
|
|
||||||
|
# But scatter_mapbox doesn't use geos, so better to control via zoom/center manually
|
||||||
|
# Let's reset and use proper centering in scatter_mapbox instead:
|
||||||
|
|
||||||
|
fig.update_layout(map_center={"lat": 46.6, "lon": 1.89}, map_zoom=4)
|
||||||
|
|
||||||
|
graph = dcc.Graph(id="map", figure=fig)
|
||||||
|
return graph
|
||||||
|
|||||||
+13
-17
@@ -1,23 +1,27 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from dash import dcc, html, register_page
|
from dash import dcc, html, register_page
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
from src.figures import get_sources_tables
|
from src.figures import get_sources_tables
|
||||||
|
from src.utils import meta_content
|
||||||
|
|
||||||
title = "À propos"
|
name = "À propos"
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
register_page(
|
register_page(
|
||||||
__name__, path="/a-propos", title=f"decp.info - {title}", name=title, order=5
|
__name__,
|
||||||
|
path="/a-propos",
|
||||||
|
title=meta_content["title"],
|
||||||
|
name=name,
|
||||||
|
description=meta_content["description"],
|
||||||
|
image_url=meta_content["image_url"],
|
||||||
|
order=5,
|
||||||
)
|
)
|
||||||
|
|
||||||
layout = [
|
layout = [
|
||||||
html.Div(
|
html.Div(
|
||||||
className="container",
|
className="container",
|
||||||
children=[
|
children=[
|
||||||
html.H2(title),
|
html.H2(name),
|
||||||
dcc.Markdown(
|
dcc.Markdown(
|
||||||
"""Outil d'exploration libre et gratuit des données de marchés publics, développé par Colin Maudry.
|
"""Outil d'exploration libre et gratuit des données de marchés publics, développé par Colin Maudry.
|
||||||
|
|
||||||
@@ -27,7 +31,7 @@ j'aimerais beaucoup échanger avec vous pour comprendre vos cas d'usages et vos
|
|||||||
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
|
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
|
les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
|
||||||
|
|
||||||
- de nombreuses visualisations de données (cartes, graphiques, tableaux) sur des thématiques variées (vivacité de la concurrence, secteurs d'activité, insertion par l'activité économique (IAE), distance acheteur-fournisseur...)
|
- 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
|
- la sauvegarde de filtres pour les retrouver plus tard et les partager
|
||||||
- des alertes par email si des marchés correspondant à certains critères
|
- des alertes par email si des marchés correspondant à certains critères
|
||||||
- le développement d'une API pour alimenter d'autres logiciels
|
- le développement d'une API pour alimenter d'autres logiciels
|
||||||
@@ -38,25 +42,17 @@ les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
|
|||||||
dcc.Markdown("""
|
dcc.Markdown("""
|
||||||
- via l'achat d'une prestation de service (devis, prestation, facture), vous pouvez financer le développement de [fonctionnalités prévues](https://github.com/ColinMaudry/decp.info/issues), ou d'autres !
|
- 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)
|
- ma société accepte aussi les dons (pas de réduction d'impôt possible)
|
||||||
- envoyez un mail et on discute !
|
- [écrivez-moi](/contact) et on discute !
|
||||||
|
|
||||||
#### Pour explorer le projet
|
#### 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)
|
- ✉️ [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/)
|
- 💾 [données consolidées en Open Data](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire/)
|
||||||
- 🗞️ [mon blog](https://colin.maudry.com), qui parle beaucoup de transparence des marchés publics
|
- 🗞️ [mon blog](https://colin.maudry.com)
|
||||||
- 📔 [wiki du projet](https://github.com/ColinMaudry/decp-processing/wiki)
|
- 📔 [wiki du projet](https://github.com/ColinMaudry/decp-processing/wiki)
|
||||||
- 🚰 code source
|
- 🚰 code source
|
||||||
- [de decp.info](https://github.com/ColinMaudry/decp.info)
|
- [de decp.info](https://github.com/ColinMaudry/decp.info)
|
||||||
- [du traitement des données](https://github.com/ColinMaudry/decp-processing)
|
- [du traitement des données](https://github.com/ColinMaudry/decp-processing)
|
||||||
"""),
|
|
||||||
html.H4("Contact", id="contact"),
|
|
||||||
dcc.Markdown("""
|
|
||||||
- Email : [colin+decp@maudry.com](mailto:colin+decp@maudry.com)
|
|
||||||
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
|
|
||||||
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
|
|
||||||
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
|
|
||||||
- venez discuter de la transparence de la commande publique [sur le forum teamopendata.org](https://teamopendata.org/c/commande-publique/101)
|
|
||||||
"""),
|
"""),
|
||||||
html.H4("Sources de données", id="sources"),
|
html.H4("Sources de données", id="sources"),
|
||||||
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
|
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
|
||||||
|
|
||||||
|
from src.figures import point_on_map
|
||||||
|
from src.utils import (
|
||||||
|
add_links_in_dict,
|
||||||
|
format_montant,
|
||||||
|
format_number,
|
||||||
|
get_annuaire_data,
|
||||||
|
get_departement_region,
|
||||||
|
lf,
|
||||||
|
meta_content,
|
||||||
|
setup_table_columns,
|
||||||
|
)
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path_template="/acheteurs/<acheteur_id>",
|
||||||
|
title=meta_content["title"],
|
||||||
|
name="Acheteur",
|
||||||
|
description=meta_content["description"],
|
||||||
|
image_url=meta_content["image_url"],
|
||||||
|
order=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = [
|
||||||
|
dcc.Store(id="acheteur_data", storage_type="memory"),
|
||||||
|
dcc.Location(id="url", refresh="callback-nav"),
|
||||||
|
html.Div(
|
||||||
|
className="container",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
className="wrapper",
|
||||||
|
children=[
|
||||||
|
html.H2(
|
||||||
|
className="org_title",
|
||||||
|
children=[
|
||||||
|
html.Span(id="acheteur_siret"),
|
||||||
|
" - ",
|
||||||
|
html.Span(id="acheteur_nom"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
className="org_year",
|
||||||
|
children=dcc.Dropdown(
|
||||||
|
id="acheteur_year",
|
||||||
|
options=["Toutes"]
|
||||||
|
+ [
|
||||||
|
str(year)
|
||||||
|
for year in range(
|
||||||
|
2018, int(datetime.date.today().year) + 1
|
||||||
|
)
|
||||||
|
],
|
||||||
|
placeholder="Année",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
className="org_infos",
|
||||||
|
children=[
|
||||||
|
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
|
||||||
|
html.P(["Commune : ", html.Strong(id="acheteur_commune")]),
|
||||||
|
html.P(
|
||||||
|
[
|
||||||
|
"Département : ",
|
||||||
|
html.Strong(id="acheteur_departement"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
html.P(["Région : ", html.Strong(id="acheteur_region")]),
|
||||||
|
html.A(
|
||||||
|
id="acheteur_lien_annuaire",
|
||||||
|
children="Plus de détails sur l'Annuaire des entreprises",
|
||||||
|
target="_blank",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
className="org_stats",
|
||||||
|
children=[
|
||||||
|
html.P(id="acheteur_titre_stats"),
|
||||||
|
html.P(id="acheteur_marches_attribues"),
|
||||||
|
html.P(id="acheteur_titulaires_differents"),
|
||||||
|
html.Button(
|
||||||
|
"Téléchargement au format Excel",
|
||||||
|
id="btn-download-acheteur-data",
|
||||||
|
),
|
||||||
|
dcc.Download(id="download-acheteur-data"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
html.Div(className="org_map", id="acheteur_map"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# récupérer les données de l'acheteur sur l'api annuaire
|
||||||
|
html.H3("Derniers marchés publics attribués"),
|
||||||
|
html.Div(id="acheteur_last_marches", children=""),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="acheteur_siret", component_property="children"),
|
||||||
|
Output(component_id="acheteur_nom", component_property="children"),
|
||||||
|
Output(component_id="acheteur_commune", component_property="children"),
|
||||||
|
Output(component_id="acheteur_map", component_property="children"),
|
||||||
|
Output(component_id="acheteur_departement", component_property="children"),
|
||||||
|
Output(component_id="acheteur_region", component_property="children"),
|
||||||
|
Output(component_id="acheteur_lien_annuaire", component_property="href"),
|
||||||
|
Input(component_id="url", component_property="pathname"),
|
||||||
|
)
|
||||||
|
def update_acheteur_infos(url):
|
||||||
|
acheteur_siret = url.split("/")[-1]
|
||||||
|
if len(acheteur_siret) != 14:
|
||||||
|
acheteur_siret = (
|
||||||
|
f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})"
|
||||||
|
)
|
||||||
|
data = get_annuaire_data(acheteur_siret)
|
||||||
|
data_etablissement = data["matching_etablissements"][0]
|
||||||
|
acheteur_map = point_on_map(
|
||||||
|
data_etablissement["latitude"], data_etablissement["longitude"]
|
||||||
|
)
|
||||||
|
code_departement, nom_departement, nom_region = get_departement_region(
|
||||||
|
data_etablissement["code_postal"]
|
||||||
|
)
|
||||||
|
departement = f"{nom_departement} ({code_departement})"
|
||||||
|
lien_annuaire = (
|
||||||
|
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
acheteur_siret,
|
||||||
|
data["nom_raison_sociale"],
|
||||||
|
data_etablissement["libelle_commune"],
|
||||||
|
acheteur_map,
|
||||||
|
departement,
|
||||||
|
nom_region,
|
||||||
|
lien_annuaire,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="acheteur_marches_attribues", component_property="children"),
|
||||||
|
Output(
|
||||||
|
component_id="acheteur_titulaires_differents", component_property="children"
|
||||||
|
),
|
||||||
|
Input(component_id="acheteur_data", component_property="data"),
|
||||||
|
)
|
||||||
|
def update_acheteur_stats(data):
|
||||||
|
df = pl.DataFrame(data)
|
||||||
|
if df.height == 0:
|
||||||
|
df = pl.DataFrame(schema=lf.collect_schema())
|
||||||
|
df_marches = df.unique("id")
|
||||||
|
nb_marches = format_number(df_marches.height)
|
||||||
|
# somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item()))
|
||||||
|
marches_attribues = [html.Strong(nb_marches), " marchés et accord-cadres attribués"]
|
||||||
|
# + ", pour un total de ", html.Strong(somme_marches + " €")]
|
||||||
|
del df_marches
|
||||||
|
|
||||||
|
nb_titulaires = df.unique("titulaire_id").height
|
||||||
|
nb_titulaires = [
|
||||||
|
html.Strong(format_number(nb_titulaires)),
|
||||||
|
" titulaires (SIRET) différents",
|
||||||
|
]
|
||||||
|
del df
|
||||||
|
|
||||||
|
return marches_attribues, nb_titulaires
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="acheteur_data", component_property="data"),
|
||||||
|
Input(component_id="url", component_property="pathname"),
|
||||||
|
Input(component_id="acheteur_year", component_property="value"),
|
||||||
|
)
|
||||||
|
def get_acheteur_marches_data(url, acheteur_year: str) -> pl.LazyFrame:
|
||||||
|
acheteur_siret = url.split("/")[-1]
|
||||||
|
lff = lf.filter(pl.col("acheteur_id") == acheteur_siret)
|
||||||
|
lff = lff.fill_null("")
|
||||||
|
lff = lff.select(
|
||||||
|
"id",
|
||||||
|
"uid",
|
||||||
|
"objet",
|
||||||
|
"dateNotification",
|
||||||
|
"titulaire_id",
|
||||||
|
"titulaire_typeIdentifiant",
|
||||||
|
"titulaire_nom",
|
||||||
|
"montant",
|
||||||
|
"codeCPV",
|
||||||
|
"dureeMois",
|
||||||
|
)
|
||||||
|
if acheteur_year and acheteur_year != "Toutes":
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col("dateNotification").cast(pl.String).str.starts_with(acheteur_year)
|
||||||
|
)
|
||||||
|
lff = lff.sort(["dateNotification", "id"], descending=True, nulls_last=True)
|
||||||
|
|
||||||
|
data = lff.collect(engine="streaming").to_dicts()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="acheteur_last_marches", component_property="children"),
|
||||||
|
Input(component_id="acheteur_data", component_property="data"),
|
||||||
|
)
|
||||||
|
def get_last_marches_table(data) -> html.Div:
|
||||||
|
dff = pl.DataFrame(data)
|
||||||
|
dff = format_montant(dff)
|
||||||
|
columns, tooltip = setup_table_columns(
|
||||||
|
dff,
|
||||||
|
hideable=False,
|
||||||
|
exclude=["titulaire_id", "titulaire_typeIdentifiant", "uid"],
|
||||||
|
)
|
||||||
|
data = dff.to_dicts()
|
||||||
|
data = add_links_in_dict(data, "titulaire")
|
||||||
|
|
||||||
|
table = html.Div(
|
||||||
|
className="marches_table",
|
||||||
|
id="acheteur_datatable",
|
||||||
|
children=dash_table.DataTable(
|
||||||
|
data=data,
|
||||||
|
markdown_options={"html": True},
|
||||||
|
page_action="native",
|
||||||
|
filter_action="native",
|
||||||
|
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
|
||||||
|
columns=columns,
|
||||||
|
tooltip_header=tooltip,
|
||||||
|
tooltip_duration=8000,
|
||||||
|
tooltip_delay=350,
|
||||||
|
cell_selectable=False,
|
||||||
|
page_size=10,
|
||||||
|
style_cell_conditional=[
|
||||||
|
{
|
||||||
|
"if": {"column_id": "objet"},
|
||||||
|
"minWidth": "300px",
|
||||||
|
"textAlign": "left",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"lineHeight": "14px",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {"column_id": "titulaire_nom"},
|
||||||
|
"minWidth": "200px",
|
||||||
|
"textAlign": "left",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"lineHeight": "18px",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("download-acheteur-data", "data"),
|
||||||
|
Input("btn-download-acheteur-data", "n_clicks"),
|
||||||
|
State(component_id="acheteur_data", component_property="data"),
|
||||||
|
State(component_id="acheteur_nom", component_property="children"),
|
||||||
|
State(component_id="acheteur_year", component_property="value"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def download_acheteur_data(
|
||||||
|
n_clicks,
|
||||||
|
data: [dict],
|
||||||
|
acheteur_nom: str,
|
||||||
|
annee: str,
|
||||||
|
):
|
||||||
|
df_to_download = pl.DataFrame(data)
|
||||||
|
|
||||||
|
def to_bytes(buffer):
|
||||||
|
df_to_download.write_excel(
|
||||||
|
buffer, worksheet="DECP" if annee in ["Toutes", None] else annee
|
||||||
|
)
|
||||||
|
|
||||||
|
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
||||||
|
return dcc.send_bytes(to_bytes, filename=f"decp_{acheteur_nom}_{date}.xlsx")
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import smtplib
|
||||||
|
import time
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
from dash import Input, Output, State, callback, dcc, html, register_page
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
from src.utils import meta_content
|
||||||
|
|
||||||
|
name = "Contact"
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path="/contact",
|
||||||
|
title=meta_content["title"],
|
||||||
|
name=name,
|
||||||
|
description=meta_content["description"],
|
||||||
|
image_url=meta_content["image_url"],
|
||||||
|
order=6,
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = html.Div(
|
||||||
|
className="container",
|
||||||
|
children=[
|
||||||
|
html.H2("Contact", id="contact"),
|
||||||
|
html.P(
|
||||||
|
"Votre message arrivera directement dans ma boîte mail, et je reviendrai vers vous rapidement."
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
[
|
||||||
|
html.Label("Votre nom"),
|
||||||
|
dcc.Input(
|
||||||
|
id="input-name",
|
||||||
|
type="text",
|
||||||
|
style={"width": "100%", "padding": "8px", "margin": "8px 0"},
|
||||||
|
),
|
||||||
|
html.Label("Votre adresse email"),
|
||||||
|
dcc.Input(
|
||||||
|
id="input-email",
|
||||||
|
type="email",
|
||||||
|
style={"width": "100%", "padding": "8px", "margin": "8px 0"},
|
||||||
|
),
|
||||||
|
html.Label("Message"),
|
||||||
|
dcc.Textarea(
|
||||||
|
id="input-message",
|
||||||
|
style={
|
||||||
|
"width": "100%",
|
||||||
|
"height": 200,
|
||||||
|
"padding": "8px",
|
||||||
|
"margin": "8px 0",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
html.Button(
|
||||||
|
"Envoyer ",
|
||||||
|
id="submit-button",
|
||||||
|
n_clicks=0,
|
||||||
|
style={"marginTop": "10px"},
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
id="output-message", style={"marginTop": "10px", "color": "green"}
|
||||||
|
),
|
||||||
|
],
|
||||||
|
style={
|
||||||
|
"maxWidth": "600px",
|
||||||
|
"margin": "auto",
|
||||||
|
"padding": "20px",
|
||||||
|
"lineHeight": "20px",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
dcc.Markdown("""
|
||||||
|
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
|
||||||
|
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
|
||||||
|
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
|
||||||
|
- venez discuter de la transparence de la commande publique [sur le forum teamopendata.org](https://teamopendata.org/c/commande-publique/101)
|
||||||
|
"""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
rate_limit_store = {} # {ip: last_timestamp}
|
||||||
|
RATE_LIMIT_WINDOW = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
|
def is_rate_limited():
|
||||||
|
ip = request.remote_addr
|
||||||
|
now = time.time()
|
||||||
|
last_time = rate_limit_store.get(ip)
|
||||||
|
|
||||||
|
if last_time and (now - last_time) < RATE_LIMIT_WINDOW:
|
||||||
|
return True # rate limited !
|
||||||
|
rate_limit_store[ip] = now # màj du timestamp
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_email(email):
|
||||||
|
return re.match(r"[^@]+@[^@]+\.[^@]+", email)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("output-message", "children"),
|
||||||
|
Input("submit-button", "n_clicks"),
|
||||||
|
State("input-name", "value"),
|
||||||
|
State("input-email", "value"),
|
||||||
|
State("input-message", "value"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def send_email(n_clicks, form_name, form_email, form_message):
|
||||||
|
if not all([form_name, form_email, form_message]):
|
||||||
|
return html.Div(
|
||||||
|
"Veuillez s'il vous plaît remplir tous les champs.", style={"color": "red"}
|
||||||
|
)
|
||||||
|
|
||||||
|
client_ip = request.remote_addr
|
||||||
|
if is_rate_limited():
|
||||||
|
wait_time = int(RATE_LIMIT_WINDOW - (time.time() - rate_limit_store[client_ip]))
|
||||||
|
return html.Div(
|
||||||
|
f"⏳ J'ai mis en place une protection contre le spam, et vous m'avez écrit il y a moins de 5 minutes. "
|
||||||
|
f"Veuillez s'il vous plaît attendre {wait_time} secondes avant de renvoyer un message.",
|
||||||
|
style={"color": "black"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Configuration du serveur SMTP
|
||||||
|
smtp_server = os.getenv("SENDER_SERVER_DOMAIN")
|
||||||
|
smtp_port = 587
|
||||||
|
login_email = os.getenv("LOGIN_EMAIL")
|
||||||
|
from_email = os.getenv("FROM_EMAIL")
|
||||||
|
to_email = os.getenv("TO_EMAIL", from_email)
|
||||||
|
login_password = os.getenv("LOGIN_PASSWORD")
|
||||||
|
|
||||||
|
print(
|
||||||
|
smtp_server,
|
||||||
|
smtp_port,
|
||||||
|
login_email,
|
||||||
|
from_email,
|
||||||
|
to_email,
|
||||||
|
login_password,
|
||||||
|
sep="\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Création de l'email
|
||||||
|
email = MIMEMultipart()
|
||||||
|
email["From"] = from_email
|
||||||
|
email["To"] = to_email
|
||||||
|
email["Subject"] = f"[decp.info] Message de {form_name}"
|
||||||
|
|
||||||
|
body = f"""
|
||||||
|
Nom : {form_name}
|
||||||
|
Email : {form_email}
|
||||||
|
|
||||||
|
Message :
|
||||||
|
{form_message}
|
||||||
|
"""
|
||||||
|
email.attach(MIMEText(body, "plain"))
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
server = smtplib.SMTP(smtp_server, smtp_port)
|
||||||
|
server.starttls()
|
||||||
|
server.login(login_email, login_password)
|
||||||
|
server.sendmail(from_email, to_email, email.as_string())
|
||||||
|
server.quit()
|
||||||
|
|
||||||
|
return html.Div("✅ Envoi réussi", style={"color": "green"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return html.Div(
|
||||||
|
f"❌ Échec de l'envoi du message : {str(e)}", style={"color": "red"}
|
||||||
|
)
|
||||||
+85
-106
@@ -3,55 +3,38 @@ from datetime import datetime
|
|||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
|
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
from src.utils import (
|
from src.utils import (
|
||||||
add_annuaire_link,
|
add_links,
|
||||||
add_resource_link,
|
add_resource_link,
|
||||||
booleans_to_strings,
|
filter_table_data,
|
||||||
|
format_montant,
|
||||||
format_number,
|
format_number,
|
||||||
lf,
|
lf,
|
||||||
logger,
|
meta_content,
|
||||||
split_filter_part,
|
setup_table_columns,
|
||||||
|
sort_table_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
|
update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
|
||||||
update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y")
|
update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y")
|
||||||
df_filtered = pl.DataFrame()
|
|
||||||
|
|
||||||
# Unique les données actuelles, pas les anciennes versions de marchés
|
|
||||||
|
|
||||||
lf = lf.filter(pl.col("donneesActuelles"))
|
|
||||||
|
|
||||||
# Suppression des colonnes inutiles
|
|
||||||
lf = lf.drop(
|
|
||||||
[
|
|
||||||
"donneesActuelles",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convertir les colonnes booléennes en chaînes de caractères
|
|
||||||
lf = booleans_to_strings(lf)
|
|
||||||
|
|
||||||
# Remplacer les valeurs manquantes par des chaînes vides
|
|
||||||
lf = lf.fill_null("")
|
|
||||||
|
|
||||||
|
|
||||||
# Ajout des liens vers l'annuaire
|
|
||||||
lf = add_annuaire_link(lf)
|
|
||||||
|
|
||||||
# Ajout des liens open data
|
|
||||||
lf = add_resource_link(lf)
|
|
||||||
|
|
||||||
schema = lf.collect_schema()
|
schema = lf.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,
|
||||||
|
)
|
||||||
|
|
||||||
title = "Tableau"
|
datatable = html.Div(
|
||||||
register_page(__name__, path="/", title="decp.info", name=title, order=1)
|
className="marches_table",
|
||||||
|
children=dash_table.DataTable(
|
||||||
datatable = dash_table.DataTable(
|
|
||||||
cell_selectable=False,
|
cell_selectable=False,
|
||||||
id="table",
|
id="table",
|
||||||
page_size=20,
|
page_size=20,
|
||||||
@@ -59,17 +42,6 @@ datatable = dash_table.DataTable(
|
|||||||
page_action="custom",
|
page_action="custom",
|
||||||
filter_action="custom",
|
filter_action="custom",
|
||||||
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
|
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
|
||||||
columns=[
|
|
||||||
{
|
|
||||||
"name": i,
|
|
||||||
"id": i,
|
|
||||||
"presentation": "markdown",
|
|
||||||
"type": "text",
|
|
||||||
"format": {"nully": "N/A"},
|
|
||||||
"hideable": True,
|
|
||||||
}
|
|
||||||
for i in lf.collect_schema().names()
|
|
||||||
],
|
|
||||||
sort_action="custom",
|
sort_action="custom",
|
||||||
sort_mode="multi",
|
sort_mode="multi",
|
||||||
sort_by=[],
|
sort_by=[],
|
||||||
@@ -91,9 +63,20 @@ datatable = dash_table.DataTable(
|
|||||||
"lineHeight": "14px",
|
"lineHeight": "14px",
|
||||||
"whiteSpace": "normal",
|
"whiteSpace": "normal",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"if": {"column_id": "titulaire_nom"},
|
||||||
|
"minWidth": "250px",
|
||||||
|
"textAlign": "left",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"lineHeight": "14px",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
data_timestamp=0,
|
data_timestamp=0,
|
||||||
markdown_options={"html": True},
|
markdown_options={"html": True},
|
||||||
|
tooltip_duration=8000,
|
||||||
|
tooltip_delay=350,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
layout = [
|
layout = [
|
||||||
@@ -105,21 +88,35 @@ layout = [
|
|||||||
),
|
),
|
||||||
dcc.Markdown(
|
dcc.Markdown(
|
||||||
"""
|
"""
|
||||||
|
##### Définition des colonnes
|
||||||
|
|
||||||
**Filtres**
|
Pour voir la définition d'une colonne, passez votre souris sur son en-tête.
|
||||||
|
|
||||||
|
##### Filtres
|
||||||
|
|
||||||
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`.
|
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`.
|
||||||
|
|
||||||
- Champs textuels : la recherche est insensible à la casse (majuscules/minuscules).
|
- Champs textuels : la recherche est insensible à la casse (majuscules/minuscules) et retourne les valeurs qui contiennent
|
||||||
- Champs numériques : 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.
|
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.
|
Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page.
|
||||||
|
|
||||||
**Télécharger le résultat**
|
##### Tri
|
||||||
|
|
||||||
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.
|
Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre : tri ascendant, tri descendant, pas de tri.
|
||||||
|
|
||||||
|
##### Télécharger le résultat
|
||||||
|
|
||||||
|
Vous pouvez télécharger le résultat de vos filtres et tris, pour les colonnes affichées, en cliquant sur **Télécharger au format Excel**.
|
||||||
|
|
||||||
|
##### Liens
|
||||||
|
|
||||||
|
Les liens dans les colonnes Identifiant unique, Acheteur et Titulaire vous permettent de consulter une vue qui leur est dédiée
|
||||||
|
(informations, marchés attribués/remportés, etc.)
|
||||||
|
|
||||||
Si vous téléchargez un volume important de données, il se peut que vous attendiez quelques minutes avant le début du téléchargement.
|
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -147,6 +144,7 @@ layout = [
|
|||||||
disabled=True,
|
disabled=True,
|
||||||
),
|
),
|
||||||
dcc.Download(id="download-data"),
|
dcc.Download(id="download-data"),
|
||||||
|
dcc.Store(id="filtered_data", storage_type="memory"),
|
||||||
html.P("Données mises à jour le " + str(update_date)),
|
html.P("Données mises à jour le " + str(update_date)),
|
||||||
],
|
],
|
||||||
className="table-menu",
|
className="table-menu",
|
||||||
@@ -159,6 +157,8 @@ layout = [
|
|||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
Output("table", "data"),
|
Output("table", "data"),
|
||||||
|
Output("table", "columns"),
|
||||||
|
Output("table", "tooltip_header"),
|
||||||
Output("table", "data_timestamp"),
|
Output("table", "data_timestamp"),
|
||||||
Output("nb_rows", "children"),
|
Output("nb_rows", "children"),
|
||||||
Output("btn-download-data", "disabled"),
|
Output("btn-download-data", "disabled"),
|
||||||
@@ -172,56 +172,21 @@ layout = [
|
|||||||
)
|
)
|
||||||
def update_table(page_current, page_size, filter_query, sort_by, data_timestamp):
|
def update_table(page_current, page_size, filter_query, sort_by, data_timestamp):
|
||||||
print(" + + + + + + + + + + + + + + + + + + ")
|
print(" + + + + + + + + + + + + + + + + + + ")
|
||||||
global df_filtered
|
|
||||||
|
|
||||||
# Application des filtres
|
# Application des filtres
|
||||||
lff: pl.LazyFrame = lf # start from the original data
|
lff: pl.LazyFrame = lf # start from the original data
|
||||||
if filter_query:
|
if filter_query:
|
||||||
filtering_expressions = filter_query.split(" && ")
|
lff = filter_table_data(lff, filter_query)
|
||||||
for filter_part in filtering_expressions:
|
|
||||||
col_name, operator, filter_value = split_filter_part(filter_part)
|
|
||||||
col_type = str(schema[col_name])
|
|
||||||
print("filter_value:", filter_value)
|
|
||||||
print("filter_value_type:", type(filter_value))
|
|
||||||
print("col_type:", col_type)
|
|
||||||
|
|
||||||
if operator in ("<", "<=", ">", ">="):
|
|
||||||
filter_value = int(filter_value)
|
|
||||||
if operator == "<":
|
|
||||||
lff = lff.filter(pl.col(col_name) < filter_value)
|
|
||||||
elif operator == ">":
|
|
||||||
lff = lff.filter(pl.col(col_name) > filter_value)
|
|
||||||
elif operator == ">=":
|
|
||||||
lff = lff.filter(pl.col(col_name) >= filter_value)
|
|
||||||
elif operator == "<=":
|
|
||||||
lff = lff.filter(pl.col(col_name) <= filter_value)
|
|
||||||
|
|
||||||
elif col_type.startswith("Int") or col_type.startswith("Float"):
|
|
||||||
try:
|
|
||||||
filter_value = int(filter_value)
|
|
||||||
except ValueError:
|
|
||||||
logger.error(f"Invalid numeric filter value: {filter_value}")
|
|
||||||
continue
|
|
||||||
lff = lff.filter(pl.col(col_name) == filter_value)
|
|
||||||
|
|
||||||
elif operator == "contains" and col_type == "String":
|
|
||||||
lff = lff.filter(pl.col(col_name).str.contains("(?i)" + filter_value))
|
|
||||||
|
|
||||||
# elif operator == 'datestartswith':
|
|
||||||
# lff = lff.filter(pl.col(col_name).str.startswith(filter_value)")
|
|
||||||
|
|
||||||
if len(sort_by) > 0:
|
if len(sort_by) > 0:
|
||||||
lff = lff.sort(
|
lff = sort_table_data(lff, sort_by)
|
||||||
[col["column_id"] for col in sort_by],
|
|
||||||
descending=[col["direction"] == "desc" for col in sort_by],
|
|
||||||
nulls_last=True,
|
|
||||||
)
|
|
||||||
print(sort_by)
|
|
||||||
|
|
||||||
|
# Remplace les strings null par "", mais pas les numeric null
|
||||||
|
lff = lff.fill_null("")
|
||||||
|
|
||||||
|
# Matérialisation des filtres
|
||||||
dff: pl.DataFrame = lff.collect()
|
dff: pl.DataFrame = lff.collect()
|
||||||
|
|
||||||
df_filtered = dff.clone()
|
|
||||||
|
|
||||||
height = dff.height
|
height = dff.height
|
||||||
|
|
||||||
nb_rows = f"{format_number(height)} lignes"
|
nb_rows = f"{format_number(height)} lignes"
|
||||||
@@ -230,6 +195,18 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp)
|
|||||||
start_row = page_current * page_size
|
start_row = page_current * page_size
|
||||||
# end_row = (page_current + 1) * page_size
|
# end_row = (page_current + 1) * page_size
|
||||||
dff = dff.slice(start_row, page_size)
|
dff = dff.slice(start_row, page_size)
|
||||||
|
|
||||||
|
# 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()
|
dicts = dff.to_dicts()
|
||||||
|
|
||||||
if height > 65000:
|
if height > 65000:
|
||||||
@@ -243,6 +220,8 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
dicts,
|
dicts,
|
||||||
|
columns,
|
||||||
|
tooltip,
|
||||||
data_timestamp + 1,
|
data_timestamp + 1,
|
||||||
nb_rows,
|
nb_rows,
|
||||||
download_disabled,
|
download_disabled,
|
||||||
@@ -254,26 +233,26 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp)
|
|||||||
@callback(
|
@callback(
|
||||||
Output("download-data", "data"),
|
Output("download-data", "data"),
|
||||||
Input("btn-download-data", "n_clicks"),
|
Input("btn-download-data", "n_clicks"),
|
||||||
|
State("table", "filter_query"),
|
||||||
|
State("table", "sort_by"),
|
||||||
State("table", "hidden_columns"),
|
State("table", "hidden_columns"),
|
||||||
prevent_initial_call=True,
|
prevent_initial_call=True,
|
||||||
)
|
)
|
||||||
def download_data(n_clicks, hidden_columns: list = None):
|
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
|
||||||
df_to_download = df_filtered.clone()
|
lff: pl.LazyFrame = lf # start from the original data
|
||||||
|
|
||||||
print(df_to_download.columns)
|
|
||||||
|
|
||||||
# Rétablissement des colonnes source et sourceOpenData (voir add_resource_link)
|
|
||||||
df_to_download = df_to_download.with_columns(
|
|
||||||
pl.col("source").str.extract(r'href="(.*?)"').alias("sourceFile"),
|
|
||||||
pl.col("source").str.extract(r'">(.*?)<').alias("sourceDataset"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Les colonnes masquées sont supprimées
|
# Les colonnes masquées sont supprimées
|
||||||
if hidden_columns:
|
if hidden_columns:
|
||||||
df_to_download = df_to_download.drop(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):
|
def to_bytes(buffer):
|
||||||
df_to_download.write_excel(buffer, worksheet="DECP")
|
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
|
||||||
|
|
||||||
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
||||||
return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx")
|
return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx")
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
from dash import Input, Output, callback, dcc, html, register_page
|
||||||
|
from polars import selectors as cs
|
||||||
|
|
||||||
|
from src.utils import data_schema, format_montant, lf, meta_content
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path_template="/marches/<uid>",
|
||||||
|
title=meta_content["title"],
|
||||||
|
name="Marché",
|
||||||
|
description=meta_content["description"],
|
||||||
|
image_url=meta_content["image_url"],
|
||||||
|
order=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = [
|
||||||
|
dcc.Store(id="marche_data"),
|
||||||
|
dcc.Store(id="titulaires_data"),
|
||||||
|
dcc.Location(id="url", refresh="callback-nav"),
|
||||||
|
html.Div(
|
||||||
|
className="container marche_infos",
|
||||||
|
children=[
|
||||||
|
html.P("Vous consultez un résumé des données de ce marché public"),
|
||||||
|
html.Ul(
|
||||||
|
[
|
||||||
|
html.Li(
|
||||||
|
"après son attribution aux titulaires qui l'ont remporté à la suite d'un appel d'offres (ou sans appel d'offres via une attribution directe)"
|
||||||
|
),
|
||||||
|
html.Li(
|
||||||
|
"après avoir appliqué les éventuelles modifications de montant, durée ou titulaires renseignées par l'acheteur"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
html.P(
|
||||||
|
"Le montant total payé aux titulaires, la durée du marché et la liste des titulaires peuvent cependant encore évoluer jusqu'à la fin de l'exécution du marché."
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
className="wrapper",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
className="marche_map",
|
||||||
|
id="marche_map",
|
||||||
|
children=[
|
||||||
|
html.H4("Titulaires"),
|
||||||
|
html.Ul(id="marche_infos_titulaires"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
html.Div(className="marche_infos_1", id="marche_infos_1"),
|
||||||
|
html.Div(className="marche_infos_2", id="marche_infos_2"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("marche_data", "data"),
|
||||||
|
Output("titulaires_data", "data"),
|
||||||
|
Input(component_id="url", component_property="pathname"),
|
||||||
|
)
|
||||||
|
def get_marche_data(url):
|
||||||
|
marche_uid = url.split("/")[-1]
|
||||||
|
|
||||||
|
# Récupération des données du marché à partir du lf global
|
||||||
|
lff = lf.filter(pl.col("uid") == pl.lit(marche_uid))
|
||||||
|
|
||||||
|
# Données des titulaires du marché
|
||||||
|
dff_titulaires = lff.select(cs.starts_with("titulaire")).collect(engine="streaming")
|
||||||
|
|
||||||
|
# Données du marché
|
||||||
|
dff_marche = (
|
||||||
|
lff.select(~cs.starts_with("titulaires")).unique().collect(engine="streaming")
|
||||||
|
)
|
||||||
|
dff_marche = format_montant(dff_marche)
|
||||||
|
|
||||||
|
assert dff_marche.height == 1
|
||||||
|
|
||||||
|
return dff_marche.to_dicts()[0], dff_titulaires.to_dicts()
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("marche_infos_1", "children"),
|
||||||
|
Output("marche_infos_2", "children"),
|
||||||
|
Output("marche_infos_titulaires", "children"),
|
||||||
|
Input("marche_data", "data"),
|
||||||
|
Input("titulaires_data", "data"),
|
||||||
|
)
|
||||||
|
def update_marche_info(marche, titulaires):
|
||||||
|
def make_parameter(col):
|
||||||
|
column_object = data_schema.get(col)
|
||||||
|
column_name = column_object.get("friendly_name") if column_object else col
|
||||||
|
|
||||||
|
if marche[col]:
|
||||||
|
if col == "acheteur_nom":
|
||||||
|
value = html.A(
|
||||||
|
href=f"/acheteurs/{marche['acheteur_id']}",
|
||||||
|
children=marche["acheteur_nom"],
|
||||||
|
)
|
||||||
|
elif col == "sourceDataset":
|
||||||
|
value = html.A(
|
||||||
|
href=marche["sourceFile"], children=marche["sourceDataset"]
|
||||||
|
)
|
||||||
|
column_name = "Source des données"
|
||||||
|
|
||||||
|
# Dates
|
||||||
|
elif col in ["dateNotification", "datePublicationDonnees"]:
|
||||||
|
print(marche[col])
|
||||||
|
|
||||||
|
value = datetime.fromisoformat(marche[col]).strftime("%d/%m/%Y")
|
||||||
|
|
||||||
|
# Listes
|
||||||
|
elif (
|
||||||
|
col
|
||||||
|
in [
|
||||||
|
"techniques",
|
||||||
|
"typesPrix",
|
||||||
|
"considerationsSociales",
|
||||||
|
"considerationsEnvironnementales",
|
||||||
|
]
|
||||||
|
and "," in marche[col]
|
||||||
|
):
|
||||||
|
col_values = marche[col].split(", ")
|
||||||
|
lines = []
|
||||||
|
for val in col_values:
|
||||||
|
lines.append(html.Li(val))
|
||||||
|
_content = html.Div(
|
||||||
|
[html.P([column_name, " : "]), html.Ul(children=lines)]
|
||||||
|
)
|
||||||
|
return _content
|
||||||
|
else:
|
||||||
|
value = marche.get(col)
|
||||||
|
else:
|
||||||
|
value = ""
|
||||||
|
|
||||||
|
param_content = html.P([column_name, " : ", html.Strong(value)])
|
||||||
|
return param_content
|
||||||
|
|
||||||
|
marche_infos = [
|
||||||
|
make_parameter("id"),
|
||||||
|
make_parameter("objet"),
|
||||||
|
make_parameter("dateNotification"), # date
|
||||||
|
make_parameter("nature"),
|
||||||
|
make_parameter("acheteur_nom"), # lien
|
||||||
|
make_parameter("montant"),
|
||||||
|
make_parameter("codeCPV"),
|
||||||
|
make_parameter("procedure"),
|
||||||
|
make_parameter("techniques"), # list
|
||||||
|
make_parameter("dureeMois"),
|
||||||
|
make_parameter("offresRecues"),
|
||||||
|
make_parameter("datePublicationDonnees"), # date
|
||||||
|
make_parameter("formePrix"),
|
||||||
|
make_parameter("typesPrix"), # list
|
||||||
|
make_parameter("attributionAvance"),
|
||||||
|
make_parameter("tauxAvance"),
|
||||||
|
make_parameter("marcheInnovant"), # label
|
||||||
|
make_parameter("modalitesExecution"),
|
||||||
|
make_parameter("considerationsSociales"), # list
|
||||||
|
make_parameter("considerationsEnvironnementales"), # list
|
||||||
|
make_parameter("ccag"),
|
||||||
|
make_parameter("sousTraitanceDeclaree"),
|
||||||
|
make_parameter("typeGroupementOperateurs"),
|
||||||
|
make_parameter("origineFrance"),
|
||||||
|
make_parameter("origineUE"),
|
||||||
|
make_parameter("idAccordCadre"),
|
||||||
|
make_parameter("sourceDataset"), # lien
|
||||||
|
]
|
||||||
|
|
||||||
|
half = round(len(marche_infos) / 2)
|
||||||
|
# pas inclus pour l'instant : lieu d'exécution, modifications
|
||||||
|
|
||||||
|
titulaires_lines = []
|
||||||
|
for titulaire in titulaires:
|
||||||
|
if titulaire["titulaire_typeIdentifiant"] == "SIRET":
|
||||||
|
content = html.Li(
|
||||||
|
html.A(
|
||||||
|
href=f"/titulaires/{titulaire['titulaire_id']}",
|
||||||
|
children=titulaire["titulaire_nom"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content = html.Li(titulaire["titulaire_nom"])
|
||||||
|
titulaires_lines.append(content)
|
||||||
|
|
||||||
|
return marche_infos[:half], marche_infos[half:], titulaires_lines
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
from dash import dcc, html, register_page
|
from dash import dcc, html, register_page
|
||||||
|
|
||||||
from src.figures import get_barchart_sources, get_map_count_marches
|
from src.figures import get_barchart_sources, get_map_count_marches
|
||||||
from src.utils import lf
|
from src.utils import lf, meta_content
|
||||||
|
|
||||||
title = "Statistiques"
|
name = "Statistiques"
|
||||||
|
|
||||||
register_page(
|
register_page(
|
||||||
__name__, path="/statistiques", title=f"decp.info - {title}", name=title, order=3
|
__name__,
|
||||||
|
path="/statistiques",
|
||||||
|
title=meta_content["title"],
|
||||||
|
name=name,
|
||||||
|
description=meta_content["description"],
|
||||||
|
image_url=meta_content["image_url"],
|
||||||
|
order=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -14,7 +20,7 @@ layout = [
|
|||||||
html.Div(
|
html.Div(
|
||||||
className="container",
|
className="container",
|
||||||
children=[
|
children=[
|
||||||
html.H2(title),
|
html.H2(name),
|
||||||
dcc.Loading(
|
dcc.Loading(
|
||||||
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
||||||
id="loading-statistques",
|
id="loading-statistques",
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
from dash import Input, Output, State, callback, dash_table, dcc, html, register_page
|
||||||
|
|
||||||
|
from src.figures import point_on_map
|
||||||
|
from src.utils import (
|
||||||
|
add_links_in_dict,
|
||||||
|
format_montant,
|
||||||
|
format_number,
|
||||||
|
get_annuaire_data,
|
||||||
|
get_departement_region,
|
||||||
|
lf,
|
||||||
|
meta_content,
|
||||||
|
setup_table_columns,
|
||||||
|
)
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path_template="/titulaires/<titulaire_id>",
|
||||||
|
title=meta_content["title"],
|
||||||
|
name="Titulaire",
|
||||||
|
description=meta_content["description"],
|
||||||
|
image_url=meta_content["image_url"],
|
||||||
|
order=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 21690123100011
|
||||||
|
|
||||||
|
layout = [
|
||||||
|
dcc.Store(id="titulaire_data", storage_type="memory"),
|
||||||
|
dcc.Location(id="url", refresh="callback-nav"),
|
||||||
|
html.Div(
|
||||||
|
className="container",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
className="wrapper",
|
||||||
|
children=[
|
||||||
|
html.H2(
|
||||||
|
className="org_title",
|
||||||
|
children=[
|
||||||
|
html.Span(id="titulaire_siret"),
|
||||||
|
" - ",
|
||||||
|
html.Span(id="titulaire_nom"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
className="org_year",
|
||||||
|
children=dcc.Dropdown(
|
||||||
|
id="titulaire_year",
|
||||||
|
options=["Toutes"]
|
||||||
|
+ [
|
||||||
|
str(year)
|
||||||
|
for year in range(
|
||||||
|
2018, int(datetime.date.today().year) + 1
|
||||||
|
)
|
||||||
|
],
|
||||||
|
placeholder="Année",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
className="org_infos",
|
||||||
|
children=[
|
||||||
|
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
|
||||||
|
html.P(["Commune : ", html.Strong(id="titulaire_commune")]),
|
||||||
|
html.P(
|
||||||
|
[
|
||||||
|
"Département : ",
|
||||||
|
html.Strong(id="titulaire_departement"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
html.P(["Région : ", html.Strong(id="titulaire_region")]),
|
||||||
|
html.A(
|
||||||
|
id="titulaire_lien_annuaire",
|
||||||
|
children="Plus de détails sur l'Annuaire des entreprises",
|
||||||
|
target="_blank",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
html.Div(
|
||||||
|
className="org_stats",
|
||||||
|
children=[
|
||||||
|
html.P(id="titulaire_titre_stats"),
|
||||||
|
html.P(id="titulaire_marches_remportes"),
|
||||||
|
html.P(id="titulaire_acheteurs_differents"),
|
||||||
|
html.Button(
|
||||||
|
"Téléchargement au format Excel",
|
||||||
|
id="btn-download-titulaire-data",
|
||||||
|
),
|
||||||
|
dcc.Download(id="download-titulaire-data"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
html.Div(className="org_map", id="titulaire_map"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# récupérer les données de l'acheteur sur l'api annuaire
|
||||||
|
html.H3("Derniers marchés publics remportés"),
|
||||||
|
html.Div(id="titulaire_last_marches", children=""),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="titulaire_siret", component_property="children"),
|
||||||
|
Output(component_id="titulaire_nom", component_property="children"),
|
||||||
|
Output(component_id="titulaire_commune", component_property="children"),
|
||||||
|
Output(component_id="titulaire_map", component_property="children"),
|
||||||
|
Output(component_id="titulaire_departement", component_property="children"),
|
||||||
|
Output(component_id="titulaire_region", component_property="children"),
|
||||||
|
Output(component_id="titulaire_lien_annuaire", component_property="href"),
|
||||||
|
Input(component_id="url", component_property="pathname"),
|
||||||
|
)
|
||||||
|
def update_titulaire_infos(url):
|
||||||
|
titulaire_siret = url.split("/")[-1]
|
||||||
|
if len(titulaire_siret) != 14:
|
||||||
|
titulaire_siret = (
|
||||||
|
f"Le SIRET renseigné doit faire 14 caractères ({titulaire_siret})"
|
||||||
|
)
|
||||||
|
data = get_annuaire_data(titulaire_siret)
|
||||||
|
data_etablissement = data["matching_etablissements"][0]
|
||||||
|
titulaire_map = point_on_map(
|
||||||
|
data_etablissement["latitude"], data_etablissement["longitude"]
|
||||||
|
)
|
||||||
|
code_departement, nom_departement, nom_region = get_departement_region(
|
||||||
|
data_etablissement["code_postal"]
|
||||||
|
)
|
||||||
|
departement = f"{nom_departement} ({code_departement})"
|
||||||
|
lien_annuaire = (
|
||||||
|
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
titulaire_siret,
|
||||||
|
data["nom_raison_sociale"],
|
||||||
|
data_etablissement["libelle_commune"],
|
||||||
|
titulaire_map,
|
||||||
|
departement,
|
||||||
|
nom_region,
|
||||||
|
lien_annuaire,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="titulaire_marches_remportes", component_property="children"),
|
||||||
|
Output(
|
||||||
|
component_id="titulaire_acheteurs_differents", component_property="children"
|
||||||
|
),
|
||||||
|
Input(component_id="titulaire_data", component_property="data"),
|
||||||
|
)
|
||||||
|
def update_titulaire_stats(data):
|
||||||
|
df = pl.DataFrame(data)
|
||||||
|
if df.height == 0:
|
||||||
|
df = pl.DataFrame(schema=lf.collect_schema())
|
||||||
|
df_marches = df.unique("uid")
|
||||||
|
nb_marches = format_number(df_marches.height)
|
||||||
|
# somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item()))
|
||||||
|
marches_remportes = [html.Strong(nb_marches), " marchés et accord-cadres remportés"]
|
||||||
|
# + ", pour un total de ", html.Strong(somme_marches + " €")]
|
||||||
|
del df_marches
|
||||||
|
|
||||||
|
nb_acheteurs = df.unique("acheteur_id").height
|
||||||
|
nb_acheteurs = [
|
||||||
|
html.Strong(format_number(nb_acheteurs)),
|
||||||
|
" titulaires (SIRET) différents",
|
||||||
|
]
|
||||||
|
del df
|
||||||
|
|
||||||
|
return marches_remportes, nb_acheteurs
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="titulaire_data", component_property="data"),
|
||||||
|
Input(component_id="url", component_property="pathname"),
|
||||||
|
Input(component_id="titulaire_year", component_property="value"),
|
||||||
|
)
|
||||||
|
def get_titulaire_marches_data(url, titulaire_year: str) -> pl.LazyFrame:
|
||||||
|
titulaire_siret = url.split("/")[-1]
|
||||||
|
lff = lf.filter(
|
||||||
|
(pl.col("titulaire_id") == titulaire_siret)
|
||||||
|
& (pl.col("titulaire_typeIdentifiant") == "SIRET")
|
||||||
|
)
|
||||||
|
lff = lff.fill_null("")
|
||||||
|
lff = lff.select(
|
||||||
|
"id",
|
||||||
|
"uid",
|
||||||
|
"objet",
|
||||||
|
"dateNotification",
|
||||||
|
"acheteur_id",
|
||||||
|
"acheteur_nom",
|
||||||
|
"montant",
|
||||||
|
"codeCPV",
|
||||||
|
"dureeMois",
|
||||||
|
)
|
||||||
|
if titulaire_year and titulaire_year != "Toutes":
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col("dateNotification").cast(pl.String).str.starts_with(titulaire_year)
|
||||||
|
)
|
||||||
|
lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True)
|
||||||
|
|
||||||
|
data = lff.collect(engine="streaming").to_dicts()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="titulaire_last_marches", component_property="children"),
|
||||||
|
Input(component_id="titulaire_data", component_property="data"),
|
||||||
|
)
|
||||||
|
def get_last_marches_table(data) -> html.Div:
|
||||||
|
columns = [
|
||||||
|
"uid",
|
||||||
|
"objet",
|
||||||
|
"dateNotification",
|
||||||
|
"acheteur_nom",
|
||||||
|
"montant",
|
||||||
|
"codeCPV",
|
||||||
|
"dureeMois",
|
||||||
|
]
|
||||||
|
|
||||||
|
dff = pl.DataFrame(data)
|
||||||
|
dff = format_montant(dff)
|
||||||
|
columns, tooltip = setup_table_columns(
|
||||||
|
dff, hideable=False, exclude=["acheteur_id", "id"]
|
||||||
|
)
|
||||||
|
data = dff.to_dicts()
|
||||||
|
# Idéalement on utiliserait add_org_links(), mais le résultat attendu
|
||||||
|
# est différent de home.py (Tableau)
|
||||||
|
data = add_links_in_dict(data, "acheteur")
|
||||||
|
|
||||||
|
table = html.Div(
|
||||||
|
className="marches_table",
|
||||||
|
id="titulaire_datatable",
|
||||||
|
children=dash_table.DataTable(
|
||||||
|
data=data,
|
||||||
|
markdown_options={"html": True},
|
||||||
|
page_action="native",
|
||||||
|
filter_action="native",
|
||||||
|
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
|
||||||
|
columns=columns,
|
||||||
|
tooltip_header=tooltip,
|
||||||
|
tooltip_duration=8000,
|
||||||
|
tooltip_delay=350,
|
||||||
|
cell_selectable=False,
|
||||||
|
page_size=10,
|
||||||
|
style_cell_conditional=[
|
||||||
|
{
|
||||||
|
"if": {"column_id": "objet"},
|
||||||
|
"minWidth": "300px",
|
||||||
|
"textAlign": "left",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"lineHeight": "14px",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {"column_id": "acheteur_nom"},
|
||||||
|
"maxWidth": "400px",
|
||||||
|
"textAlign": "left",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"lineHeight": "18px",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("download-titulaire-data", "data"),
|
||||||
|
Input("btn-download-titulaire-data", "n_clicks"),
|
||||||
|
State(component_id="titulaire_data", component_property="data"),
|
||||||
|
State(component_id="titulaire_nom", component_property="children"),
|
||||||
|
State(component_id="titulaire_year", component_property="value"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def download_titulaire_data(
|
||||||
|
n_clicks,
|
||||||
|
data: [dict],
|
||||||
|
titulaire_nom: str,
|
||||||
|
annee: str,
|
||||||
|
):
|
||||||
|
df_to_download = pl.DataFrame(data)
|
||||||
|
|
||||||
|
def to_bytes(buffer):
|
||||||
|
df_to_download.write_excel(
|
||||||
|
buffer, worksheet="DECP" if annee in ["Toutes", None] else annee
|
||||||
|
)
|
||||||
|
|
||||||
|
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
||||||
|
return dcc.send_bytes(to_bytes, filename=f"decp_{titulaire_nom}_{date}.xlsx")
|
||||||
+207
-21
@@ -1,14 +1,13 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
import polars.selectors as cs
|
import polars.selectors as cs
|
||||||
from dotenv import load_dotenv
|
from httpx import get
|
||||||
from polars.exceptions import ComputeError
|
from polars.exceptions import ComputeError
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
operators = [
|
operators = [
|
||||||
["s<", "<"],
|
["s<", "<"],
|
||||||
["s>", ">"],
|
["s>", ">"],
|
||||||
@@ -40,21 +39,21 @@ def split_filter_part(filter_part):
|
|||||||
return [None] * 3
|
return [None] * 3
|
||||||
|
|
||||||
|
|
||||||
def add_resource_link(lff: pl.LazyFrame) -> pl.LazyFrame:
|
def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
|
||||||
lff = lff.with_columns(
|
dff = dff.with_columns(
|
||||||
(
|
(
|
||||||
'<a href="' + pl.col("sourceFile") + '">' + pl.col("sourceDataset") + "</a>"
|
'<a href="' + pl.col("sourceFile") + '">' + pl.col("sourceDataset") + "</a>"
|
||||||
).alias("source")
|
).alias("source")
|
||||||
)
|
)
|
||||||
lff = lff.drop(["sourceFile", "sourceDataset"])
|
dff = dff.drop(["sourceFile", "sourceDataset"])
|
||||||
return lff
|
return dff
|
||||||
|
|
||||||
|
|
||||||
def add_annuaire_link(lff: pl.LazyFrame):
|
def add_links(dff: pl.DataFrame):
|
||||||
lff = lff.with_columns(
|
dff = dff.with_columns(
|
||||||
pl.when(pl.col("titulaire_typeIdentifiant") == "SIRET")
|
pl.when(pl.col("titulaire_typeIdentifiant") == "SIRET")
|
||||||
.then(
|
.then(
|
||||||
'<a href = "https://annuaire-entreprises.data.gouv.fr/etablissement/'
|
'<a href = "/titulaires/'
|
||||||
+ pl.col("titulaire_id")
|
+ pl.col("titulaire_id")
|
||||||
+ '">'
|
+ '">'
|
||||||
+ pl.col("titulaire_id")
|
+ pl.col("titulaire_id")
|
||||||
@@ -63,16 +62,31 @@ def add_annuaire_link(lff: pl.LazyFrame):
|
|||||||
.otherwise(pl.col("titulaire_id"))
|
.otherwise(pl.col("titulaire_id"))
|
||||||
.alias("titulaire_id")
|
.alias("titulaire_id")
|
||||||
)
|
)
|
||||||
lff = lff.with_columns(
|
|
||||||
|
for column, path in [("acheteur_id", "acheteurs"), ("uid", "marches")]:
|
||||||
|
dff = dff.with_columns(
|
||||||
(
|
(
|
||||||
'<a href = "https://annuaire-entreprises.data.gouv.fr/etablissement/'
|
f'<a href = "/{path}/'
|
||||||
+ pl.col("acheteur_id")
|
+ pl.col(column)
|
||||||
+ '">'
|
+ '" target="_blank">'
|
||||||
+ pl.col("acheteur_id")
|
+ pl.col(column)
|
||||||
+ "</a>"
|
+ "</a>"
|
||||||
).alias("acheteur_id")
|
).alias(column)
|
||||||
)
|
)
|
||||||
return lff
|
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:
|
def booleans_to_strings(lff: pl.LazyFrame) -> pl.LazyFrame:
|
||||||
@@ -96,11 +110,53 @@ def numbers_to_strings(lff: pl.LazyFrame) -> pl.LazyFrame:
|
|||||||
return lff
|
return lff
|
||||||
|
|
||||||
|
|
||||||
|
def dates_to_strings(lff: pl.LazyFrame, column: str) -> pl.LazyFrame:
|
||||||
|
"""
|
||||||
|
Convert a date column to string type.
|
||||||
|
"""
|
||||||
|
lff = lff.with_columns(pl.col(column).cast(pl.String).fill_null(""))
|
||||||
|
return lff
|
||||||
|
|
||||||
|
|
||||||
def format_number(number) -> str:
|
def format_number(number) -> str:
|
||||||
number = "{:,}".format(number).replace(",", " ")
|
number = "{:,}".format(number).replace(",", " ")
|
||||||
return number
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def format_montant(dff: pl.DataFrame) -> pl.DataFrame:
|
||||||
|
def format_function(expr, scale=None):
|
||||||
|
# https://stackoverflow.com/a/78636786
|
||||||
|
expr = expr.cast(pl.String).str.splitn(".", 2)
|
||||||
|
|
||||||
|
num = expr.struct[0]
|
||||||
|
frac = expr.struct[1]
|
||||||
|
|
||||||
|
# Ajout des espaces
|
||||||
|
num = (
|
||||||
|
num.str.reverse()
|
||||||
|
.str.replace_all(r"\d{3}", "$0 ")
|
||||||
|
.str.reverse()
|
||||||
|
.str.replace(r"^ ", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
frac: pl.Expr = (
|
||||||
|
pl.when(frac.is_not_null() & ~frac.is_in(["0"]))
|
||||||
|
.then("," + frac)
|
||||||
|
.otherwise(pl.lit(""))
|
||||||
|
)
|
||||||
|
|
||||||
|
return num + frac + pl.lit(" €")
|
||||||
|
|
||||||
|
dff = dff.with_columns(pl.col("montant").pipe(format_function).alias("montant"))
|
||||||
|
return dff
|
||||||
|
|
||||||
|
|
||||||
|
def get_annuaire_data(siret: str) -> dict:
|
||||||
|
url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}"
|
||||||
|
response = get(url)
|
||||||
|
return response.json()["results"][0]
|
||||||
|
|
||||||
|
|
||||||
def get_decp_data() -> pl.LazyFrame:
|
def get_decp_data() -> pl.LazyFrame:
|
||||||
# Chargement du fichier parquet
|
# Chargement du fichier parquet
|
||||||
# Le fichier est chargé en mémoire, ce qui est plus rapide qu'une base de données pour le moment.
|
# Le fichier est chargé en mémoire, ce qui est plus rapide qu'une base de données pour le moment.
|
||||||
@@ -117,13 +173,143 @@ def get_decp_data() -> pl.LazyFrame:
|
|||||||
sleep(10)
|
sleep(10)
|
||||||
lff: pl.LazyFrame = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
|
lff: pl.LazyFrame = pl.scan_parquet(os.getenv("DATA_FILE_PARQUET_PATH"))
|
||||||
|
|
||||||
# Remplacement des valeurs numériques par des chaînes de caractères
|
|
||||||
# lff = numbers_to_strings(lff)
|
|
||||||
|
|
||||||
# Tri des marchés par date de notification
|
# Tri des marchés par date de notification
|
||||||
lff = lff.sort(by=["datePublicationDonnees"], descending=True, nulls_last=True)
|
lff = lff.sort(by=["dateNotification", "uid"], descending=True, nulls_last=True)
|
||||||
|
|
||||||
|
# Uniquement les données actuelles, pas les anciennes versions de marchés
|
||||||
|
lff = lff.filter(pl.col("donneesActuelles")).drop("donneesActuelles")
|
||||||
|
|
||||||
|
# Convertir les colonnes booléennes en chaînes de caractères
|
||||||
|
lff = booleans_to_strings(lff)
|
||||||
|
|
||||||
|
# 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
|
return lff
|
||||||
|
|
||||||
|
|
||||||
|
def get_departements() -> dict:
|
||||||
|
with open("data/departements.json", "rb") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_departement_region(code_postal):
|
||||||
|
if code_postal > "97000":
|
||||||
|
code_departement = code_postal[:3]
|
||||||
|
else:
|
||||||
|
code_departement = code_postal[:2]
|
||||||
|
nom_departement = departements[code_departement]["departement"]
|
||||||
|
nom_region = departements[code_departement]["region"]
|
||||||
|
return code_departement, nom_departement, nom_region
|
||||||
|
|
||||||
|
|
||||||
|
def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
|
||||||
|
schema = lff.collect_schema()
|
||||||
|
filtering_expressions = filter_query.split(" && ")
|
||||||
|
for filter_part in filtering_expressions:
|
||||||
|
col_name, operator, filter_value = split_filter_part(filter_part)
|
||||||
|
col_type = str(schema[col_name])
|
||||||
|
print("filter_value:", filter_value)
|
||||||
|
print("filter_value_type:", type(filter_value))
|
||||||
|
print("col_type:", col_type)
|
||||||
|
|
||||||
|
if col_type == "Date":
|
||||||
|
# Convertir la colonne en chaînes de caractères
|
||||||
|
lff = dates_to_strings(lff, col_name)
|
||||||
|
|
||||||
|
if operator in ("<", "<=", ">", ">="):
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col(col_name).is_not_null() & (pl.col(col_name) != pl.lit(""))
|
||||||
|
)
|
||||||
|
if operator == "<":
|
||||||
|
lff = lff.filter(pl.col(col_name) < filter_value)
|
||||||
|
elif operator == ">":
|
||||||
|
lff = lff.filter(pl.col(col_name) > filter_value)
|
||||||
|
elif operator == ">=":
|
||||||
|
lff = lff.filter(pl.col(col_name) >= filter_value)
|
||||||
|
elif operator == "<=":
|
||||||
|
lff = lff.filter(pl.col(col_name) <= filter_value)
|
||||||
|
|
||||||
|
elif col_type.startswith("Int") or col_type.startswith("Float"):
|
||||||
|
try:
|
||||||
|
filter_value = int(filter_value)
|
||||||
|
except ValueError:
|
||||||
|
logger.error(f"Invalid numeric filter value: {filter_value}")
|
||||||
|
continue
|
||||||
|
lff = lff.filter(pl.col(col_name) == filter_value)
|
||||||
|
|
||||||
|
elif operator == "contains" and col_type in ["String", "Date"]:
|
||||||
|
lff = lff.filter(pl.col(col_name).str.contains("(?i)" + filter_value))
|
||||||
|
|
||||||
|
# elif operator == 'datestartswith':
|
||||||
|
# lff = lff.filter(pl.col(col_name).str.startswith(filter_value)")
|
||||||
|
|
||||||
|
return lff
|
||||||
|
|
||||||
|
|
||||||
|
def sort_table_data(lff: pl.LazyFrame, sort_by: list) -> pl.LazyFrame:
|
||||||
|
lff = lff.sort(
|
||||||
|
[col["column_id"] for col in sort_by],
|
||||||
|
descending=[col["direction"] == "desc" for col in sort_by],
|
||||||
|
nulls_last=True,
|
||||||
|
)
|
||||||
|
print(sort_by)
|
||||||
|
return lff
|
||||||
|
|
||||||
|
|
||||||
|
def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tuple:
|
||||||
|
# Liste finale de colonnes
|
||||||
|
columns = []
|
||||||
|
tooltip = {}
|
||||||
|
for column_id in dff.columns:
|
||||||
|
if exclude and column_id in exclude:
|
||||||
|
continue
|
||||||
|
column_object = data_schema.get(column_id)
|
||||||
|
if column_object:
|
||||||
|
column_name = column_object.get("friendly_name", column_id)
|
||||||
|
else:
|
||||||
|
column_name = column_id
|
||||||
|
|
||||||
|
column = {
|
||||||
|
"name": column_name,
|
||||||
|
"id": column_id,
|
||||||
|
"presentation": "markdown",
|
||||||
|
"type": "text",
|
||||||
|
"format": {"nully": "N/A"},
|
||||||
|
"hideable": hideable,
|
||||||
|
}
|
||||||
|
columns.append(column)
|
||||||
|
|
||||||
|
if column_object:
|
||||||
|
tooltip[column_id] = {
|
||||||
|
"value": f"""**{column_object.get("friendly_name")}** ({column_id})
|
||||||
|
|
||||||
|
"""
|
||||||
|
+ column_object["description"],
|
||||||
|
"type": "markdown",
|
||||||
|
}
|
||||||
|
return columns, tooltip
|
||||||
|
|
||||||
|
|
||||||
lf = get_decp_data()
|
lf = get_decp_data()
|
||||||
|
departements = get_departements()
|
||||||
|
domain_name = (
|
||||||
|
"test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"
|
||||||
|
)
|
||||||
|
meta_content = {
|
||||||
|
"image_url": f"https://{domain_name}/assets/decp.info.png",
|
||||||
|
"title": "decp.info - exploration des marchés publics français",
|
||||||
|
"description": (
|
||||||
|
"Explorez et analysez les données des marchés publics français avec cet outil libre et gratuit. "
|
||||||
|
"Pour une commande publique accessible à toutes et tous."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Récupération du schéma des données tabulaires
|
||||||
|
data_schema: dict = get(os.getenv("DATA_SCHEMA_PATH"), follow_redirects=True).json()
|
||||||
|
data_schema["source"] = {
|
||||||
|
"description": "Code de la source des données, avec un lien vers le fichier Open Data dont proviennent les données de ce marché public.",
|
||||||
|
"friendly_name": "Source des données",
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user