Compare commits

..

33 Commits

Author SHA1 Message Date
Colin Maudry 021361e2c1 Màj CHANGELOG.md 2025-12-24 11:34:23 +01:00
Colin Maudry 04ec9438f1 Liste de colonnes dans les URL plus compacte #58 2025-12-24 11:32:15 +01:00
Colin Maudry e6960dc16d Merge branch 'main' into dev 2025-12-24 11:05:28 +01:00
Colin Maudry 2fb71e466c Exemple d'URL dans le CHANGELOG 2025-12-24 11:02:35 +01:00
Colin Maudry b47463b638 L'URL de partage contient les colonnes à afficher, non les colonnes à masquer #58 2025-12-24 10:50:21 +01:00
Colin Maudry 84cf48182d Possibilité de filtrer une colonne avec plusieurs mots 2025-12-24 10:16:25 +01:00
Colin Maudry 0f11a575ff Merge tag 'v2.3.0' into dev
- Possibilité de filtrer, trier etc. dans les vues acheteur et titulaire
- Possibilité de partager les filtres, tris et choix de colonnes via une adresse Web
2025-12-24 09:46:48 +01:00
Colin Maudry c9459771ac Merge branch 'release/2.3.0' 2025-12-24 09:45:55 +01:00
Colin Maudry 99eef0eaa3 Ajout mode d'emploi #58 2025-12-24 09:40:15 +01:00
Colin Maudry 4719ef5754 Ajout de copy.svg 2025-12-24 09:35:55 +01:00
Colin Maudry 032ca5554e Déplacement des notes de version vers CHANGELOG.md 2025-12-24 09:31:33 +01:00
Colin Maudry 90d66bcbb6 Merge branch 'feature/58_filter_url' into dev 2025-12-18 04:25:39 +01:00
Colin Maudry 43104f611e Réparation des callbacks, mode d'emploi #58 2025-12-18 04:23:53 +01:00
Colin Maudry 24ef21761f Gestion des annonces dans .env 2025-12-18 01:37:58 +01:00
Colin Maudry e0fa8464d2 Merge tag 'v2.2.3' into dev
- mise à jour de l'adresse email de contact (colmo.tech)
- message sur l'indisponibilité des données MINEF
2025-12-04 16:40:14 +01:00
Colin Maudry 0691329b04 Merge branch 'hotfix/2.2.3' 2025-12-04 16:38:38 +01:00
Colin Maudry 359ba248ac Changelog 2.2.3 2025-12-04 16:38:19 +01:00
Colin Maudry 19d7735306 Message informant du problème avec les données MINEF 2025-12-04 16:34:56 +01:00
Colin Maudry 43fd2df2c0 Suggested by Gemini Pro, but race condition: filters apply before columns are created #58
Prompt: - In the tableau page (src/pages/tableau.py), I want to allow users to copy a URL in their clipboard that enables opening the page with the same view: applied filters, sorting and column selection of the datatable. Typically to share the view with a colleague via email or chat.
  In terms of Dash callbacks, that would mean the following:
	- one callback syncs the filters, the sort parameters and the selection of columns with a read-only text input that stores the URL to copy. This callback also ensures the button to copy to the clipboard is visible, possibly replacing the confirmation message (see next point)
	- one callback reacts to clicks on a button to store the URLin the clipboard. This button in on the same row as "Télécharger au format Excel" button. When clicked, the URL is stored in the clipboard, and the button is replaced with a confrmation message (URL copiée)
	- one callback reads the URL, and applies the filters, sorting and column selection
- The URL stores the view configuration (filters, sorting and columns) in URL parameters, similar to what is done in REST APIs. The values are the same as stored in the DataTable parameters, just URL encoded. The URL parameters are in French: filtres, tris, colonnes.
2025-12-03 16:31:05 +01:00
Colin Maudry 9ae6f29391 Plus de tolérance dans l'ingestion des data dicts 2025-11-24 16:54:50 +01:00
Colin Maudry 80a7e51ec4 Merge branch 'feature/share_filtering_datatables' into dev 2025-11-24 16:01:34 +01:00
Colin Maudry e216b42379 Meilleure gestion des df vides acheteur/titulaire 2025-11-24 16:00:21 +01:00
Colin Maudry 7f42ca67a2 Bonne configuration des boutons de téléchargemetn 2025-11-24 15:33:27 +01:00
Colin Maudry ec15852406 Téléchargemetn filtré OK dans acheteur 2025-11-24 14:00:28 +01:00
Colin Maudry 8c98a60c75 filtres, tris fonctionnels (acheteur) 2025-11-24 13:43:42 +01:00
Colin Maudry 565a0cfd08 Custom filter logic 2025-11-24 11:17:05 +01:00
Colin Maudry 53919bff3e Cast acheteur_year to int et non dateNotification to string 2025-11-24 10:41:47 +01:00
Colin Maudry 193cf5302c Merge branch 'main' into dev 2025-11-24 09:38:27 +01:00
Colin Maudry fd2f87e382 Annonce fichiers excel fonctionnels 2025-11-24 09:37:35 +01:00
Colin Maudry 59e363a1fc Merge tag 'v2.2.2' into dev
- Correction d'un bug dans le téléchargement Excel
2025-11-22 18:49:11 +01:00
Colin Maudry a51d2a83b4 Nettoyage 2025-11-18 15:37:47 +01:00
Colin Maudry 6b133c21ab Classe partagée pour la génération de datatables #56 2025-11-15 21:08:28 +01:00
Colin Maudry 503ec33f43 Classe partagée pour la génération de datatables #56 2025-11-15 21:01:59 +01:00
15 changed files with 772 additions and 498 deletions
+3
View File
@@ -3,6 +3,9 @@ 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"
# Annonce dans l'en-tête du site
ANNOUNCEMENTS=
# Chemin vers le schéma de données # 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 DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a
+133
View File
@@ -0,0 +1,133 @@
#### 2.3.0 (24 décembre 2025)
- Possibilité de filtrer, trier etc. dans les vues acheteur et titulaire
- Possibilité de partager les filtres, tris et choix de colonnes via une adresse Web ([exemple](https://decp.info/tableau?filtres=%7Bacheteur_nom%7D+icontains+centre+%26%26+%7BdureeMois%7D+icontains+24&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2Cacheteur_departement_code%2CsourceDataset))
- Possibilité de filtrer une colonne avec plusieurs mots
##### 2.2.3 (4 décembre 2025)
- mise à jour de l'adresse email de contact (colmo.tech)
- message sur l'indisponibilité des données MINEF
##### 2.2.2 (22 novembre 2025)
- Correction d'un bug dans le téléchargement Excel
##### 2.2.1 (15 novembre 2025)
- Le moteur de recherche ignore les tirets ("franche comté" trouve "Bourgogne-Franche-Comté)
- Phrase "tagline" au-dessus du champ de recherche
- Les infos de Contact rebasculent dans À propos
- Police de caractère "Open Sans" généralisée
#### 2.2.0 (13 novembre 2025)
- Moteur de recherche (acheteurs et titulaires) en page d'accueil ([#58](https://github.com/ColinMaudry/decp.info/issues/58))
- Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/decp.info/issues/55)))
- Moins de colonnes affichées par défaut dans Tableau ([#54](https://github.com/ColinMaudry/decp.info/issues/54))
##### 2.1.7 (11 novembre 2025)
- Remplacement du formulaire de contact par une adresse email
##### 2.1.6 (15 octobre 2025)
- Stabilisation de la vue marché
##### 2.1.5 (10 octobre 2025)
- réparation des filtres (notamment < > sur les montants)
- remplacement des valeurs "null" dans les tableaux par des cellules vides
##### 2.1.4 (8 octobre 2025)
- possibilité de filtrer sur le champ "Source"
- création automatique d'une release Github quand je push un tag
##### 2.1.3 (4 octobre 2025)
- tentative d'auto-release à chaque création de tag git
- adaptation au format TableSchema
##### 2.1.2 (3 octobre 2025)
- dataframe global plutôt que lazyframe, pour plus de résilience et charger toutes les données en mémoire
##### 2.1.1 (1er octobre 2025)
- ajout d'une section dans À propos sur la qualité et l'exhaustivité des données ([#43](https://github.com/ColinMaudry/decp.info/issues/43))
- ajout du nombre de marchés en plus du nombre de lignes dans la vue Tableau
#### 2.1.0 (30 septembre 2025)
- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/decp.info/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786) ([#35](https://github.com/ColinMaudry/decp.info/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/decp.info/issues/40)) 🔎
- Ajout des balises HTML meta Open Graph et Twitter ([#39](https://github.com/ColinMaudry/decp.info/issues/39)) pour de beaux aperçus de liens 🖼️
- Formulaire de contact ([#48](https://github.com/ColinMaudry/decp.info/issues/48)) 📨
- Nom de colonnes plus_agréables ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 💅
- Définition des colonnes quand vous passez votre souris sur les en-têtes ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 📖
- Affichage du numéro de version près du logo et lien vers ici 🤓
- Variables globales uniquement en lecture (😁)
##### 2.0.1 (23 septembre 2025)
- Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/decp.info/issues/38)
- Amélioration du script de déploiement (deploy.sh)
- Meilleures instructions d'installation et lancement
- Coquilles 🐚
### 2.0.0 (23 septembre 2025)
- détails des sources de données
- section "À propos" plus développée
- correction de bugs dans les filtres de la data table
#### 2.0.0-alpha
- Data table fonctionnelle
### 1.5.0 (28/01/2023
- fixation des dépendances Python pour plus de stabilité en cas de réinstallation (Pipfile)
#### 1.4.1 (14/06/2021)
- ajout des traductions des opérations de filtrage à toutes les vues, pas seulement /db/decp
### 1.4.0 (14/06/2021)
- traduction des opérations de filtrage (ex : contains => contient)
- élargissement des menus de filtrage
- correction du titre de la page des notes de versions
### 1.3.0 (03/06/2021)
- utilisation de noms de colonnes plus lisibles dans l'application
- suppression des références à la licence et aux données source sur la page d'accueil
- correction des liens vers le code source
- correction de l'indentation des puces dans les notes de version
### 1.2.0 (28/05/2021)
- ajout d'une page "Notes de version"
- meilleur lien pour la documentation des champs
- déplacement du code de decp.info depuis [ColinMaudry/decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) vers [ColinMaudry/decp.info](https://github.com/ColinMaudry/decp.info)
### 1.1.0 (25/05/2021)
- ajout de nouvelles vues :
- Marchés publics sans leurs titulaires : vue dédiée aux titulaires de marchés avec des données provenant du répertoire SIRENE
- Données sur les titulaires et géolocalisation : vue sans les titulaires pour analyser les nombres de marchés et les montants
- amélioration de la page d'accueil
- développement de la page "db" avec description des vues et liste des colonnes
- les codes APE sont cliquables
- ajout des mentions légales
- ajout d'un formulatire d'inscription à une lettre d'information
- correction de bugs :
- correction du format de certaines dates dans les données
### 1.0.0
- publication sur https://decp.info
- ajout d'une vue équivalente au format DECP réglementaire
- personnalisation de datasette
- script de conversion quotidien basé sur [dataflows](https://github.com/datahq/dataflows)
+3 -125
View File
@@ -1,8 +1,7 @@
# decp.info # decp.info
> v2.2.2 > v2.3.0
> Outil d'exploration et de téléchargement des données essentielles de la commande publique.
Outil d'exploration et de téléchargement des données essentielles de la commande publique.
=> [decp.info](https://decp.info) => [decp.info](https://decp.info)
@@ -38,125 +37,4 @@ Ne pas oublier de mettre à jour les fichier .env.
## Notes de version ## Notes de version
##### 2.2.2 (22 novembre 2025) Voir [CHANGELOG](https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md).
- Correction d'un bug dans le téléchargement Excel
##### 2.2.1 (15 novembre 2025)
- Le moteur de recherche ignore les tirets ("franche comté" trouve "Bourgogne-Franche-Comté)
- Phrase "tagline" au-dessus du champ de recherche
- Les infos de Contact rebasculent dans À propos
- Police de caractère "Open Sans" généralisée
#### 2.2.0 (13 novembre 2025)
- Moteur de recherche (acheteurs et titulaires) en page d'accueil ([#58](https://github.com/ColinMaudry/decp.info/issues/58))
- Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/decp.info/issues/55)))
- Moins de colonnes affichées par défaut dans Tableau ([#54](https://github.com/ColinMaudry/decp.info/issues/54))
##### 2.1.7 (11 novembre 2025)
- Remplacement du formulaire de contact par une adresse email
##### 2.1.6 (15 octobre 2025)
- Stabilisation de la vue marché
##### 2.1.5 (10 octobre 2025)
- réparation des filtres (notamment < > sur les montants)
- remplacement des valeurs "null" dans les tableaux par des cellules vides
##### 2.1.4 (8 octobre 2025)
- possibilité de filtrer sur le champ "Source"
- création automatique d'une release Github quand je push un tag
##### 2.1.3 (4 octobre 2025)
- tentative d'auto-release à chaque création de tag git
- adaptation au format TableSchema
##### 2.1.2 (3 octobre 2025)
- dataframe global plutôt que lazyframe, pour plus de résilience et charger toutes les données en mémoire
##### 2.1.1 (1er octobre 2025)
- ajout d'une section dans À propos sur la qualité et l'exhaustivité des données ([#43](https://github.com/ColinMaudry/decp.info/issues/43))
- ajout du nombre de marchés en plus du nombre de lignes dans la vue Tableau
#### 2.1.0 (30 septembre 2025)
- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/decp.info/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786) ([#35](https://github.com/ColinMaudry/decp.info/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/decp.info/issues/40)) 🔎
- Ajout des balises HTML meta Open Graph et Twitter ([#39](https://github.com/ColinMaudry/decp.info/issues/39)) pour de beaux aperçus de liens 🖼️
- Formulaire de contact ([#48](https://github.com/ColinMaudry/decp.info/issues/48)) 📨
- Nom de colonnes plus_agréables ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 💅
- Définition des colonnes quand vous passez votre souris sur les en-têtes ([#33](https://github.com/ColinMaudry/decp.info/issues/33)) 📖
- Affichage du numéro de version près du logo et lien vers ici 🤓
- Variables globales uniquement en lecture (😁)
##### 2.0.1 (23 septembre 2025)
- Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/decp.info/issues/38)
- Amélioration du script de déploiement (deploy.sh)
- Meilleures instructions d'installation et lancement
- Coquilles 🐚
### 2.0.0 (23 septembre 2025)
- détails des sources de données
- section "À propos" plus développée
- correction de bugs dans les filtres de la data table
#### 2.0.0-alpha
- Data table fonctionnelle
### 1.5.0 (28/01/2023
- fixation des dépendances Python pour plus de stabilité en cas de réinstallation (Pipfile)
#### 1.4.1 (14/06/2021)
- ajout des traductions des opérations de filtrage à toutes les vues, pas seulement /db/decp
### 1.4.0 (14/06/2021)
- traduction des opérations de filtrage (ex : contains => contient)
- élargissement des menus de filtrage
- correction du titre de la page des notes de versions
### 1.3.0 (03/06/2021)
- utilisation de noms de colonnes plus lisibles dans l'application
- suppression des références à la licence et aux données source sur la page d'accueil
- correction des liens vers le code source
- correction de l'indentation des puces dans les notes de version
### 1.2.0 (28/05/2021)
- ajout d'une page "Notes de version"
- meilleur lien pour la documentation des champs
- déplacement du code de decp.info depuis [ColinMaudry/decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) vers [ColinMaudry/decp.info](https://github.com/ColinMaudry/decp.info)
### 1.1.0 (25/05/2021)
- ajout de nouvelles vues :
- Marchés publics sans leurs titulaires : vue dédiée aux titulaires de marchés avec des données provenant du répertoire SIRENE
- Données sur les titulaires et géolocalisation : vue sans les titulaires pour analyser les nombres de marchés et les montants
- amélioration de la page d'accueil
- développement de la page "db" avec description des vues et liste des colonnes
- les codes APE sont cliquables
- ajout des mentions légales
- ajout d'un formulatire d'inscription à une lettre d'information
- correction de bugs :
- correction du format de certaines dates dans les données
### 1.0.0
- publication sur https://decp.info
- ajout d'une vue équivalente au format DECP réglementaire
- personnalisation de datasette
- script de conversion quotidien basé sur [dataflows](https://github.com/datahq/dataflows)
+3 -3
View File
@@ -1,13 +1,13 @@
[project] [project]
name = "decp.info" name = "decp.info"
description = "Interface d'exploration et d'analyse des marchés publics français." description = "Interface d'exploration et d'analyse des marchés publics français."
version = "2.2.2" version = "2.3.0"
requires-python = ">= 3.10" requires-python = ">= 3.10"
authors = [ authors = [
{ name = "Colin Maudry", email = "colin+decp@maudry.com" } { name = "Colin Maudry", email = "colin@colmo.tech" }
] ]
dependencies = [ dependencies = [
"dash==3.2.0", "dash==3.3.0",
"dash[compress]", "dash[compress]",
"polars", "polars",
"gunicorn", "gunicorn",
+3 -2
View File
@@ -1,4 +1,5 @@
import logging import logging
import os
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
import tomllib import tomllib
@@ -82,7 +83,7 @@ app.layout = html.Div(
html.P( html.P(
children=html.A( children=html.A(
version, version,
href="https://github.com/ColinMaudry/decp.info?tab=readme-ov-file#notes-de-version", href="https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md",
target="_blank", target="_blank",
), ),
className="version", className="version",
@@ -92,7 +93,7 @@ app.layout = html.Div(
), ),
html.Div( html.Div(
id="announcements", id="announcements",
children=dcc.Markdown(""), children=dcc.Markdown(os.getenv("ANNOUNCEMENTS")),
), ),
html.Div( html.Div(
[ [
+1
View File
@@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="copy" class="svg-inline--fa fa-copy fa-w-14 " role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M433.941 65.941l-51.882-51.882A48 48 0 0 0 348.118 0H176c-26.51 0-48 21.49-48 48v48H48c-26.51 0-48 21.49-48 48v320c0 26.51 21.49 48 48 48h224c26.51 0 48-21.49 48-48v-48h80c26.51 0 48-21.49 48-48V99.882a48 48 0 0 0-14.059-33.941zM266 464H54a6 6 0 0 1-6-6V150a6 6 0 0 1 6-6h74v224c0 26.51 21.49 48 48 48h96v42a6 6 0 0 1-6 6zm128-96H182a6 6 0 0 1-6-6V54a6 6 0 0 1 6-6h106v88c0 13.255 10.745 24 24 24h88v202a6 6 0 0 1-6 6zm6-256h-64V48h9.632c1.591 0 3.117.632 4.243 1.757l48.368 48.368a6 6 0 0 1 1.757 4.243V112z"></path></svg>

After

Width:  |  Height:  |  Size: 738 B

+7 -1
View File
@@ -133,6 +133,12 @@ td[data-dash-column="objet"],td[data-dash-column="titulaire_nom"],td[data-dash-c
.marches_table { .marches_table {
font-family: "Open Sans", sans-serif; font-family: "Open Sans", sans-serif;
} }
.marches_table.stuck {
position: relative;
right: 200px;
}
.marches_table .cell-table tr:nth-child(even) td { .marches_table .cell-table tr:nth-child(even) td {
background-color: #feeeee; background-color: #feeeee;
font-family: "Open Sans", sans-serif; font-family: "Open Sans", sans-serif;
@@ -212,7 +218,7 @@ summary > h3 {
} }
#_pages_content { #_pages_content {
padding-top: 28px; padding: 28px 24px 0 24px;
} }
/* Vue acheteur/titulaire/recherche */ /* Vue acheteur/titulaire/recherche */
+6 -35
View File
@@ -1,11 +1,12 @@
import polars as pl import polars as pl
from dash import dash_table, html from dash import html
from src.figures import DataTable
from utils import add_links_in_dict, format_values, setup_table_columns from utils import add_links_in_dict, format_values, setup_table_columns
def get_top_org_table(data, org_type: str): def get_top_org_table(data, org_type: str):
dff = pl.DataFrame(data) dff = pl.DataFrame(data, strict=False, infer_schema_length=5000)
if dff.height == 0: if dff.height == 0:
return html.Div() return html.Div()
@@ -15,7 +16,7 @@ def get_top_org_table(data, org_type: str):
dff_nb = dff.group_by(f"{org_type}_id", f"{org_type}_nom", "distance").agg( dff_nb = dff.group_by(f"{org_type}_id", f"{org_type}_nom", "distance").agg(
pl.len().alias("Attributions"), pl.sum("montant").alias("montant") pl.len().alias("Attributions"), pl.sum("montant").alias("montant")
) )
dff_nb = dff_nb.sort(by="montant", descending=True) dff_nb = dff_nb.sort(by="montant", descending=True, nulls_last=True)
dff_nb = dff_nb.cast(pl.String) dff_nb = dff_nb.cast(pl.String)
dff_nb = dff_nb.fill_null("") dff_nb = dff_nb.fill_null("")
dff_nb = format_values(dff_nb) dff_nb = format_values(dff_nb)
@@ -25,41 +26,11 @@ def get_top_org_table(data, org_type: str):
data = dff_nb.to_dicts() data = dff_nb.to_dicts()
data = add_links_in_dict(data, f"{org_type}") data = add_links_in_dict(data, f"{org_type}")
return dash_table.DataTable( return DataTable(
dtid=f"top10_{org_type}",
data=data, data=data,
markdown_options={"html": True},
page_action="native", page_action="native",
page_size=10, page_size=10,
columns=columns, columns=columns,
cell_selectable=False,
tooltip_header=tooltip, tooltip_header=tooltip,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
"fontSize": "85%",
},
{
"if": {"column_id": "acheteur_nom"},
"minWidth": "200px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "16px",
# "fontSize": "85%",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "200px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "16px",
"whiteSpace": "normal",
# "fontSize": "85%",
},
],
) )
+67
View File
@@ -1,4 +1,5 @@
import json import json
from typing import Literal
import plotly.express as px import plotly.express as px
import polars as pl import polars as pl
@@ -192,3 +193,69 @@ def point_on_map(lat, lon):
graph = dcc.Graph(id="map", figure=fig) graph = dcc.Graph(id="map", figure=fig)
return graph return graph
class DataTable(dash_table.DataTable):
def __init__(
self,
dtid: str,
hidden_columns: list = None,
data=None,
columns: list = None,
page_size: int = 20,
page_action: Literal["native", "custom", "none"] = "native",
sort_action: Literal["native", "custom", "none"] = "native",
filter_action: Literal["native", "custom", "none"] = "native",
**kwargs,
):
# Styles de base
style_cell_conditional = [
{
"if": {"column_id": "objet"},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_nom"},
"minWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
]
# Initialisation de la classe parente avec les arguments
super().__init__(
id=dtid,
data=data,
columns=columns,
cell_selectable=False,
page_size=page_size,
filter_action=filter_action,
page_action=page_action,
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
sort_action=sort_action,
sort_mode="multi",
sort_by=[],
row_deletable=False,
page_current=0,
style_cell_conditional=style_cell_conditional,
data_timestamp=0,
markdown_options={"html": True},
tooltip_duration=8000,
tooltip_delay=350,
hidden_columns=hidden_columns,
**kwargs, # Possibilité de remplacer des arguments
)
+1 -1
View File
@@ -42,7 +42,7 @@ les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
html.H4("Contact", id="contact"), html.H4("Contact", id="contact"),
dcc.Markdown(""" dcc.Markdown("""
- Email : [colin+decp@maudry.com](mailto:colin+decp@maudry.com) - Email : [colin@colmo.tech](mailto:colin@colmo.tech)
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social) - Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m) - Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/) - LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
+110 -84
View File
@@ -1,19 +1,21 @@
import 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, dcc, html, register_page
from src.callbacks import get_top_org_table from src.callbacks import get_top_org_table
from src.figures import point_on_map from src.figures import DataTable, point_on_map
from src.utils import ( from src.utils import (
add_links_in_dict,
df, df,
filter_table_data,
format_number, format_number,
format_values,
get_annuaire_data, get_annuaire_data,
get_button_properties,
get_default_hidden_columns,
get_departement_region, get_departement_region,
meta_content, meta_content,
setup_table_columns, prepare_table_data,
sort_table_data,
) )
register_page( register_page(
@@ -26,11 +28,22 @@ register_page(
order=5, order=5,
) )
datatable = html.Div(
className="marches_table",
children=DataTable(
dtid="acheteur_datatable",
page_action="custom",
filter_action="custom",
sort_action="custom",
page_size=10,
hidden_columns=get_default_hidden_columns(page="acheteur"),
),
)
layout = [ layout = [
dcc.Store(id="acheteur_data", storage_type="memory"), dcc.Store(id="acheteur_data", storage_type="memory"),
dcc.Location(id="url", refresh="callback-nav"), dcc.Location(id="url", refresh="callback-nav"),
html.Div( html.Div(
className="container",
children=[ children=[
html.Div( html.Div(
className="wrapper", className="wrapper",
@@ -84,9 +97,9 @@ layout = [
html.P(id="acheteur_titulaires_differents"), html.P(id="acheteur_titulaires_differents"),
html.Button( html.Button(
"Téléchargement au format Excel", "Téléchargement au format Excel",
id="btn-download-acheteur-data", id="btn-download-data-acheteur",
), ),
dcc.Download(id="download-acheteur-data"), dcc.Download(id="download-data-acheteur"),
], ],
), ),
html.Div(className="org_map", id="acheteur_map"), html.Div(className="org_map", id="acheteur_map"),
@@ -101,7 +114,26 @@ layout = [
), ),
# récupérer les données de l'acheteur sur l'api annuaire # récupérer les données de l'acheteur sur l'api annuaire
html.H3("Derniers marchés publics attribués"), html.H3("Derniers marchés publics attribués"),
html.Div(id="acheteur_last_marches", children=""), dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-home",
type="default",
children=[
html.Div(
[
html.P("lignes", id="acheteur_nb_rows"),
html.Button(
"Téléchargement désactivé au-delà de 65 000 lignes",
id="btn-download-filtered-data-acheteur",
disabled=True,
),
dcc.Download(id="acheteur-download-filtered-data"),
],
className="table-menu",
),
datatable,
],
),
], ],
), ),
] ]
@@ -154,7 +186,7 @@ def update_acheteur_infos(url):
Input(component_id="acheteur_data", component_property="data"), Input(component_id="acheteur_data", component_property="data"),
) )
def update_acheteur_stats(data): def update_acheteur_stats(data):
dff = pl.DataFrame(data) dff = pl.DataFrame(data, strict=False, infer_schema_length=5000)
if dff.height == 0: if dff.height == 0:
dff = pl.DataFrame(schema=df.collect_schema()) dff = pl.DataFrame(schema=df.collect_schema())
df_marches = dff.unique("id") df_marches = dff.unique("id")
@@ -176,91 +208,49 @@ def update_acheteur_stats(data):
@callback( @callback(
Output(component_id="acheteur_data", component_property="data"), Output(component_id="acheteur_data", component_property="data"),
Output("btn-download-data-acheteur", "disabled"),
Output("btn-download-data-acheteur", "children"),
Output("btn-download-data-acheteur", "title"),
Input(component_id="url", component_property="pathname"), Input(component_id="url", component_property="pathname"),
Input(component_id="acheteur_year", component_property="value"), Input(component_id="acheteur_year", component_property="value"),
) )
def get_acheteur_marches_data(url, acheteur_year: str) -> list[dict]: def get_acheteur_marches_data(url, acheteur_year: str) -> tuple:
acheteur_siret = url.split("/")[-1] acheteur_siret = url.split("/")[-1]
lff = df.lazy() lff = df.lazy()
lff = lff.filter(pl.col("acheteur_id") == acheteur_siret) lff = lff.filter(pl.col("acheteur_id") == acheteur_siret)
lff = lff.select(
"id",
"uid",
"objet",
"dateNotification",
"titulaire_id",
"titulaire_typeIdentifiant",
"titulaire_nom",
"distance",
"montant",
"codeCPV",
"dureeMois",
)
if acheteur_year and acheteur_year != "Toutes": if acheteur_year and acheteur_year != "Toutes":
lff = lff.filter( acheteur_year = int(acheteur_year)
pl.col("dateNotification").cast(pl.String).str.starts_with(acheteur_year) lff = lff.filter(pl.col("dateNotification").dt.year() == acheteur_year)
) lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True)
lff = lff.sort(["dateNotification", "id"], descending=True, nulls_last=True) dff: pl.DataFrame = lff.collect(engine="streaming")
download_disabled, download_text, download_title = get_button_properties(dff.height)
data = lff.collect(engine="streaming").to_dicts() data = dff.to_dicts()
return data return data, download_disabled, download_text, download_title
@callback( @callback(
Output(component_id="acheteur_last_marches", component_property="children"), Output("acheteur_datatable", "data"),
Input(component_id="acheteur_data", component_property="data"), Output("acheteur_datatable", "columns"),
Output("acheteur_datatable", "tooltip_header"),
Output("acheteur_datatable", "data_timestamp"),
Output("acheteur_nb_rows", "children"),
Output("btn-download-filtered-data-acheteur", "disabled"),
Output("btn-download-filtered-data-acheteur", "children"),
Output("btn-download-filtered-data-acheteur", "title"),
Input("acheteur_data", "data"),
Input("acheteur_datatable", "page_current"),
Input("acheteur_datatable", "page_size"),
Input("acheteur_datatable", "filter_query"),
Input("acheteur_datatable", "sort_by"),
State("acheteur_datatable", "data_timestamp"),
) )
def get_last_marches_table(data) -> html.Div: def get_last_marches_data(
dff = pl.DataFrame(data) data, page_current, page_size, filter_query, sort_by, data_timestamp
if dff.height == 0: ) -> tuple:
return html.Div(html.P("Aucun marché trouvé.")) return prepare_table_data(
dff = dff.cast(pl.String) data, data_timestamp, filter_query, page_current, page_size, sort_by
dff = dff.fill_null("")
dff = format_values(dff)
columns, tooltip = setup_table_columns(
dff,
hideable=False,
exclude=["titulaire_id", "titulaire_typeIdentifiant", "uid"],
) )
data = dff.to_dicts()
data = add_links_in_dict(data, "titulaire")
table = html.Div(
className="marches_table",
id="acheteur_datatable",
children=dash_table.DataTable(
data=data,
markdown_options={"html": True},
page_action="native",
filter_action="native",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
columns=columns,
tooltip_header=tooltip,
tooltip_duration=8000,
tooltip_delay=350,
cell_selectable=False,
page_size=10,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "300px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "200px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
),
)
return table
@callback( @callback(
@@ -272,8 +262,8 @@ def get_top_titulaires(data):
@callback( @callback(
Output("download-acheteur-data", "data"), Output("download-data-acheteur", "data"),
Input("btn-download-acheteur-data", "n_clicks"), Input("btn-download-data-acheteur", "n_clicks"),
State(component_id="acheteur_data", component_property="data"), State(component_id="acheteur_data", component_property="data"),
State(component_id="acheteur_nom", component_property="children"), State(component_id="acheteur_nom", component_property="children"),
State(component_id="acheteur_year", component_property="value"), State(component_id="acheteur_year", component_property="value"),
@@ -294,3 +284,39 @@ def download_acheteur_data(
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{acheteur_nom}_{date}.xlsx") return dcc.send_bytes(to_bytes, filename=f"decp_{acheteur_nom}_{date}.xlsx")
@callback(
Output("acheteur-download-filtered-data", "data"),
State("acheteur_data", "data"),
Input("btn-download-filtered-data-acheteur", "n_clicks"),
State("acheteur_nom", "children"),
State("acheteur_datatable", "filter_query"),
State("acheteur_datatable", "sort_by"),
State("acheteur_datatable", "hidden_columns"),
prevent_initial_call=True,
)
def download_filtered_acheteur_data(
data, n_clicks, acheteur_nom, filter_query, sort_by, hidden_columns: list = None
):
lff: pl.LazyFrame = pl.LazyFrame(
data
) # start from the full acheteur data, not from paginated table data
# Les colonnes masquées sont supprimées
if hidden_columns:
lff = lff.drop(hidden_columns)
if filter_query:
lff = filter_table_data(lff, filter_query)
if len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
def to_bytes(buffer):
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(
to_bytes, filename=f"decp_filtrées_{acheteur_nom}_{date}.xlsx"
)
+6 -24
View File
@@ -1,5 +1,6 @@
from dash import Input, Output, callback, dash_table, dcc, html, register_page from dash import Input, Output, callback, dcc, html, register_page
from src.figures import DataTable
from src.utils import ( from src.utils import (
df_acheteurs, df_acheteurs,
df_titulaires, df_titulaires,
@@ -73,31 +74,13 @@ def update_search_results(query):
className=f"results_{org_type}", className=f"results_{org_type}",
children=[ children=[
html.H3(f"{org_type.title()}s : {count}"), html.H3(f"{org_type.title()}s : {count}"),
dash_table.DataTable( DataTable(
dtid=f"results_{org_type}_datatable",
columns=columns, columns=columns,
data=results.to_dicts(), data=results.to_dicts(),
page_size=10, page_size=10,
# style_table={"overflowX": "auto"}, sort_action="none",
markdown_options={"html": True}, filter_action="none",
cell_selectable=False,
style_cell_conditional=[
{
"if": {"column_id": "acheteur_nom"},
"maxWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"maxWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
), ),
], ],
) )
@@ -107,5 +90,4 @@ def update_search_results(query):
content.extend(org_content) content.extend(org_content)
return content return content
else:
return html.P("") return html.P("")
+128 -110
View File
@@ -1,26 +1,26 @@
import json
import os import os
import urllib.parse
from datetime import datetime 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, dcc, html, no_update, register_page
from src.figures import DataTable
from src.utils import ( from src.utils import (
add_links,
add_resource_link,
df, df,
filter_table_data, filter_table_data,
format_number,
format_values,
get_default_hidden_columns, get_default_hidden_columns,
invert_columns,
meta_content, meta_content,
setup_table_columns, schema,
sort_table_data, sort_table_data,
) )
from utils import prepare_table_data
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")
schema = df.collect_schema()
name = "Tableau" name = "Tableau"
register_page( register_page(
@@ -35,61 +35,28 @@ register_page(
datatable = html.Div( datatable = html.Div(
className="marches_table", className="marches_table",
children=dash_table.DataTable( children=DataTable(
cell_selectable=False, dtid="table",
id="table",
page_size=20, page_size=20,
page_current=0,
page_action="custom", page_action="custom",
filter_action="custom", filter_action="custom",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
sort_action="custom", sort_action="custom",
sort_mode="multi", hidden_columns=get_default_hidden_columns(None),
sort_by=[], columns=[{"id": col, "name": col} for col in df.columns],
row_deletable=False,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_nom"},
"minWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
data_timestamp=0,
markdown_options={"html": True},
tooltip_duration=8000,
tooltip_delay=350,
hidden_columns=get_default_hidden_columns(schema),
), ),
) )
layout = [ layout = [
dcc.Location(id="url", refresh=False),
html.Div( html.Div(
html.Details( html.Details(
children=[ children=[
html.Summary( html.Summary(
html.H3("Mode d'emploi", style={"text-decoration": "underline"}), html.H3("Mode d'emploi", style={"textDecoration": "underline"}),
), ),
dcc.Markdown( dcc.Markdown(
""" dangerously_allow_html=True,
children="""
##### Définition des colonnes ##### Définition des colonnes
Pour voir la définition d'une colonne, passez votre souris sur son en-tête. Pour voir la définition d'une colonne, passez votre souris sur son en-tête.
@@ -99,10 +66,10 @@ layout = [
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) et retourne les valeurs qui contiennent - Champs textuels : la recherche est insensible à la casse (majuscules/minuscules) et retourne les valeurs qui contiennent
le texte recherché. Exemple : `rennes` retourne "RENNES METROPOLE". le texte recherché. Exemple : `rennes` retourne "RENNES METROPOLE". Lorsque vous ouvrez une URL de vue, le format équivalent `icontains rennes` est utilisé.
- 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 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", - 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" `2024` pour "en 2024", `> 2022` pour "à partir de 2022". Lorsque vous ouvrez une URL de vue, le format équivalent `i<` ou `i>` est utilisé.
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.
@@ -110,6 +77,12 @@ layout = [
Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre : tri ascendant, tri descendant, pas de tri. Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre : tri ascendant, tri descendant, pas de tri.
##### Partager une vue
Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqué. Vous pouvez copier une adresse Web qui reproduit la vue courante à l'identique en cliquant sur l'icône <img src="/assets/copy.svg" alt="drawing" width="20"/>. En la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres.
Pratique pour partager une vue avec un·e collègue, sur les réseaux sociaux, ou la sauvegarder pour plus tard.
##### Télécharger le résultat ##### 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**. 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**.
@@ -119,7 +92,7 @@ layout = [
Les liens dans les colonnes Identifiant unique, Acheteur et Titulaire vous permettent de consulter une vue qui leur est dédiée 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.) (informations, marchés attribués/remportés, etc.)
""" """,
), ),
], ],
id="instructions", id="instructions",
@@ -140,6 +113,8 @@ layout = [
html.Div( html.Div(
[ [
html.P("lignes", id="nb_rows"), html.P("lignes", id="nb_rows"),
html.Div(id="copy-container"),
dcc.Input(id="share-url", readOnly=True, style={"display": "none"}),
html.Button( html.Button(
"Téléchargement désactivé au-delà de 65 000 lignes", "Téléchargement désactivé au-delà de 65 000 lignes",
id="btn-download-data", id="btn-download-data",
@@ -173,65 +148,12 @@ layout = [
State("table", "data_timestamp"), State("table", "data_timestamp"),
) )
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):
if os.getenv("DEVELOPMENT").lower() == "true": # if ctx.triggered_id != "url":
print(" + + + + + + + + + + + + + + + + + + ") # search_params = None
# else:
# Application des filtres # search_params = urllib.parse.parse_qs(search_params.lstrip("?"))
lff: pl.LazyFrame = df.lazy() # start from the original data return prepare_table_data(
if filter_query: None, data_timestamp, filter_query, page_current, page_size, sort_by
lff = filter_table_data(lff, filter_query)
if len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
# Matérialisation des filtres
dff: pl.DataFrame = lff.collect()
height = dff.height
nb_rows = f"{format_number(height)} lignes ({format_number(dff.select('uid').unique().height)} marchés)"
# Pagination des données
start_row = page_current * page_size
# end_row = (page_current + 1) * page_size
dff = dff.slice(start_row, page_size)
# Tout devient string
dff = dff.cast(pl.String)
# Remplace les strings null par "", mais pas les numeric null
dff = dff.fill_null("")
# Ajout des liens vers l'annuaire des entreprises
dff = add_links(dff)
# Ajout des liens vers les fichiers Open Data
dff = add_resource_link(dff)
# Formatage des montants
dff = format_values(dff)
columns, tooltip = setup_table_columns(dff)
dicts = dff.to_dicts()
if height > 65000:
download_disabled = True
download_text = "Téléchargement désactivé au-delà de 65 000 lignes"
download_title = "Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul. Contactez-moi pour me présenter votre besoin en téléchargement afin que je puisse adapter la solution."
else:
download_disabled = False
download_text = "Télécharger au format Excel"
download_title = ""
return (
dicts,
columns,
tooltip,
data_timestamp + 1,
nb_rows,
download_disabled,
download_text,
download_title,
) )
@@ -261,3 +183,99 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
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")
@callback(
Output("table", "filter_query"),
Output("table", "sort_by"),
Output("table", "hidden_columns"),
Output("url", "search", allow_duplicate=True),
Input("url", "search"),
prevent_initial_call=True,
)
def restore_view_from_url(search):
if not search:
return no_update, no_update, no_update, no_update
params = urllib.parse.parse_qs(search.lstrip("?"))
print("params", params)
filter_query = no_update
sort_by = no_update
hidden_columns = no_update
if "filtres" in params:
filter_query = params["filtres"][0]
if "tris" in params:
try:
sort_by = json.loads(params["tris"][0])
except json.JSONDecodeError:
pass
if "colonnes" in params:
columns = params["colonnes"][0].split(",")
verified_columns = [column for column in columns if column in schema.names()]
hidden_columns = invert_columns(verified_columns)
return filter_query, sort_by, hidden_columns, ""
@callback(
Output("share-url", "value"),
Output("copy-container", "children"),
Input("table", "filter_query"),
Input("table", "sort_by"),
Input("table", "hidden_columns"),
State("url", "href"),
)
def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href):
if not href:
return no_update, no_update
# Extract base URL (remove existing query params)
base_url = href.split("?")[0]
params = {}
if filter_query:
params["filtres"] = filter_query
if sort_by:
params["tris"] = json.dumps(sort_by)
if hidden_columns:
columns = invert_columns(hidden_columns)
columns = ",".join(columns)
params["colonnes"] = columns
query_string = urllib.parse.urlencode(params)
full_url = f"{base_url}?{query_string}" if query_string else base_url
copy_button = dcc.Clipboard(
id="btn-copy-url",
target_id="share-url",
title="Copier l'URL de cette vue",
style={
"display": "inline-block",
"fontSize": 20,
"verticalAlign": "top",
"cursor": "pointer",
},
className="fa fa-link",
)
return full_url, copy_button
@callback(
Output("copy-container", "children", allow_duplicate=True),
Input("btn-copy-url", "n_clicks", allow_optional=True),
prevent_initial_call=True,
)
def show_confirmation(n_clicks):
if n_clicks:
return html.Span(
"Adresse de la vue copiée",
style={"color": "green", "fontWeight": "bold", "marginLeft": "10px"},
)
return no_update
+120 -97
View File
@@ -1,19 +1,21 @@
import 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, dcc, html, register_page
from src.callbacks import get_top_org_table from src.callbacks import get_top_org_table
from src.figures import point_on_map from src.figures import DataTable, point_on_map
from src.utils import ( from src.utils import (
add_links_in_dict,
df, df,
filter_table_data,
format_number, format_number,
format_values,
get_annuaire_data, get_annuaire_data,
get_button_properties,
get_default_hidden_columns,
get_departement_region, get_departement_region,
meta_content, meta_content,
setup_table_columns, prepare_table_data,
sort_table_data,
) )
register_page( register_page(
@@ -26,13 +28,22 @@ register_page(
order=5, order=5,
) )
# 21690123100011 datatable = html.Div(
className="marches_table",
children=DataTable(
dtid="titulaire_datatable",
page_action="custom",
filter_action="custom",
sort_action="custom",
page_size=10,
hidden_columns=get_default_hidden_columns(page="titulaire"),
),
)
layout = [ layout = [
dcc.Store(id="titulaire_data", storage_type="memory"), dcc.Store(id="titulaire_data", storage_type="memory"),
dcc.Location(id="url", refresh="callback-nav"), dcc.Location(id="url", refresh="callback-nav"),
html.Div( html.Div(
className="container",
children=[ children=[
html.Div( html.Div(
className="wrapper", className="wrapper",
@@ -86,9 +97,9 @@ layout = [
html.P(id="titulaire_acheteurs_differents"), html.P(id="titulaire_acheteurs_differents"),
html.Button( html.Button(
"Téléchargement au format Excel", "Téléchargement au format Excel",
id="btn-download-titulaire-data", id="btn-download-data-titulaire",
), ),
dcc.Download(id="download-titulaire-data"), dcc.Download(id="download-data-titulaire"),
], ],
), ),
html.Div(className="org_map", id="titulaire_map"), html.Div(className="org_map", id="titulaire_map"),
@@ -103,7 +114,26 @@ layout = [
), ),
# récupérer les données de l'acheteur sur l'api annuaire # récupérer les données de l'acheteur sur l'api annuaire
html.H3("Derniers marchés publics remportés"), html.H3("Derniers marchés publics remportés"),
html.Div(id="titulaire_last_marches", children=""), dcc.Loading(
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
id="loading-home",
type="default",
children=[
html.Div(
[
html.P("lignes", id="titulaire_nb_rows"),
html.Button(
"Téléchargement désactivé au-delà de 65 000 lignes",
id="btn-download-filtered-data-titulaire",
disabled=True,
),
dcc.Download(id="titulaire-download-filtered-data"),
],
className="table-menu",
),
datatable,
],
),
], ],
), ),
] ]
@@ -156,123 +186,80 @@ def update_titulaire_infos(url):
Input(component_id="titulaire_data", component_property="data"), Input(component_id="titulaire_data", component_property="data"),
) )
def update_titulaire_stats(data): def update_titulaire_stats(data):
dff = pl.DataFrame(data) dff = pl.DataFrame(data, strict=False, infer_schema_length=5000)
if dff.height == 0: if dff.height == 0:
dff = pl.DataFrame(schema=dff.collect_schema()) nb_marches = 0
nb_acheteurs = 0
else:
df_marches = dff.unique("uid") df_marches = dff.unique("uid")
nb_marches = format_number(df_marches.height) nb_marches = format_number(df_marches.height)
# somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item()))
marches_remportes = [html.Strong(nb_marches), " marchés et accord-cadres remportés"]
# + ", pour un total de ", html.Strong(somme_marches + " €")]
del df_marches
nb_acheteurs = dff.unique("acheteur_id").height nb_acheteurs = dff.unique("acheteur_id").height
nb_acheteurs = [
texte_marches_remportes = [
html.Strong(nb_marches),
" marchés et accord-cadres remportés",
]
# + ", pour un total de ", html.Strong(somme_marches + " €")]
texte_nb_acheteurs = [
html.Strong(format_number(nb_acheteurs)), html.Strong(format_number(nb_acheteurs)),
" acheteurs (SIRET) différents", " acheteurs (SIRET) différents",
] ]
del dff
return marches_remportes, nb_acheteurs return texte_marches_remportes, texte_nb_acheteurs
@callback( @callback(
Output(component_id="titulaire_data", component_property="data"), Output(component_id="titulaire_data", component_property="data"),
Output("btn-download-data-titulaire", "disabled"),
Output("btn-download-data-titulaire", "children"),
Output("btn-download-data-titulaire", "title"),
Input(component_id="url", component_property="pathname"), Input(component_id="url", component_property="pathname"),
Input(component_id="titulaire_year", component_property="value"), Input(component_id="titulaire_year", component_property="value"),
) )
def get_titulaire_marches_data(url, titulaire_year: str) -> list[dict]: def get_titulaire_marches_data(url, titulaire_year: str) -> tuple:
titulaire_siret = url.split("/")[-1] titulaire_siret = url.split("/")[-1]
lff = df.lazy() lff = df.lazy()
lff = lff.filter( lff = lff.filter(
(pl.col("titulaire_id") == titulaire_siret) (pl.col("titulaire_id") == titulaire_siret)
& (pl.col("titulaire_typeIdentifiant") == "SIRET") & (pl.col("titulaire_typeIdentifiant") == "SIRET")
) )
lff = lff.select(
"id",
"uid",
"objet",
"dateNotification",
"acheteur_id",
"acheteur_nom",
"distance",
"montant",
"codeCPV",
"dureeMois",
)
if titulaire_year and titulaire_year != "Toutes": if titulaire_year and titulaire_year != "Toutes":
lff = lff.filter( lff = lff.filter(
pl.col("dateNotification").cast(pl.String).str.starts_with(titulaire_year) pl.col("dateNotification").cast(pl.String).str.starts_with(titulaire_year)
) )
lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True) lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True)
lff = lff.fill_null("")
data = lff.collect(engine="streaming").to_dicts() dff: pl.DataFrame = lff.collect(engine="streaming")
return data download_disabled, download_text, download_title = get_button_properties(dff.height)
data = dff.to_dicts()
return data, download_disabled, download_text, download_title
@callback( @callback(
Output(component_id="titulaire_last_marches", component_property="children"), Output("titulaire_datatable", "data"),
Input(component_id="titulaire_data", component_property="data"), Output("titulaire_datatable", "columns"),
Output("titulaire_datatable", "tooltip_header"),
Output("titulaire_datatable", "data_timestamp"),
Output("titulaire_nb_rows", "children"),
Output("btn-download-filtered-data-titulaire", "disabled"),
Output("btn-download-filtered-data-titulaire", "children"),
Output("btn-download-filtered-data-titulaire", "title"),
Input("titulaire_data", "data"),
Input("titulaire_datatable", "page_current"),
Input("titulaire_datatable", "page_size"),
Input("titulaire_datatable", "filter_query"),
Input("titulaire_datatable", "sort_by"),
State("titulaire_datatable", "data_timestamp"),
) )
def get_last_marches_table(data) -> html.Div: def get_last_marches_data(
columns = [ data, page_current, page_size, filter_query, sort_by, data_timestamp
"uid", ) -> list[dict]:
"objet", return prepare_table_data(
"dateNotification", data, data_timestamp, filter_query, page_current, page_size, sort_by
"acheteur_nom",
"montant",
"codeCPV",
"dureeMois",
]
dff = pl.DataFrame(data)
dff = dff.cast(pl.String)
dff = dff.fill_null("")
dff = format_values(dff)
columns, tooltip = setup_table_columns(
dff, hideable=False, exclude=["acheteur_id", "id"]
) )
data = dff.to_dicts()
# Idéalement on utiliserait add_org_links(), mais le résultat attendu
# est différent de home.py (Tableau)
data = add_links_in_dict(data, "acheteur")
table = html.Div(
className="marches_table",
id="titulaire_datatable",
children=dash_table.DataTable(
data=data,
markdown_options={"html": True},
page_action="native",
filter_action="native",
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
columns=columns,
tooltip_header=tooltip,
tooltip_duration=8000,
tooltip_delay=350,
cell_selectable=False,
page_size=10,
style_cell_conditional=[
{
"if": {"column_id": "objet"},
"minWidth": "300px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_nom"},
"maxWidth": "400px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
),
)
return table
@callback( @callback(
@@ -284,8 +271,8 @@ def get_top_acheteurs(data):
@callback( @callback(
Output("download-titulaire-data", "data"), Output("download-data-titulaire", "data"),
Input("btn-download-titulaire-data", "n_clicks"), Input("btn-download-data-titulaire", "n_clicks"),
State(component_id="titulaire_data", component_property="data"), State(component_id="titulaire_data", component_property="data"),
State(component_id="titulaire_nom", component_property="children"), State(component_id="titulaire_nom", component_property="children"),
State(component_id="titulaire_year", component_property="value"), State(component_id="titulaire_year", component_property="value"),
@@ -306,3 +293,39 @@ def download_titulaire_data(
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{titulaire_nom}_{date}.xlsx") return dcc.send_bytes(to_bytes, filename=f"decp_{titulaire_nom}_{date}.xlsx")
@callback(
Output("titulaire-download-filtered-data", "data"),
State("titulaire_data", "data"),
Input("btn-download-filtered-data-titulaire", "n_clicks"),
State("titulaire_nom", "children"),
State("titulaire_datatable", "filter_query"),
State("titulaire_datatable", "sort_by"),
State("titulaire_datatable", "hidden_columns"),
prevent_initial_call=True,
)
def download_filtered_titulaire_data(
data, n_clicks, titulaire_nom, filter_query, sort_by, hidden_columns: list = None
):
lff: pl.LazyFrame = pl.LazyFrame(
data
) # start from the full titulaire data, not from paginated table data
# Les colonnes masquées sont supprimées
if hidden_columns:
lff = lff.drop(hidden_columns)
if filter_query:
lff = filter_table_data(lff, filter_query)
if len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
def to_bytes(buffer):
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(
to_bytes, filename=f"decp_filtrées_{titulaire_nom}_{date}.xlsx"
)
+171 -6
View File
@@ -7,7 +7,6 @@ from time import localtime, sleep
import polars as pl import polars as pl
import polars.selectors as cs import polars.selectors as cs
from httpx import get, post from httpx import get, post
from polars import Schema
from polars.exceptions import ComputeError from polars.exceptions import ComputeError
from unidecode import unidecode from unidecode import unidecode
@@ -28,6 +27,7 @@ def split_filter_part(filter_part):
["i<", "<"], ["i<", "<"],
["i>", ">"], ["i>", ">"],
["icontains", "contains"], ["icontains", "contains"],
# [" ", "contains"]
] ]
print("filter part", filter_part) print("filter part", filter_part)
for operator_group in operators: for operator_group in operators:
@@ -296,6 +296,7 @@ def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
lff = lff.filter(pl.col(col_name) <= filter_value) lff = lff.filter(pl.col(col_name) <= filter_value)
elif operator == "contains": elif operator == "contains":
if col_type in ["String", "Date"]: if col_type in ["String", "Date"]:
filter_value = filter_value.strip('"')
lff = lff.filter( lff = lff.filter(
pl.col(col_name).str.contains("(?i)" + filter_value) pl.col(col_name).str.contains("(?i)" + filter_value)
) )
@@ -331,7 +332,7 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup
continue continue
column_object = data_schema.get(column_id) column_object = data_schema.get(column_id)
if column_object: if column_object:
column_name = column_object.get("title", column_id) column_name = column_object.get("title")
else: else:
column_name = column_id column_name = column_id
@@ -356,18 +357,47 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup
return columns, tooltip return columns, tooltip
def get_default_hidden_columns(schema: Schema): def get_default_hidden_columns(page):
if page == "acheteur":
displayed_columns = [
"uid",
"objet",
"dateNotification",
"titulaire_id",
"titulaire_typeIdentifiant",
"titulaire_nom",
"distance",
"montant",
"codeCPV",
"dureeRestanteMois",
]
elif page == "titulaire":
displayed_columns = [
"uid",
"objet",
"dateNotification",
"acheteur_id",
"acheteur_nom",
"distance",
"montant",
"codeCPV",
"dureeRestanteMois",
]
else:
displayed_columns = os.getenv("DISPLAYED_COLUMNS") displayed_columns = os.getenv("DISPLAYED_COLUMNS")
hidden_columns = [] if displayed_columns is None:
if displayed_columns: raise ValueError("DISPLAYED_COLUMNS n'est pas configuré")
else:
displayed_columns = displayed_columns.replace(" ", "").split(",") displayed_columns = displayed_columns.replace(" ", "").split(",")
hidden_columns = []
for col in schema.names(): for col in schema.names():
if col in displayed_columns: if col in displayed_columns:
continue continue
else: else:
hidden_columns.append(col) hidden_columns.append(col)
return hidden_columns return hidden_columns
raise ValueError("DISPLAYED_COLUMNS n'est pas configuré")
def get_data_schema() -> dict: def get_data_schema() -> dict:
@@ -503,9 +533,144 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
return dff return dff
def prepare_table_data(
data, data_timestamp, filter_query, page_current, page_size, sort_by
):
"""
Fonction de préparation des données pour les datatables, afin de permettre une gestion fine des logiques,
notamment pour les filtres et les tris.
:param data
:param data_timestamp:
:param filter_query:
:param page_current:
:param page_size:
:param sort_by:
:param search_params:
:return:
"""
if os.getenv("DEVELOPMENT").lower() == "true":
print(" + + + + + + + + + + + + + + + + + + ")
# Récupération des données
if isinstance(data, list):
lff: pl.LazyFrame = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
else:
lff: pl.LazyFrame = df.lazy() # start from the original data
# if search_params:
# if "filtres" in search_params:
# filter_query = search_params["filtres"][0]
#
# if "tris" in search_params:
# try:
# sort_by = json.loads(search_params["tris"][0])
# except json.JSONDecodeError:
# pass
#
# if "colonnes" in search_params:
# try:
# hidden_columns = json.loads(search_params["colonnes"][0])
# print(hidden_columns)
# lff = lff.drop(hidden_columns)
# except json.JSONDecodeError:
# pass
# Application des filtres
if filter_query:
lff = filter_table_data(lff, filter_query)
# Application des tris
if len(sort_by) > 0:
lff = sort_table_data(lff, sort_by)
# Matérialisation des filtres
dff: pl.DataFrame = lff.collect()
height = dff.height
if height > 0:
nb_rows = f"{format_number(height)} lignes ({format_number(dff.select('uid').unique().height)} marchés)"
else:
nb_rows = "0 lignes (0 marchés)"
# Pagination des données
start_row = page_current * page_size
# end_row = (page_current + 1) * page_size
dff = dff.slice(start_row, page_size)
# Tout devient string
dff = dff.cast(pl.String)
# Remplace les strings null par "", mais pas les numeric null
dff = dff.fill_null("")
# Ajout des liens vers l'annuaire des entreprises
dff = add_links(dff)
# Ajout des liens vers les fichiers Open Data
if "sourceFile" in dff.columns:
dff = add_resource_link(dff)
# Formatage des montants
if height > 0:
dff = format_values(dff)
# Récupération des colonnes et tooltip
columns, tooltip = setup_table_columns(dff)
dicts = dff.to_dicts()
# Propriétés du bouton de téléchargement
download_disabled, download_text, download_title = get_button_properties(height)
return (
dicts,
columns,
tooltip,
data_timestamp + 1,
nb_rows,
download_disabled,
download_text,
download_title,
)
def get_button_properties(height):
if height > 65000:
download_disabled = True
download_text = "Téléchargement désactivé au-delà de 65 000 lignes"
download_title = "Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul. Contactez-moi pour me présenter votre besoin en téléchargement afin que je puisse adapter la solution."
elif height == 0:
download_disabled = True
download_text = "Pas de données à télécharger"
download_title = ""
else:
download_disabled = False
download_text = "Télécharger au format Excel"
download_title = ""
return download_disabled, download_text, download_title
def invert_columns(columns):
"""
Renvoie les colonnes du schéma non spécifiées en paramètre. Utile pour passer d'une colonnes masquées à une liste de colonnes affichées, et vice versa.
:param columns:
:return:
"""
inverted_columns = []
for column in schema.names():
if column not in columns:
inverted_columns.append(column)
return inverted_columns
df: pl.DataFrame = get_decp_data() df: pl.DataFrame = get_decp_data()
schema = df.collect_schema()
df_acheteurs = get_org_data(df, "acheteur") df_acheteurs = get_org_data(df, "acheteur")
df_titulaires = get_org_data(df, "titulaire") df_titulaires = get_org_data(df, "titulaire")
departements = get_departements() departements = get_departements()
domain_name = ( domain_name = (
"test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info" "test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"