import json import os import urllib.parse from datetime import datetime import polars as pl from dash import Input, Output, State, callback, dcc, html, no_update, register_page from src.figures import DataTable from src.utils import ( df, filter_table_data, get_default_hidden_columns, invert_columns, meta_content, schema, sort_table_data, ) from utils import prepare_table_data 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="table", page_size=20, page_action="custom", filter_action="custom", sort_action="custom", hidden_columns=get_default_hidden_columns(None), columns=[{"id": col, "name": col} for col in df.columns], ), ) layout = [ dcc.Location(id="tableau_url", refresh=False), 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, ) ], ), html.Div( html.Details( children=[ html.Summary( 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. ##### Filtres Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`. - Champs textuels : la recherche est insensible à la casse (majuscules/minuscules) et retourne les valeurs qui contiennent le texte recherché. Exemple : `rennes` retourne "RENNES METROPOLE". Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances`. 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". Lorsque vous ouvrez une URL de vue, le format équivalent `i<` ou `i>` est utilisé. - Pour les champs textuels et dates : pour chercher du texte qui **commence par** votre texte, entez `texte*`, pour chercher du texte qui **finit par** votre texte, entez `*texte`. C'est par exemple utile pour filtrer des acheteurs ou titulaires par numéro SIREN (`123456789*`). Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page. ##### Tri Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre : tri ascendant, tri descendant, pas de tri. ##### 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**. ##### Liens Les liens dans les colonnes Identifiant unique, Acheteur et Titulaire vous permettent de consulter une vue qui leur est dédiée (informations, marchés attribués/remportés, etc.) """, ), ], id="instructions", ), id="header", ), # html.Div( # [ # "Recherche dans objet : ", # dcc.Input(id="search", value="", type="text"), # ] # )]), dcc.Loading( overlay_style={"visibility": "visible", "filter": "blur(2px)"}, id="loading-home", type="default", children=[ html.Div( [ html.P("lignes", id="nb_rows"), html.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", disabled=True, ), dcc.Download(id="download-data"), dcc.Store(id="filtered_data", storage_type="memory"), html.P("Données mises à jour le " + str(update_date)), ], className="table-menu", ), datatable, ], ), ] @callback( Output("table", "data"), Output("table", "columns"), Output("table", "tooltip_header"), Output("table", "data_timestamp"), Output("nb_rows", "children"), Output("btn-download-data", "disabled"), Output("btn-download-data", "children"), Output("btn-download-data", "title"), Input("table", "page_current"), Input("table", "page_size"), Input("table", "filter_query"), Input("table", "sort_by"), State("table", "data_timestamp"), ) def update_table(page_current, page_size, filter_query, sort_by, data_timestamp): # if 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 ) @callback( Output("download-data", "data"), Input("btn-download-data", "n_clicks"), State("table", "filter_query"), State("table", "sort_by"), State("table", "hidden_columns"), prevent_initial_call=True, ) def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): lff: pl.LazyFrame = df.lazy() # start from the original data # Les colonnes masquées sont supprimées if hidden_columns: lff = lff.drop(hidden_columns) if filter_query: lff = filter_table_data(lff, filter_query) if len(sort_by) > 0: lff = sort_table_data(lff, sort_by) def to_bytes(buffer): lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP") date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx") @callback( Output("table", "filter_query"), Output("table", "sort_by"), Output("table", "hidden_columns"), Output("tableau_url", "search", allow_duplicate=True), Input("tableau_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("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: 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