diff --git a/.template.env b/.template.env index 86ff67f..67df0ba 100644 --- a/.template.env +++ b/.template.env @@ -3,6 +3,9 @@ PORT=8050 DEVELOPMENT=True 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 DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ad0ac24 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,132 @@ +#### 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 + +##### 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) diff --git a/README.md b/README.md index b8efcea..b38991f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ # decp.info -> v2.2.3 - -Outil d'exploration et de téléchargement des données essentielles de la commande publique. +> v2.3.0 +> Outil d'exploration et de téléchargement des données essentielles de la commande publique. => [decp.info](https://decp.info) @@ -38,130 +37,4 @@ Ne pas oublier de mettre à jour les fichier .env. ## Notes de version -##### 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) +Voir [CHANGELOG](https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md). diff --git a/pyproject.toml b/pyproject.toml index 9bb5736..59b5d4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "decp.info" description = "Interface d'exploration et d'analyse des marchés publics français." -version = "2.2.3" +version = "2.3.0" requires-python = ">= 3.10" authors = [ { name = "Colin Maudry", email = "colin@colmo.tech" } ] dependencies = [ - "dash==3.2.0", + "dash==3.3.0", "dash[compress]", "polars", "gunicorn", diff --git a/src/app.py b/src/app.py index a2dd1fb..699c389 100644 --- a/src/app.py +++ b/src/app.py @@ -1,4 +1,5 @@ import logging +import os import dash_bootstrap_components as dbc import tomllib @@ -82,7 +83,7 @@ app.layout = html.Div( html.P( children=html.A( 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", ), className="version", @@ -92,9 +93,7 @@ app.layout = html.Div( ), html.Div( id="announcements", - children=dcc.Markdown( - "ℹ️ La publication des données consolidées par le MINEF est actuellement [cassée](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-fichiers-consolides/#/discussions/69306148a4871a110fd7a7d0), ce qui casse la mise à jour de decp.info. Je travaille donc en priorité à une [désolidarisation des données du MINEF](https://github.com/ColinMaudry/decp-processing/issues/151). Merci pour votre patience." - ), + children=dcc.Markdown(os.getenv("ANNOUNCEMENTS")), ), html.Div( [ diff --git a/src/assets/copy.svg b/src/assets/copy.svg new file mode 100644 index 0000000..f14d4e4 --- /dev/null +++ b/src/assets/copy.svg @@ -0,0 +1 @@ + diff --git a/src/assets/style.css b/src/assets/style.css index c009a16..4f91b10 100644 --- a/src/assets/style.css +++ b/src/assets/style.css @@ -133,6 +133,12 @@ td[data-dash-column="objet"],td[data-dash-column="titulaire_nom"],td[data-dash-c .marches_table { font-family: "Open Sans", sans-serif; } + +.marches_table.stuck { + position: relative; + right: 200px; +} + .marches_table .cell-table tr:nth-child(even) td { background-color: #feeeee; font-family: "Open Sans", sans-serif; @@ -212,7 +218,7 @@ summary > h3 { } #_pages_content { - padding-top: 28px; + padding: 28px 24px 0 24px; } /* Vue acheteur/titulaire/recherche */ diff --git a/src/callbacks.py b/src/callbacks.py index 6bf197e..d5f1522 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -1,11 +1,12 @@ 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 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: 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( 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.fill_null("") 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 = add_links_in_dict(data, f"{org_type}") - return dash_table.DataTable( + return DataTable( + dtid=f"top10_{org_type}", data=data, - markdown_options={"html": True}, page_action="native", page_size=10, columns=columns, - cell_selectable=False, tooltip_header=tooltip, - style_cell_conditional=[ - { - "if": {"column_id": "objet"}, - "minWidth": "350px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "14px", - "whiteSpace": "normal", - "fontSize": "85%", - }, - { - "if": {"column_id": "acheteur_nom"}, - "minWidth": "200px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "16px", - # "fontSize": "85%", - "whiteSpace": "normal", - }, - { - "if": {"column_id": "titulaire_nom"}, - "minWidth": "200px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "16px", - "whiteSpace": "normal", - # "fontSize": "85%", - }, - ], ) diff --git a/src/figures.py b/src/figures.py index ec5eda1..8796e96 100644 --- a/src/figures.py +++ b/src/figures.py @@ -1,4 +1,5 @@ import json +from typing import Literal import plotly.express as px import polars as pl @@ -192,3 +193,69 @@ def point_on_map(lat, lon): graph = dcc.Graph(id="map", figure=fig) 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 + ) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 87ddd05..6d51bb8 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -1,19 +1,21 @@ import datetime 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.figures import point_on_map +from src.figures import DataTable, point_on_map from src.utils import ( - add_links_in_dict, df, + filter_table_data, format_number, - format_values, get_annuaire_data, + get_button_properties, + get_default_hidden_columns, get_departement_region, meta_content, - setup_table_columns, + prepare_table_data, + sort_table_data, ) register_page( @@ -26,11 +28,22 @@ register_page( 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 = [ dcc.Store(id="acheteur_data", storage_type="memory"), dcc.Location(id="url", refresh="callback-nav"), html.Div( - className="container", children=[ html.Div( className="wrapper", @@ -84,9 +97,9 @@ layout = [ html.P(id="acheteur_titulaires_differents"), html.Button( "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"), @@ -101,7 +114,26 @@ layout = [ ), # 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=""), + 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"), ) def update_acheteur_stats(data): - dff = pl.DataFrame(data) + dff = pl.DataFrame(data, strict=False, infer_schema_length=5000) if dff.height == 0: dff = pl.DataFrame(schema=df.collect_schema()) df_marches = dff.unique("id") @@ -176,91 +208,49 @@ def update_acheteur_stats(data): @callback( 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="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] lff = df.lazy() lff = lff.filter(pl.col("acheteur_id") == acheteur_siret) - lff = lff.select( - "id", - "uid", - "objet", - "dateNotification", - "titulaire_id", - "titulaire_typeIdentifiant", - "titulaire_nom", - "distance", - "montant", - "codeCPV", - "dureeMois", - ) if acheteur_year and acheteur_year != "Toutes": - lff = lff.filter( - pl.col("dateNotification").cast(pl.String).str.starts_with(acheteur_year) - ) - lff = lff.sort(["dateNotification", "id"], descending=True, nulls_last=True) + acheteur_year = int(acheteur_year) + lff = lff.filter(pl.col("dateNotification").dt.year() == acheteur_year) + lff = lff.sort(["dateNotification", "uid"], 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() - return data + data = dff.to_dicts() + return data, download_disabled, download_text, download_title @callback( - Output(component_id="acheteur_last_marches", component_property="children"), - Input(component_id="acheteur_data", component_property="data"), + Output("acheteur_datatable", "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: - dff = pl.DataFrame(data) - if dff.height == 0: - return html.Div(html.P("Aucun marché trouvé.")) - dff = dff.cast(pl.String) - dff = dff.fill_null("") - dff = format_values(dff) - columns, tooltip = setup_table_columns( - dff, - hideable=False, - exclude=["titulaire_id", "titulaire_typeIdentifiant", "uid"], +def get_last_marches_data( + data, page_current, page_size, filter_query, sort_by, data_timestamp +) -> tuple: + return prepare_table_data( + data, data_timestamp, filter_query, page_current, page_size, sort_by ) - 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( @@ -272,8 +262,8 @@ def get_top_titulaires(data): @callback( - Output("download-acheteur-data", "data"), - Input("btn-download-acheteur-data", "n_clicks"), + Output("download-data-acheteur", "data"), + Input("btn-download-data-acheteur", "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"), @@ -294,3 +284,39 @@ def download_acheteur_data( date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") 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" + ) diff --git a/src/pages/recherche.py b/src/pages/recherche.py index 4ce7877..bf78231 100644 --- a/src/pages/recherche.py +++ b/src/pages/recherche.py @@ -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 ( df_acheteurs, df_titulaires, @@ -73,31 +74,13 @@ def update_search_results(query): className=f"results_{org_type}", children=[ html.H3(f"{org_type.title()}s : {count}"), - dash_table.DataTable( + DataTable( + dtid=f"results_{org_type}_datatable", columns=columns, data=results.to_dicts(), page_size=10, - # style_table={"overflowX": "auto"}, - markdown_options={"html": True}, - cell_selectable=False, - style_cell_conditional=[ - { - "if": {"column_id": "acheteur_nom"}, - "maxWidth": "250px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - }, - { - "if": {"column_id": "titulaire_nom"}, - "maxWidth": "250px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - }, - ], + sort_action="none", + filter_action="none", ), ], ) @@ -107,5 +90,4 @@ def update_search_results(query): content.extend(org_content) return content - else: - return html.P("") + return html.P("") diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 472c685..42d37b8 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -1,26 +1,24 @@ +import json import os +import urllib.parse from datetime import datetime 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 ( - add_links, - add_resource_link, df, filter_table_data, - format_number, - format_values, get_default_hidden_columns, meta_content, - setup_table_columns, sort_table_data, ) +from utils import prepare_table_data update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH")) update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y") -schema = df.collect_schema() name = "Tableau" register_page( @@ -35,61 +33,28 @@ register_page( datatable = html.Div( className="marches_table", - children=dash_table.DataTable( - cell_selectable=False, - id="table", + children=DataTable( + dtid="table", page_size=20, - page_current=0, page_action="custom", filter_action="custom", - filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."}, sort_action="custom", - sort_mode="multi", - sort_by=[], - row_deletable=False, - style_cell_conditional=[ - { - "if": {"column_id": "objet"}, - "minWidth": "350px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - }, - { - "if": {"column_id": "acheteur_nom"}, - "minWidth": "250px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - }, - { - "if": {"column_id": "titulaire_nom"}, - "minWidth": "250px", - "textAlign": "left", - "overflow": "hidden", - "lineHeight": "18px", - "whiteSpace": "normal", - }, - ], - data_timestamp=0, - markdown_options={"html": True}, - tooltip_duration=8000, - tooltip_delay=350, - hidden_columns=get_default_hidden_columns(schema), + hidden_columns=get_default_hidden_columns(None), + columns=[{"id": col, "name": col} for col in df.columns], ), ) layout = [ + dcc.Location(id="url", refresh=False), html.Div( html.Details( children=[ html.Summary( - html.H3("Mode d'emploi", style={"text-decoration": "underline"}), + html.H3("Mode d'emploi", style={"textDecoration": "underline"}), ), dcc.Markdown( - """ + dangerously_allow_html=True, + children=""" ##### Définition des colonnes Pour voir la définition d'une colonne, passez votre souris sur son en-tête. @@ -99,10 +64,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`. - 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 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. @@ -110,6 +75,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. + ##### 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 drawing. 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 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 +90,7 @@ layout = [ Les liens dans les colonnes Identifiant unique, Acheteur et Titulaire vous permettent de consulter une vue qui leur est dédiée (informations, marchés attribués/remportés, etc.) - """ + """, ), ], id="instructions", @@ -140,6 +111,8 @@ layout = [ html.Div( [ html.P("lignes", id="nb_rows"), + html.Div(id="copy-container"), + dcc.Input(id="share-url", readOnly=True, style={"display": "none"}), html.Button( "Téléchargement désactivé au-delà de 65 000 lignes", id="btn-download-data", @@ -173,65 +146,12 @@ layout = [ State("table", "data_timestamp"), ) def update_table(page_current, page_size, filter_query, sort_by, data_timestamp): - if os.getenv("DEVELOPMENT").lower() == "true": - print(" + + + + + + + + + + + + + + + + + + ") - - # Application des filtres - lff: pl.LazyFrame = df.lazy() # start from the original data - if filter_query: - lff = filter_table_data(lff, filter_query) - - if len(sort_by) > 0: - lff = sort_table_data(lff, sort_by) - - # Matérialisation des filtres - dff: pl.DataFrame = lff.collect() - - height = dff.height - nb_rows = f"{format_number(height)} lignes ({format_number(dff.select('uid').unique().height)} marchés)" - - # Pagination des données - start_row = page_current * page_size - # end_row = (page_current + 1) * page_size - dff = dff.slice(start_row, page_size) - - # Tout devient string - dff = dff.cast(pl.String) - - # Remplace les strings null par "", mais pas les numeric null - dff = dff.fill_null("") - - # Ajout des liens vers l'annuaire des entreprises - dff = add_links(dff) - - # Ajout des liens vers les fichiers Open Data - dff = add_resource_link(dff) - - # Formatage des montants - dff = format_values(dff) - - columns, tooltip = setup_table_columns(dff) - - dicts = dff.to_dicts() - - if height > 65000: - download_disabled = True - download_text = "Téléchargement désactivé au-delà de 65 000 lignes" - download_title = "Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul. Contactez-moi pour me présenter votre besoin en téléchargement afin que je puisse adapter la solution." - else: - download_disabled = False - download_text = "Télécharger au format Excel" - download_title = "" - - return ( - dicts, - columns, - tooltip, - data_timestamp + 1, - nb_rows, - download_disabled, - download_text, - download_title, + # if ctx.triggered_id != "url": + # search_params = None + # else: + # search_params = urllib.parse.parse_qs(search_params.lstrip("?")) + return prepare_table_data( + None, data_timestamp, filter_query, page_current, page_size, sort_by ) @@ -261,3 +181,98 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") 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: + try: + hidden_columns = json.loads(params["colonnes"][0]) + except json.JSONDecodeError: + pass + + 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: + params["colonnes"] = json.dumps(hidden_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 diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index 3ebeaef..d75eb78 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -1,19 +1,21 @@ import datetime 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.figures import point_on_map +from src.figures import DataTable, point_on_map from src.utils import ( - add_links_in_dict, df, + filter_table_data, format_number, - format_values, get_annuaire_data, + get_button_properties, + get_default_hidden_columns, get_departement_region, meta_content, - setup_table_columns, + prepare_table_data, + sort_table_data, ) register_page( @@ -26,13 +28,22 @@ register_page( 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 = [ dcc.Store(id="titulaire_data", storage_type="memory"), dcc.Location(id="url", refresh="callback-nav"), html.Div( - className="container", children=[ html.Div( className="wrapper", @@ -86,9 +97,9 @@ layout = [ html.P(id="titulaire_acheteurs_differents"), html.Button( "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"), @@ -103,7 +114,26 @@ layout = [ ), # 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=""), + 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"), ) def update_titulaire_stats(data): - dff = pl.DataFrame(data) + dff = pl.DataFrame(data, strict=False, infer_schema_length=5000) if dff.height == 0: - dff = pl.DataFrame(schema=dff.collect_schema()) - df_marches = dff.unique("uid") - nb_marches = format_number(df_marches.height) - # somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item())) - marches_remportes = [html.Strong(nb_marches), " marchés et accord-cadres remportés"] - # + ", pour un total de ", html.Strong(somme_marches + " €")] - del df_marches + nb_marches = 0 + nb_acheteurs = 0 + else: + df_marches = dff.unique("uid") + nb_marches = format_number(df_marches.height) + 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)), " acheteurs (SIRET) différents", ] - del dff - return marches_remportes, nb_acheteurs + return texte_marches_remportes, texte_nb_acheteurs @callback( 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="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] lff = df.lazy() lff = lff.filter( (pl.col("titulaire_id") == titulaire_siret) & (pl.col("titulaire_typeIdentifiant") == "SIRET") ) - lff = lff.select( - "id", - "uid", - "objet", - "dateNotification", - "acheteur_id", - "acheteur_nom", - "distance", - "montant", - "codeCPV", - "dureeMois", - ) if titulaire_year and titulaire_year != "Toutes": lff = lff.filter( pl.col("dateNotification").cast(pl.String).str.starts_with(titulaire_year) ) lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True) + lff = lff.fill_null("") - data = lff.collect(engine="streaming").to_dicts() - return data + dff: pl.DataFrame = lff.collect(engine="streaming") + download_disabled, download_text, download_title = get_button_properties(dff.height) + + data = dff.to_dicts() + return data, download_disabled, download_text, download_title @callback( - Output(component_id="titulaire_last_marches", component_property="children"), - Input(component_id="titulaire_data", component_property="data"), + Output("titulaire_datatable", "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: - columns = [ - "uid", - "objet", - "dateNotification", - "acheteur_nom", - "montant", - "codeCPV", - "dureeMois", - ] - - dff = pl.DataFrame(data) - dff = dff.cast(pl.String) - dff = dff.fill_null("") - dff = format_values(dff) - columns, tooltip = setup_table_columns( - dff, hideable=False, exclude=["acheteur_id", "id"] +def get_last_marches_data( + data, page_current, page_size, filter_query, sort_by, data_timestamp +) -> list[dict]: + return prepare_table_data( + data, data_timestamp, filter_query, page_current, page_size, sort_by ) - 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( @@ -284,8 +271,8 @@ def get_top_acheteurs(data): @callback( - Output("download-titulaire-data", "data"), - Input("btn-download-titulaire-data", "n_clicks"), + Output("download-data-titulaire", "data"), + Input("btn-download-data-titulaire", "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"), @@ -306,3 +293,39 @@ def download_titulaire_data( date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S") 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" + ) diff --git a/src/utils.py b/src/utils.py index 084938c..e7d47ad 100644 --- a/src/utils.py +++ b/src/utils.py @@ -7,7 +7,6 @@ from time import localtime, sleep import polars as pl import polars.selectors as cs from httpx import get, post -from polars import Schema from polars.exceptions import ComputeError from unidecode import unidecode @@ -28,6 +27,7 @@ def split_filter_part(filter_part): ["i<", "<"], ["i>", ">"], ["icontains", "contains"], + # [" ", "contains"] ] print("filter part", filter_part) for operator_group in operators: @@ -331,7 +331,7 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup continue column_object = data_schema.get(column_id) if column_object: - column_name = column_object.get("title", column_id) + column_name = column_object.get("title") else: column_name = column_id @@ -356,18 +356,47 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup return columns, tooltip -def get_default_hidden_columns(schema: Schema): - displayed_columns = os.getenv("DISPLAYED_COLUMNS") +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") + if displayed_columns is None: + raise ValueError("DISPLAYED_COLUMNS n'est pas configuré") + else: + displayed_columns = displayed_columns.replace(" ", "").split(",") + hidden_columns = [] - if displayed_columns: - displayed_columns = displayed_columns.replace(" ", "").split(",") - for col in schema.names(): - if col in displayed_columns: - continue - else: - hidden_columns.append(col) - return hidden_columns - raise ValueError("DISPLAYED_COLUMNS n'est pas configuré") + + for col in schema.names(): + if col in displayed_columns: + continue + else: + hidden_columns.append(col) + return hidden_columns def get_data_schema() -> dict: @@ -503,9 +532,130 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame: 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 + + df: pl.DataFrame = get_decp_data() +schema = df.collect_schema() + df_acheteurs = get_org_data(df, "acheteur") df_titulaires = get_org_data(df, "titulaire") + departements = get_departements() domain_name = ( "test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"