539 lines
21 KiB
Python
539 lines
21 KiB
Python
import json
|
|
import os
|
|
import urllib.parse
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
import dash_bootstrap_components as dbc
|
|
import polars as pl
|
|
from dash import (
|
|
ClientsideFunction,
|
|
Input,
|
|
Output,
|
|
State,
|
|
callback,
|
|
clientside_callback,
|
|
dcc,
|
|
html,
|
|
no_update,
|
|
register_page,
|
|
)
|
|
|
|
from src.db import query_marches, schema
|
|
from src.figures import DataTable, make_column_picker
|
|
from src.utils import logger
|
|
from src.utils.seo import META_CONTENT
|
|
from src.utils.table import (
|
|
COLUMNS,
|
|
filter_table_data,
|
|
get_default_hidden_columns,
|
|
invert_columns,
|
|
prepare_table_data,
|
|
sort_table_data,
|
|
)
|
|
from src.utils.tracking import track_search
|
|
|
|
update_date_timestamp = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
|
|
update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y")
|
|
update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat()
|
|
|
|
|
|
NAME = "Tableau"
|
|
register_page(
|
|
__name__,
|
|
path="/tableau",
|
|
title="Tableau des marchés publics | decp.info",
|
|
name=NAME,
|
|
description="Consultez, filtrez et exportez les données essentielles de la commande publique sous forme de tableau.",
|
|
image_url=META_CONTENT["image_url"],
|
|
order=1,
|
|
)
|
|
|
|
DATATABLE = html.Div(
|
|
className="marches_table",
|
|
children=DataTable(
|
|
dtid="tableau_datatable",
|
|
persisted_props=["filter_query", "sort_by"],
|
|
persistence_type="local",
|
|
persistence=True,
|
|
page_size=20,
|
|
page_action="custom",
|
|
filter_action="custom",
|
|
sort_action="custom",
|
|
hidden_columns=[],
|
|
columns=[{"id": col, "name": col} for col in schema.names()],
|
|
),
|
|
)
|
|
|
|
layout = [
|
|
dcc.Location(id="tableau_url", refresh=False),
|
|
dcc.Store(id="filter-cleanup-trigger-tableau"),
|
|
dcc.Store(id="tableau-hidden-columns", storage_type="local"),
|
|
dcc.Store(id="tableau-table"),
|
|
html.Script(
|
|
type="application/ld+json",
|
|
id="dataset_jsonld",
|
|
children=[
|
|
json.dumps(
|
|
{
|
|
"@context": "https://schema.org/",
|
|
"@type": "Dataset",
|
|
"name": "Données essentielles des marchés publics français (DECP)",
|
|
"description": "Données de marchés publics exhaustives décrivant les marchés publics attribués en France depuis 2018.",
|
|
"url": "https://decp.info",
|
|
"sameAs": "https://www.data.gouv.fr/datasets/608c055b35eb4e6ee20eb325",
|
|
"keywords": [
|
|
"marchés publics",
|
|
"commande publique",
|
|
"decp",
|
|
"public procurement",
|
|
],
|
|
"license": "https://www.etalab.gouv.fr/licence-ouverte-open-licence",
|
|
"isAccessibleForFree": True,
|
|
"creator": {
|
|
"@type": "Organization",
|
|
"url": "https://colmo.tech",
|
|
"name": "Colmo",
|
|
"sameAs": "https://annuaire-entreprises.data.gouv.fr/entreprise/colmo-989393350",
|
|
"contactPoint": {
|
|
"@type": "ContactPoint",
|
|
"contactType": "Support et contact commercial",
|
|
"email": "colin@colmo.tech",
|
|
},
|
|
},
|
|
"includedInDataCatalog": {
|
|
"@type": "DataCatalog",
|
|
"name": "data.gouv.fr",
|
|
},
|
|
"distribution": [
|
|
{
|
|
"@type": "DataDownload",
|
|
"encodingFormat": "CSV",
|
|
"contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/22847056-61df-452d-837d-8b8ceadbfc52",
|
|
},
|
|
{
|
|
"@type": "DataDownload",
|
|
"encodingFormat": "Parquet",
|
|
"contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432",
|
|
},
|
|
],
|
|
"temporalCoverage": f"2018-01-01/{update_date_iso[:10]}",
|
|
"spatialCoverage": {
|
|
"@type": "Place",
|
|
"address": {"countryCode": "FR"},
|
|
},
|
|
},
|
|
indent=2,
|
|
)
|
|
],
|
|
),
|
|
dcc.Markdown(
|
|
f"Ce tableau contient tous les marchés attribués en France. Il vous permet d'appliquer un filtre sur une ou plusieurs colonnes, et ainsi produire la liste de marchés dont vous avez besoin (exemples : [marchés de voirie < 40 k€ en 2025](/tableau?filtres=%7Bacheteur_id%7D+icontains+24350013900189+%26%26+%7BdateNotification%7D+icontains+2025%2A+%26%26+%7Bmontant%7D+i%3C+40000+%26%26+%7Bobjet%7D+icontains+voirie&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2Cacheteur_departement_code%2CsourceDataset), [marchés > 500 k€ avec clause sociale attribués à des PME à plus de 100 km dans le Var](/tableau?filtres=%7Btitulaire_categorie%7D+icontains+PME+%26%26+%7Btitulaire_distance%7D+i%3E+100+%26%26+%7Bmontant%7D+i%3E+500000+%26%26+%7Bacheteur_departement_code%7D+icontains+83+%26%26+%7BconsiderationsSociales%7D+icontains+clause&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2CconsiderationsSociales%2Ctitulaire_distance%2Cacheteur_departement_code%2Ctitulaire_categorie%2CsourceDataset)). Par défaut seules quelques colonnes sont affichées, mais vous pouvez en afficher jusqu'à {len(schema.names())} en cliquant sur le bouton **Choisir les colonnes**. Cet outil est assez puissant, je vous recommande de lire le mode d'emploi pour en tirer pleinement partie.",
|
|
style={"maxWidth": "1000px"},
|
|
),
|
|
html.Div(
|
|
[],
|
|
id="header",
|
|
),
|
|
dcc.Loading(
|
|
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
|
id="loading-home",
|
|
type="default",
|
|
children=[
|
|
html.Div(
|
|
[
|
|
# Modal du mode d'emploi
|
|
dbc.Button("Mode d'emploi", id="tableau_help_open"),
|
|
dbc.Modal(
|
|
[
|
|
dbc.ModalHeader(dbc.ModalTitle("Mode d'emploi")),
|
|
dbc.ModalBody(
|
|
dcc.Markdown(
|
|
dangerously_allow_html=True,
|
|
children=f"""
|
|
##### Définition des colonnes
|
|
|
|
Pour voir la définition d'une colonne, passez votre souris sur son en-tête.
|
|
|
|
##### Vos réglages sont persistents
|
|
|
|
Les filtres, les tris et le choix de colonnes sont automatiquement enregistrés dans votre navigateur et persistent même si vous changez de page ou si vous fermez votre navigateur. À votre retour, vous retrouverez cette page comme vous l'avez laissée.
|
|
|
|
##### Appliquer des filtres
|
|
|
|
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`.
|
|
|
|
- Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché, n'est pas sensible à la casse (majuscules/minuscules) et est sensbible à l'accentuation.
|
|
- `rennes` => le texte contient "rennes"
|
|
- `metro* *pole` => le texte contient un mot qui commence par "metro" et un mot qui finit par "pole"
|
|
- `metropole rennes` => le texte contient les mots "metropole" et "rennes", n'importe où dans le texte
|
|
- `metropole+rennes` => le texte contient "metropole rennes", collé et dans cet ordre
|
|
- `metropole+rennes travaux distri*` => le texte contient "metropole rennes", "travaux" et un mot qui commence par "distri"
|
|
- Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances`
|
|
- Champs numériques (Durée en mois, Montant, ...) : vous pouvez...
|
|
- soit taper un nombre pour trouver les valeurs strictement égales. Exemple : `12` ne retourne que des 12
|
|
- 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 (Date de notification, ...) :
|
|
- `< 2024-01-31` pour "avant le 31 janvier 2024"
|
|
- `2024` pour "en 2024", `> 2022` pour "à partir de 2022"
|
|
|
|
Vous pouvez filtrer plusieurs colonnes à la fois.
|
|
|
|
##### Trier les données
|
|
|
|
Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre :
|
|
|
|
1. tri croissant
|
|
2. tri décroissant
|
|
3. pas de tri
|
|
|
|
##### Afficher plus de colonnes
|
|
|
|
Par défaut, un nombre réduit de colonnes est affiché pour ne pas surcharger la page. Mais vous avez le choix parmi {len(schema.names())} colonnes, ce serait dommage de vous limiter !
|
|
|
|
Pour afficher plus de colonnes, cliquez sur le bouton **Choisir les colonnes** et cochez les colonnes pour les afficher.
|
|
|
|
##### Partager une vue
|
|
|
|
Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqués. Cliquez sur **Partager** pour copier une adresse Web qui reproduit la vue courante à l'identique : 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**.
|
|
|
|
##### Liens
|
|
|
|
Les liens dans les colonnes Identifiant unique, Acheteur et Titulaire vous permettent de consulter une vue qui leur est dédiée
|
|
(informations, marchés attribués/remportés, etc.)
|
|
|
|
""",
|
|
),
|
|
),
|
|
dbc.ModalFooter(
|
|
dbc.Button(
|
|
"Fermer",
|
|
id="tableau_help_close",
|
|
className="ms-auto",
|
|
n_clicks=0,
|
|
)
|
|
),
|
|
],
|
|
id="tableau_help",
|
|
is_open=False,
|
|
fullscreen="md-down",
|
|
scrollable=True,
|
|
size="lg",
|
|
),
|
|
# Bouton modal des colonnes affichées
|
|
dbc.Button(
|
|
"Choisir les colonnes",
|
|
id="tableau_columns_open",
|
|
className="column_list",
|
|
title="Choisir les colonnes à afficher et masquer",
|
|
),
|
|
html.P("lignes", id="nb_rows"),
|
|
html.Div(id="copy-container"),
|
|
dcc.Input(id="share-url", readOnly=True, style={"display": "none"}),
|
|
dbc.Button(
|
|
"Téléchargement désactivé au-delà de 65 000 lignes",
|
|
id="btn-download-data",
|
|
disabled=True,
|
|
),
|
|
dcc.Download(id="download-data"),
|
|
dcc.Store(id="filtered_data", storage_type="memory"),
|
|
html.P("Données mises à jour le " + str(update_date)),
|
|
dbc.Button(
|
|
"Remettre à zéro",
|
|
title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
|
|
id="btn-tableau-reset",
|
|
),
|
|
],
|
|
className="table-menu",
|
|
),
|
|
dbc.Modal(
|
|
[
|
|
dbc.ModalHeader(dbc.ModalTitle("Choix des colonnes à afficher")),
|
|
dbc.ModalBody(
|
|
id="tableau_columns_body",
|
|
children=make_column_picker("tableau"),
|
|
),
|
|
dbc.ModalFooter(
|
|
dbc.Button(
|
|
"Fermer",
|
|
id="tableau_columns_close",
|
|
className="ms-auto",
|
|
n_clicks=0,
|
|
)
|
|
),
|
|
],
|
|
id="tableau_columns",
|
|
is_open=False,
|
|
fullscreen="md-down",
|
|
scrollable=True,
|
|
size="xl",
|
|
),
|
|
DATATABLE,
|
|
],
|
|
),
|
|
]
|
|
|
|
|
|
@callback(
|
|
Output("tableau_datatable", "data"),
|
|
Output("tableau_datatable", "columns"),
|
|
Output("tableau_datatable", "tooltip_header"),
|
|
Output("tableau_datatable", "data_timestamp"),
|
|
Output("nb_rows", "children"),
|
|
Output("btn-download-data", "disabled"),
|
|
Output("btn-download-data", "children"),
|
|
Output("btn-download-data", "title"),
|
|
Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True),
|
|
Input("tableau_url", "href"),
|
|
Input("tableau_datatable", "page_current"),
|
|
Input("tableau_datatable", "page_size"),
|
|
Input("tableau_datatable", "filter_query"),
|
|
Input("tableau_datatable", "sort_by"),
|
|
State("tableau_datatable", "data_timestamp"),
|
|
prevent_initial_call=True,
|
|
)
|
|
def update_table(href, page_current, page_size, filter_query, sort_by, data_timestamp):
|
|
# 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, "tableau"
|
|
)
|
|
|
|
|
|
@callback(
|
|
Output("download-data", "data"),
|
|
Input("btn-download-data", "n_clicks"),
|
|
State("tableau_datatable", "filter_query"),
|
|
State("tableau_datatable", "sort_by"),
|
|
State("tableau_datatable", "hidden_columns"),
|
|
prevent_initial_call=True,
|
|
)
|
|
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list | None = None):
|
|
lff: pl.LazyFrame = query_marches().lazy()
|
|
|
|
# Les colonnes masquées sont supprimées
|
|
if hidden_columns:
|
|
lff = lff.drop(hidden_columns)
|
|
|
|
if filter_query:
|
|
track_search(filter_query, "tab download")
|
|
lff = filter_table_data(lff, filter_query)
|
|
|
|
if sort_by and len(sort_by) > 0:
|
|
lff = sort_table_data(lff, sort_by)
|
|
|
|
def to_bytes(buffer):
|
|
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
|
|
|
|
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
|
return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx")
|
|
|
|
|
|
@callback(
|
|
Output("tableau_datatable", "filter_query"),
|
|
Output("tableau_datatable", "sort_by"),
|
|
Output("tableau-hidden-columns", "data"),
|
|
Output("tableau_url", "search"),
|
|
Output("filter-cleanup-trigger-tableau", "data"),
|
|
Input("tableau_url", "search"),
|
|
State("tableau_datatable", "filter_query"),
|
|
State("tableau_datatable", "sort_by"),
|
|
)
|
|
def restore_view_from_url(search, stored_filters, stored_sort):
|
|
if not search and not stored_filters:
|
|
return no_update, no_update, no_update, no_update, no_update
|
|
|
|
params = urllib.parse.parse_qs(search.lstrip("?")) if search else {}
|
|
logger.debug("params " + json.dumps(params, indent=2))
|
|
|
|
filter_query = no_update
|
|
sort_by = no_update
|
|
hidden_columns = no_update
|
|
trigger_cleanup = no_update
|
|
|
|
if "filtres" in params:
|
|
filter_query = params["filtres"][0]
|
|
trigger_cleanup = str(uuid.uuid4())
|
|
elif stored_filters:
|
|
filter_query = stored_filters
|
|
trigger_cleanup = str(uuid.uuid4())
|
|
|
|
if "tris" in params:
|
|
try:
|
|
sort_by = json.loads(params["tris"][0])
|
|
except json.JSONDecodeError:
|
|
pass
|
|
elif stored_sort:
|
|
sort_by = stored_sort
|
|
|
|
if "colonnes" in params:
|
|
table_columns = params["colonnes"][0].split(",")
|
|
verified_columns = [
|
|
column for column in table_columns if column in schema.names()
|
|
]
|
|
hidden_columns = invert_columns(verified_columns)
|
|
|
|
return filter_query, sort_by, hidden_columns, "", trigger_cleanup
|
|
|
|
|
|
# Pour nettoyer les icontains et i< des filtres
|
|
# voir aussi src/assets/dash_clientside.js
|
|
clientside_callback(
|
|
ClientsideFunction(
|
|
namespace="clientside",
|
|
function_name="clean_filters",
|
|
),
|
|
Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True),
|
|
Input("filter-cleanup-trigger-tableau", "data"),
|
|
prevent_initial_call=True,
|
|
)
|
|
|
|
|
|
@callback(
|
|
Output("share-url", "value"),
|
|
Output("copy-container", "children"),
|
|
Input("tableau_datatable", "filter_query"),
|
|
Input("tableau_datatable", "sort_by"),
|
|
Input("tableau_datatable", "hidden_columns"),
|
|
State("tableau_url", "href"),
|
|
prevent_initial_call=True,
|
|
)
|
|
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:
|
|
table_columns = invert_columns(hidden_columns)
|
|
table_columns = ",".join(table_columns)
|
|
params["colonnes"] = table_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",
|
|
children=[
|
|
dbc.Button(
|
|
"Partager la vue",
|
|
className="btn btn-primary",
|
|
title="Copier l'adresse de cette vue (filtres, tris, choix de colonnes) pour la partager.",
|
|
)
|
|
],
|
|
)
|
|
|
|
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
|
|
|
|
|
|
@callback(
|
|
Output("tableau_help", "is_open"),
|
|
[Input("tableau_help_open", "n_clicks"), Input("tableau_help_close", "n_clicks")],
|
|
[State("tableau_help", "is_open")],
|
|
)
|
|
def toggle_tableau_help(click_open, click_close, is_open):
|
|
if click_open or click_close:
|
|
return not is_open
|
|
return is_open
|
|
|
|
|
|
@callback(
|
|
Output("tableau-hidden-columns", "data", allow_duplicate=True),
|
|
Input("tableau_column_list", "selected_rows"),
|
|
prevent_initial_call=True,
|
|
)
|
|
def update_hidden_columns_from_checkboxes(selected_columns):
|
|
if selected_columns:
|
|
selected_columns = [COLUMNS[i] for i in selected_columns]
|
|
hidden_columns = [col for col in COLUMNS if col not in selected_columns]
|
|
return hidden_columns
|
|
else:
|
|
return []
|
|
|
|
|
|
@callback(
|
|
Output("tableau_datatable", "hidden_columns"),
|
|
Input(
|
|
"tableau-hidden-columns",
|
|
"data",
|
|
),
|
|
)
|
|
def store_hidden_columns(hidden_columns):
|
|
if hidden_columns is None:
|
|
hidden_columns = get_default_hidden_columns("tableau")
|
|
return hidden_columns
|
|
|
|
|
|
@callback(
|
|
Output("tableau_column_list", "selected_rows"),
|
|
Input("tableau_datatable", "hidden_columns"),
|
|
State("tableau_column_list", "selected_rows"), # pour éviter la boucle infinie
|
|
)
|
|
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
|
|
hidden_cols = hidden_cols or get_default_hidden_columns("tableau")
|
|
|
|
# Show all columns that are NOT hidden
|
|
visible_cols = [COLUMNS.index(col) for col in COLUMNS if col not in hidden_cols]
|
|
return visible_cols
|
|
|
|
|
|
@callback(
|
|
Output("tableau_columns", "is_open"),
|
|
Input("tableau_columns_open", "n_clicks"),
|
|
Input("tableau_columns_close", "n_clicks"),
|
|
State("tableau_columns", "is_open"),
|
|
)
|
|
def toggle_tableau_columns(click_open, click_close, is_open):
|
|
if click_open or click_close:
|
|
return not is_open
|
|
return is_open
|
|
|
|
|
|
@callback(
|
|
Output("tableau_datatable", "filter_query", allow_duplicate=True),
|
|
Output("tableau_datatable", "sort_by", allow_duplicate=True),
|
|
Input("btn-tableau-reset", "n_clicks"),
|
|
prevent_initial_call=True,
|
|
)
|
|
def reset_view(n_clicks):
|
|
return "", []
|