bb2cde2fc5
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1017 lines
42 KiB
Python
1017 lines
42 KiB
Python
import urllib.parse
|
||
from datetime import datetime
|
||
|
||
import dash_bootstrap_components as dbc
|
||
import polars as pl
|
||
from dash import (
|
||
ALL,
|
||
Input,
|
||
Output,
|
||
State,
|
||
callback,
|
||
ctx,
|
||
dcc,
|
||
html,
|
||
no_update,
|
||
register_page,
|
||
)
|
||
|
||
from src.figures import (
|
||
DataTable,
|
||
get_barchart_sources,
|
||
get_dashboard_summary_table,
|
||
get_distance_histogram,
|
||
get_duplicate_matrix,
|
||
get_geographic_maps,
|
||
get_top_org_table,
|
||
make_card,
|
||
make_column_picker,
|
||
make_donut,
|
||
)
|
||
from src.utils import (
|
||
columns,
|
||
departements,
|
||
df,
|
||
df_acheteurs,
|
||
df_titulaires,
|
||
get_default_hidden_columns,
|
||
get_enum_values_as_dict,
|
||
logger,
|
||
meta_content,
|
||
prepare_dashboard_data,
|
||
prepare_table_data,
|
||
)
|
||
|
||
name = "Observatoire"
|
||
|
||
register_page(
|
||
__name__,
|
||
path="/observatoire",
|
||
title="Observatoire | decp.info",
|
||
name=name,
|
||
description="Visualisez l'état de la publication des données essentielles des marchés publics en France.",
|
||
image_url=meta_content["image_url"],
|
||
order=3,
|
||
)
|
||
options_years = {}
|
||
for year in reversed(range(2017, datetime.now().year + 1)):
|
||
year = str(year)
|
||
options_years[year] = year
|
||
|
||
options_departements = []
|
||
for code in departements.keys():
|
||
departement = {
|
||
"label": f"{departements[code]['departement']} ({code})",
|
||
"value": code,
|
||
}
|
||
options_departements.append(departement)
|
||
|
||
OBSERVATOIRE_COLUMNS = [
|
||
col
|
||
for col in df.columns
|
||
if col.startswith("acheteur")
|
||
or col.startswith("titulaire")
|
||
or col
|
||
in [
|
||
"uid",
|
||
"dateNotification",
|
||
"montant",
|
||
"considerationsSociales",
|
||
"considerationsEnvironnementales",
|
||
"marcheInnovant",
|
||
"sousTraitanceDeclaree",
|
||
"techniques",
|
||
"sourceDataset",
|
||
"type",
|
||
"codeCPV",
|
||
]
|
||
]
|
||
|
||
DF_FILTERED: pl.DataFrame = pl.DataFrame()
|
||
|
||
layout = [
|
||
dcc.Location(id="dashboard_url", refresh="callback-nav"),
|
||
dcc.Store(id="observatoire-filters", storage_type="local"),
|
||
dcc.Store(id="observatoire-hidden-columns", storage_type="local"),
|
||
dcc.Store(
|
||
id="filter-cleanup-trigger-observatoire-preview"
|
||
), # utilisé juste pour ne pas avoir à adapter les données retournées de prepare_table data
|
||
dbc.Modal(
|
||
[
|
||
dbc.ModalHeader(dbc.ModalTitle("Montants")),
|
||
dbc.ModalBody(
|
||
[
|
||
dcc.Markdown(
|
||
"""
|
||
Les données saisies et publiées par les acheteurs comportent de nombreux montants farfelus qui sabotent les statistiques, au lieu de montants estimés avec rigueur. On parle de montants atteignant parfois les millions de milliards. Certains réutilisateurs des données mettent de côté ces marchés ou bien modifient les montants selon des règles fatalement arbitraires. J'ai fait le choix de ne quasiment pas modifier les données* afin de visibiliser le problème.
|
||
|
||
Alors, on fait comment ?
|
||
|
||
\\* Les montants composés de plus de 11 chiffres, sans les décimales, [sont ramenés](https://github.com/ColinMaudry/decp-processing/blob/main/src/tasks/clean.py#L63-L71) à 12 311 111 111, un nombre qui reste très élevé et qui est facilement reconnaissable.
|
||
"""
|
||
),
|
||
]
|
||
),
|
||
dbc.ModalFooter(
|
||
dbc.Button("Fermer", id="montant-modal-close", className="ms-auto")
|
||
),
|
||
],
|
||
id="montant-modal",
|
||
is_open=False,
|
||
),
|
||
html.Div(
|
||
className="container-fluid",
|
||
children=[
|
||
html.H2(children=[name], id="page_title"),
|
||
dcc.Loading(
|
||
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
||
id="loading-statistques",
|
||
type="default",
|
||
children=[
|
||
dbc.Row(
|
||
[
|
||
dbc.Col(
|
||
xl=3,
|
||
lg=4,
|
||
id="filters",
|
||
children=[
|
||
html.H5("Période d'attribution"),
|
||
dbc.Row(
|
||
dbc.Col(
|
||
dcc.Dropdown(
|
||
id="dashboard_year",
|
||
options=options_years,
|
||
placeholder="12 derniers mois",
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
),
|
||
),
|
||
html.H5("Acheteur"),
|
||
dbc.Row(
|
||
dbc.Col(
|
||
dcc.Input(
|
||
id="dashboard_acheteur_id",
|
||
placeholder="SIRET",
|
||
debounce=True,
|
||
style={"width": "100%"},
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
),
|
||
),
|
||
dbc.Row(
|
||
dbc.Col(
|
||
dcc.Dropdown(
|
||
id="dashboard_acheteur_categorie",
|
||
options=get_enum_values_as_dict(
|
||
"acheteur_categorie"
|
||
),
|
||
placeholder="Catégorie",
|
||
persistence=True,
|
||
persistence_type="local",
|
||
)
|
||
),
|
||
),
|
||
dbc.Row(
|
||
dbc.Col(
|
||
dcc.Dropdown(
|
||
id="dashboard_acheteur_departement_code",
|
||
searchable=True,
|
||
multi=True,
|
||
placeholder="Département",
|
||
options=options_departements,
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
),
|
||
),
|
||
html.H5("Titulaire"),
|
||
dbc.Row(
|
||
dbc.Col(
|
||
dcc.Input(
|
||
id="dashboard_titulaire_id",
|
||
placeholder="SIRET",
|
||
debounce=True,
|
||
style={"width": "100%"},
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
),
|
||
),
|
||
dbc.Row(
|
||
dbc.Col(
|
||
dcc.Dropdown(
|
||
id="dashboard_titulaire_categorie",
|
||
placeholder="Catégorie",
|
||
options=get_enum_values_as_dict(
|
||
"titulaire_categorie"
|
||
),
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
),
|
||
),
|
||
dbc.Row(
|
||
dbc.Col(
|
||
dcc.Dropdown(
|
||
id="dashboard_titulaire_departement_code",
|
||
searchable=True,
|
||
multi=True,
|
||
placeholder="Département",
|
||
options=options_departements,
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
),
|
||
),
|
||
html.H5("Marché"),
|
||
dbc.Row(
|
||
dbc.Col(
|
||
dcc.Dropdown(
|
||
id="dashboard_marche_type",
|
||
placeholder="Type",
|
||
options=get_enum_values_as_dict("type"),
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
),
|
||
),
|
||
dbc.Row(
|
||
dbc.Col(
|
||
dcc.Input(
|
||
id="dashboard_marche_objet",
|
||
placeholder="Objet",
|
||
debounce=True,
|
||
style={"width": "100%"},
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
),
|
||
),
|
||
dbc.Row(
|
||
[
|
||
dbc.Col(
|
||
dcc.Input(
|
||
id="dashboard_marche_code_cpv",
|
||
placeholder="Code CPV (début)",
|
||
debounce=True,
|
||
style={"width": "100%"},
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
lg=8,
|
||
),
|
||
dbc.Col(
|
||
html.A(
|
||
"liste des codes",
|
||
href="https://cpvcodes.eu/fr",
|
||
target="_blank",
|
||
),
|
||
lg=4,
|
||
),
|
||
]
|
||
),
|
||
dbc.Row(
|
||
[
|
||
dbc.Col(
|
||
dcc.Input(
|
||
id="dashboard_montant_min",
|
||
placeholder="Montant min.",
|
||
type="number",
|
||
min=0,
|
||
debounce=True,
|
||
style={"width": "100%"},
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
width=6,
|
||
),
|
||
dbc.Col(
|
||
dcc.Input(
|
||
id="dashboard_montant_max",
|
||
placeholder="Montant max.",
|
||
type="number",
|
||
min=0,
|
||
debounce=True,
|
||
style={"width": "100%"},
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
width=6,
|
||
),
|
||
]
|
||
),
|
||
dbc.Row(
|
||
dbc.Col(
|
||
dcc.Dropdown(
|
||
id="dashboard_marche_techniques",
|
||
placeholder="Techniques d'achat",
|
||
options=get_enum_values_as_dict(
|
||
"techniques"
|
||
),
|
||
multi=True,
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
),
|
||
),
|
||
dbc.Row(
|
||
[
|
||
dbc.Col("Sous-traitance :", lg=5),
|
||
dbc.Col(
|
||
dbc.RadioItems(
|
||
id="dashboard_marche_sousTraitanceDeclaree",
|
||
options=[
|
||
{
|
||
"label": "Tous",
|
||
"value": "all",
|
||
},
|
||
{
|
||
"label": "Oui",
|
||
"value": "oui",
|
||
},
|
||
{
|
||
"label": "Non",
|
||
"value": "non",
|
||
},
|
||
],
|
||
value="all",
|
||
inline=True,
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
lg=7,
|
||
),
|
||
]
|
||
),
|
||
dbc.Row(
|
||
[
|
||
dbc.Col("Marché innovant :", lg=5),
|
||
dbc.Col(
|
||
dbc.RadioItems(
|
||
id="dashboard_marche_innovant",
|
||
options=[
|
||
{
|
||
"label": "Tous",
|
||
"value": "all",
|
||
},
|
||
{
|
||
"label": "Oui",
|
||
"value": "oui",
|
||
},
|
||
{
|
||
"label": "Non",
|
||
"value": "non",
|
||
},
|
||
],
|
||
value="all",
|
||
inline=True,
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
lg=7,
|
||
),
|
||
]
|
||
),
|
||
dbc.Row(
|
||
dbc.Col(
|
||
dcc.Dropdown(
|
||
id="dashboard_marche_considerationsSociales",
|
||
placeholder="Considérations sociales",
|
||
options=get_enum_values_as_dict(
|
||
"considerationsSociales"
|
||
),
|
||
multi=True,
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
),
|
||
),
|
||
dbc.Row(
|
||
dbc.Col(
|
||
dcc.Dropdown(
|
||
id="dashboard_marche_considerationsEnvironnementales",
|
||
placeholder="Considérations environnementales",
|
||
multi=True,
|
||
options=get_enum_values_as_dict(
|
||
"considerationsEnvironnementales"
|
||
),
|
||
persistence=True,
|
||
persistence_type="local",
|
||
),
|
||
),
|
||
),
|
||
dcc.Download(id="download-observatoire"),
|
||
dbc.Button(
|
||
"Prévisualiser les données",
|
||
id="btn-observatoire-preview",
|
||
className="btn btn-primary",
|
||
color="primary",
|
||
outline=True,
|
||
),
|
||
dcc.Input(
|
||
id="observatoire-share-url",
|
||
readOnly=True,
|
||
style={"display": "none"},
|
||
),
|
||
dcc.Input(
|
||
id="observatoire-share-url",
|
||
readOnly=True,
|
||
style={"display": "none"},
|
||
),
|
||
html.Div(id="observatoire-copy-container"),
|
||
],
|
||
),
|
||
dbc.Col(
|
||
width=12,
|
||
lg=8,
|
||
xl=9,
|
||
id="cards",
|
||
children=[],
|
||
),
|
||
]
|
||
)
|
||
],
|
||
),
|
||
],
|
||
),
|
||
dbc.Offcanvas(
|
||
id="observatoire-preview",
|
||
title="Prévisualisation des données",
|
||
placement="bottom",
|
||
is_open=False,
|
||
scrollable=True,
|
||
style={"height": "75vh"},
|
||
children=[
|
||
# Header row: title + "Colonnes affichées" button
|
||
dbc.Row(
|
||
[
|
||
dbc.Col(
|
||
html.Div(
|
||
className="table-menu",
|
||
children=[
|
||
dbc.Button(
|
||
"Choisir les colonnes",
|
||
id="observatoire-preview-columns-open",
|
||
className="btn btn-primary",
|
||
),
|
||
html.P(id="nb_rows_observatoire"),
|
||
dbc.Button(
|
||
"Télécharger au format Excel",
|
||
id="btn-download-observatoire",
|
||
disabled=True,
|
||
className="btn btn-primary",
|
||
outline=True,
|
||
),
|
||
],
|
||
),
|
||
width="auto",
|
||
),
|
||
],
|
||
className="mb-2 align-items-center",
|
||
),
|
||
# Column picker modal
|
||
dbc.Modal(
|
||
[
|
||
dbc.ModalHeader(
|
||
dbc.ModalTitle("Colonnes affichées dans la prévisualisation")
|
||
),
|
||
dbc.ModalBody(
|
||
id="observatoire-preview-columns-body",
|
||
children=make_column_picker("observatoire_preview"),
|
||
),
|
||
dbc.ModalFooter(
|
||
dbc.Button(
|
||
"Fermer",
|
||
id="observatoire-preview-columns-close",
|
||
className="ms-auto",
|
||
n_clicks=0,
|
||
)
|
||
),
|
||
],
|
||
id="observatoire-preview-columns-modal",
|
||
is_open=False,
|
||
fullscreen="md-down",
|
||
scrollable=True,
|
||
size="xl",
|
||
),
|
||
# DataTable
|
||
html.Div(
|
||
className="marches_table",
|
||
children=DataTable(
|
||
dtid="observatoire-preview-table",
|
||
page_size=5,
|
||
page_action="custom",
|
||
sort_action="custom",
|
||
filter_action="custom",
|
||
hidden_columns=[],
|
||
columns=[{"id": col, "name": col} for col in OBSERVATOIRE_COLUMNS],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
]
|
||
|
||
|
||
FILTER_PARAMS = [
|
||
# (component_id, url_key, is_multi, default_value)
|
||
("dashboard_year", "annee", False, None),
|
||
("dashboard_acheteur_id", "acheteur_id", False, None),
|
||
("dashboard_acheteur_categorie", "acheteur_cat", False, None),
|
||
("dashboard_acheteur_departement_code", "acheteur_dept", True, None),
|
||
("dashboard_titulaire_id", "titulaire_id", False, None),
|
||
("dashboard_titulaire_categorie", "titulaire_cat", False, None),
|
||
("dashboard_titulaire_departement_code", "titulaire_dept", True, None),
|
||
("dashboard_marche_type", "type", False, None),
|
||
("dashboard_marche_objet", "objet", False, None),
|
||
("dashboard_marche_code_cpv", "cpv", False, None),
|
||
("dashboard_montant_min", "montant_min", False, None),
|
||
("dashboard_montant_max", "montant_max", False, None),
|
||
("dashboard_marche_techniques", "techniques", True, None),
|
||
("dashboard_marche_innovant", "innovant", False, "all"),
|
||
("dashboard_marche_sousTraitanceDeclaree", "sous_traitance", False, "all"),
|
||
("dashboard_marche_considerationsSociales", "social", True, None),
|
||
("dashboard_marche_considerationsEnvironnementales", "env", True, None),
|
||
]
|
||
|
||
|
||
@callback(
|
||
Output("dashboard_year", "value"),
|
||
Output("dashboard_acheteur_id", "value"),
|
||
Output("dashboard_acheteur_categorie", "value"),
|
||
Output("dashboard_acheteur_departement_code", "value"),
|
||
Output("dashboard_titulaire_id", "value"),
|
||
Output("dashboard_titulaire_categorie", "value"),
|
||
Output("dashboard_titulaire_departement_code", "value"),
|
||
Output("dashboard_marche_type", "value"),
|
||
Output("dashboard_marche_objet", "value"),
|
||
Output("dashboard_marche_code_cpv", "value"),
|
||
Output("dashboard_montant_min", "value"),
|
||
Output("dashboard_montant_max", "value"),
|
||
Output("dashboard_marche_techniques", "value"),
|
||
Output("dashboard_marche_innovant", "value"),
|
||
Output("dashboard_marche_sousTraitanceDeclaree", "value"),
|
||
Output("dashboard_marche_considerationsSociales", "value"),
|
||
Output("dashboard_marche_considerationsEnvironnementales", "value"),
|
||
Input("dashboard_url", "search"),
|
||
Input("dashboard_url", "pathname"),
|
||
State("observatoire-filters", "data"),
|
||
)
|
||
def restore_filters(search, _pathname, stored_filters):
|
||
if search:
|
||
params = urllib.parse.parse_qs(search.lstrip("?"))
|
||
known_keys = {fp[1] for fp in FILTER_PARAMS}
|
||
if any(k in params for k in known_keys):
|
||
values = []
|
||
for _comp_id, url_key, is_multi, default in FILTER_PARAMS:
|
||
if url_key in params:
|
||
if is_multi:
|
||
values.append(params[url_key])
|
||
else:
|
||
raw = params[url_key][0]
|
||
if url_key in ("montant_min", "montant_max"):
|
||
try:
|
||
raw = float(raw)
|
||
except (ValueError, TypeError):
|
||
raw = None
|
||
values.append(raw)
|
||
else:
|
||
values.append(default)
|
||
return tuple(values)
|
||
return (no_update,) * 17
|
||
|
||
|
||
@callback(
|
||
Output("observatoire-share-url", "value"),
|
||
Output("observatoire-copy-container", "children"),
|
||
*[Input(fp[0], "value") for fp in FILTER_PARAMS],
|
||
State("dashboard_url", "href"),
|
||
prevent_initial_call=True,
|
||
)
|
||
def sync_observatoire_share_url(*args):
|
||
# Last arg is href (State), rest are filter values
|
||
filter_values = args[:-1]
|
||
href = args[-1]
|
||
|
||
if not href:
|
||
return no_update, no_update
|
||
|
||
base_url = href.split("?")[0]
|
||
|
||
params = []
|
||
for (_, url_key, is_multi, default), value in zip(FILTER_PARAMS, filter_values):
|
||
if value is None or value == default or value == [] or value == "":
|
||
continue
|
||
if is_multi and isinstance(value, list):
|
||
for v in value:
|
||
params.append((url_key, v))
|
||
else:
|
||
params.append((url_key, value))
|
||
|
||
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-observatoire-url",
|
||
target_id="observatoire-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",
|
||
className="btn btn-primary mt-2",
|
||
title="Copier l'adresse de cette vue filtrée pour la partager.",
|
||
style={"display": "none"},
|
||
)
|
||
],
|
||
)
|
||
|
||
return full_url, copy_button
|
||
|
||
|
||
@callback(
|
||
Output("cards", "children"),
|
||
Input("dashboard_year", "value"),
|
||
Input("dashboard_acheteur_id", "value"),
|
||
Input("dashboard_acheteur_categorie", "value"),
|
||
Input("dashboard_acheteur_departement_code", "value"),
|
||
Input("dashboard_titulaire_id", "value"),
|
||
Input("dashboard_titulaire_categorie", "value"),
|
||
Input("dashboard_titulaire_departement_code", "value"),
|
||
Input("dashboard_marche_type", "value"),
|
||
Input("dashboard_marche_objet", "value"),
|
||
Input("dashboard_marche_code_cpv", "value"),
|
||
Input("dashboard_montant_min", "value"),
|
||
Input("dashboard_montant_max", "value"),
|
||
Input("dashboard_marche_techniques", "value"),
|
||
Input("dashboard_marche_innovant", "value"),
|
||
Input("dashboard_marche_sousTraitanceDeclaree", "value"),
|
||
Input("dashboard_marche_considerationsSociales", "value"),
|
||
Input("dashboard_marche_considerationsEnvironnementales", "value"),
|
||
)
|
||
def udpate_dashboard_cards(
|
||
dashboard_year,
|
||
dashboard_acheteur_id,
|
||
dashboard_acheteur_categorie,
|
||
dashboard_acheteur_departement_code,
|
||
dashboard_titulaire_id,
|
||
dashboard_titulaire_categorie,
|
||
dashboard_titulaire_departement_code,
|
||
dashboard_marche_type,
|
||
dashboard_marche_objet,
|
||
dashboard_marche_code_cpv,
|
||
dashboard_montant_min,
|
||
dashboard_montant_max,
|
||
dashboard_marche_techniques,
|
||
dashboard_marche_innovant,
|
||
dashboard_marche_sous_traitance_declaree,
|
||
dashboard_marche_considerations_sociales,
|
||
dashboard_marche_considerations_environnementales,
|
||
):
|
||
lff: pl.LazyFrame = df.lazy()
|
||
|
||
# Filtrage des données
|
||
lff = prepare_dashboard_data(
|
||
lff=lff,
|
||
year=dashboard_year,
|
||
acheteur_id=dashboard_acheteur_id,
|
||
acheteur_categorie=dashboard_acheteur_categorie,
|
||
acheteur_departement_code=dashboard_acheteur_departement_code,
|
||
titulaire_id=dashboard_titulaire_id,
|
||
titulaire_categorie=dashboard_titulaire_categorie,
|
||
titulaire_departement_code=dashboard_titulaire_departement_code,
|
||
type=dashboard_marche_type,
|
||
objet=dashboard_marche_objet,
|
||
code_cpv=dashboard_marche_code_cpv,
|
||
considerations_sociales=dashboard_marche_considerations_sociales,
|
||
considerations_environnementales=dashboard_marche_considerations_environnementales,
|
||
montant_min=dashboard_montant_min,
|
||
montant_max=dashboard_montant_max,
|
||
techniques=dashboard_marche_techniques,
|
||
marche_innovant=dashboard_marche_innovant,
|
||
sous_traitance_declaree=dashboard_marche_sous_traitance_declaree,
|
||
)
|
||
|
||
# Génération des métriques
|
||
dff = lff.collect(engine="streaming")
|
||
|
||
global DF_FILTERED
|
||
DF_FILTERED = dff
|
||
|
||
logger.debug("Filter data: " + str(dff.height))
|
||
|
||
df_per_uid = (
|
||
dff.select("uid", "montant").group_by("uid").agg(pl.col("montant").first())
|
||
)
|
||
nb_marches = df_per_uid.height
|
||
|
||
cards = []
|
||
|
||
card_summary_table = get_dashboard_summary_table(dff, df_per_uid, nb_marches)
|
||
|
||
cards.append(make_card(title="Résumé", paragraphs=card_summary_table))
|
||
|
||
donut_acheteur_categorie, nb_acheteur_categories = make_donut(
|
||
lff,
|
||
"acheteur_categorie",
|
||
nulls="Autres",
|
||
per_uid=True,
|
||
potentially_many_names=True,
|
||
)
|
||
cards.append(
|
||
make_card(
|
||
title="Catégorie d'acheteur",
|
||
subtitle="en nombre de marchés attribués",
|
||
fig=donut_acheteur_categorie,
|
||
lg=12 if nb_acheteur_categories > 4 else 6,
|
||
xl=8 if nb_acheteur_categories > 4 else 4,
|
||
)
|
||
)
|
||
|
||
donut_titulaire_categorie = make_donut(
|
||
lff, "titulaire_categorie", per_uid=False, nulls="?"
|
||
)
|
||
cards.append(
|
||
make_card(
|
||
title="Catégorie d'entreprise",
|
||
subtitle="en nombre de titulaires",
|
||
fig=donut_titulaire_categorie,
|
||
)
|
||
)
|
||
|
||
donut_marche_type = make_donut(lff, "type", per_uid=True, nulls="?")
|
||
cards.append(
|
||
make_card(
|
||
title="Type d'achat",
|
||
subtitle="en nombre de marchés attribués",
|
||
fig=donut_marche_type,
|
||
)
|
||
)
|
||
|
||
distance_histogram = get_distance_histogram(lff)
|
||
cards.append(
|
||
make_card(
|
||
title="Distance acheteur–titulaire",
|
||
subtitle="en nombre de marchés, échelle logarithmique",
|
||
fig=distance_histogram,
|
||
)
|
||
)
|
||
|
||
top_acheteurs = get_top_org_table(
|
||
lff, org_type="acheteur", filters=False, extra_columns=[]
|
||
)
|
||
cards.append(make_card(title="Top acheteurs", fig=top_acheteurs, lg=12, xl=8))
|
||
|
||
top_titulaires = get_top_org_table(
|
||
lff, org_type="titulaire", filters=False, extra_columns=[]
|
||
)
|
||
cards.append(make_card(title="Top titulaires", fig=top_titulaires, lg=12, xl=8))
|
||
|
||
geographic_maps: list[dbc.Col] = get_geographic_maps(dff)
|
||
|
||
other_cards = []
|
||
|
||
sources_barchart = get_barchart_sources(lff, type_date="dateNotification")
|
||
other_cards.append(
|
||
make_card(
|
||
title="Sources de données",
|
||
subtitle="Nombre de marchés attribués par mois de notification et source de données",
|
||
fig=sources_barchart,
|
||
lg=12,
|
||
xl=8,
|
||
)
|
||
)
|
||
|
||
duplicate_matrix = get_duplicate_matrix()
|
||
other_cards.append(
|
||
make_card(
|
||
title="Matrice de doublons entre sources de données",
|
||
subtitle="Ce graphique illustre les doublons de marchés publics entre sources, c'est-à-dire la proportion de marchés publiés par plus d'une source.",
|
||
fig=duplicate_matrix,
|
||
lg=12,
|
||
xl=8,
|
||
)
|
||
)
|
||
|
||
return dbc.Row(children=cards + geographic_maps + other_cards)
|
||
|
||
|
||
@callback(
|
||
Output("download-observatoire", "data"),
|
||
Input("btn-download-observatoire", "n_clicks"),
|
||
State("dashboard_year", "value"),
|
||
State("dashboard_acheteur_id", "value"),
|
||
State("dashboard_acheteur_categorie", "value"),
|
||
State("dashboard_acheteur_departement_code", "value"),
|
||
State("dashboard_titulaire_id", "value"),
|
||
State("dashboard_titulaire_categorie", "value"),
|
||
State("dashboard_titulaire_departement_code", "value"),
|
||
State("dashboard_marche_type", "value"),
|
||
State("dashboard_marche_objet", "value"),
|
||
State("dashboard_marche_code_cpv", "value"),
|
||
State("dashboard_montant_min", "value"),
|
||
State("dashboard_montant_max", "value"),
|
||
State("dashboard_marche_techniques", "value"),
|
||
State("dashboard_marche_innovant", "value"),
|
||
State("dashboard_marche_sousTraitanceDeclaree", "value"),
|
||
State("dashboard_marche_considerationsSociales", "value"),
|
||
State("dashboard_marche_considerationsEnvironnementales", "value"),
|
||
State("observatoire-hidden-columns", "data"),
|
||
prevent_initial_call=True,
|
||
)
|
||
def download_observatoire(
|
||
_n_clicks,
|
||
dashboard_year,
|
||
dashboard_acheteur_id,
|
||
dashboard_acheteur_categorie,
|
||
dashboard_acheteur_departement_code,
|
||
dashboard_titulaire_id,
|
||
dashboard_titulaire_categorie,
|
||
dashboard_titulaire_departement_code,
|
||
dashboard_marche_type,
|
||
dashboard_marche_objet,
|
||
dashboard_marche_code_cpv,
|
||
dashboard_montant_min,
|
||
dashboard_montant_max,
|
||
dashboard_marche_techniques,
|
||
dashboard_marche_innovant,
|
||
dashboard_marche_sous_traitance_declaree,
|
||
dashboard_considerations_sociales,
|
||
dashboard_considerations_environnementales,
|
||
hidden_columns,
|
||
):
|
||
lff = prepare_dashboard_data(
|
||
lff=df.lazy(),
|
||
year=dashboard_year,
|
||
acheteur_id=dashboard_acheteur_id,
|
||
acheteur_categorie=dashboard_acheteur_categorie,
|
||
acheteur_departement_code=dashboard_acheteur_departement_code,
|
||
titulaire_id=dashboard_titulaire_id,
|
||
titulaire_categorie=dashboard_titulaire_categorie,
|
||
titulaire_departement_code=dashboard_titulaire_departement_code,
|
||
type=dashboard_marche_type,
|
||
objet=dashboard_marche_objet,
|
||
code_cpv=dashboard_marche_code_cpv,
|
||
considerations_sociales=dashboard_considerations_sociales,
|
||
considerations_environnementales=dashboard_considerations_environnementales,
|
||
montant_min=dashboard_montant_min,
|
||
montant_max=dashboard_montant_max,
|
||
techniques=dashboard_marche_techniques,
|
||
marche_innovant=dashboard_marche_innovant,
|
||
sous_traitance_declaree=dashboard_marche_sous_traitance_declaree,
|
||
)
|
||
|
||
if hidden_columns:
|
||
lff = lff.drop(hidden_columns)
|
||
|
||
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_observatoire_{date}.xlsx")
|
||
|
||
|
||
@callback(
|
||
Output("montant-modal", "is_open"),
|
||
Input({"type": "modal-trigger", "index": ALL}, "n_clicks"),
|
||
Input("montant-modal-close", "n_clicks"),
|
||
prevent_initial_call=True,
|
||
)
|
||
def toggle_montant_modal(n_triggers, _close):
|
||
return isinstance(ctx.triggered_id, dict) and any(n_triggers)
|
||
|
||
|
||
@callback(
|
||
Output("page_title", "children"),
|
||
Input("dashboard_acheteur_id", "value"),
|
||
Input("dashboard_titulaire_id", "value"),
|
||
prevent_initial_call=False,
|
||
)
|
||
def add_organization_name_in_title(acheteur_id, titulaire_id):
|
||
def lookup_nom(df_org, id_col, nom_col, org_id):
|
||
match = df_org.filter(pl.col(id_col) == org_id)
|
||
return match[nom_col].item(0) if match.height >= 1 else None
|
||
|
||
if acheteur_id and len(acheteur_id) == 14:
|
||
if nom := lookup_nom(df_acheteurs, "acheteur_id", "acheteur_nom", acheteur_id):
|
||
return [
|
||
name,
|
||
html.Small(nom, className="text-muted d-block fw-normal fs-5"),
|
||
]
|
||
elif titulaire_id and len(titulaire_id) == 14:
|
||
if nom := lookup_nom(
|
||
df_titulaires, "titulaire_id", "titulaire_nom", titulaire_id
|
||
):
|
||
return [
|
||
name,
|
||
html.Small(nom, className="text-muted d-block fw-normal fs-5"),
|
||
]
|
||
return name
|
||
|
||
|
||
@callback(
|
||
Output("observatoire-preview", "is_open"),
|
||
Input("btn-observatoire-preview", "n_clicks"),
|
||
State("observatoire-preview", "is_open"),
|
||
prevent_initial_call=True,
|
||
)
|
||
def toggle_observatoire_preview(n_clicks, is_open):
|
||
return not is_open
|
||
|
||
|
||
@callback(
|
||
Output("observatoire-preview-table", "data"),
|
||
Output("observatoire-preview-table", "columns"),
|
||
Output("observatoire-preview-table", "tooltip_header"),
|
||
Output("observatoire-preview-table", "data_timestamp"),
|
||
Output("nb_rows_observatoire", "children"),
|
||
Output("btn-download-observatoire", "disabled"),
|
||
Output("btn-download-observatoire", "children"),
|
||
Output("btn-download-observatoire", "title"),
|
||
Output("filter-cleanup-trigger-observatoire-preview", "data", allow_duplicate=True),
|
||
Input("observatoire-preview", "is_open"),
|
||
Input("observatoire-preview-table", "filter_query"),
|
||
Input("observatoire-preview-table", "page_current"),
|
||
Input("observatoire-preview-table", "page_size"),
|
||
Input("observatoire-preview-table", "sort_by"),
|
||
State("observatoire-preview-table", "data_timestamp"),
|
||
prevent_initial_call=True,
|
||
)
|
||
def populate_preview_table(
|
||
is_open, filter_query, page_current, page_size, sort_by, data_timestamp
|
||
):
|
||
if not is_open:
|
||
return (no_update,) * 9
|
||
|
||
global DF_FILTERED
|
||
lff = DF_FILTERED.lazy()
|
||
|
||
return prepare_table_data(
|
||
lff,
|
||
data_timestamp,
|
||
filter_query,
|
||
page_current,
|
||
page_size,
|
||
sort_by,
|
||
"observatoire-preview",
|
||
)
|
||
|
||
|
||
@callback(
|
||
Output("observatoire-hidden-columns", "data", allow_duplicate=True),
|
||
Input("observatoire_preview_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("observatoire-preview-table", "hidden_columns"),
|
||
Input(
|
||
"observatoire-hidden-columns",
|
||
"data",
|
||
),
|
||
)
|
||
def store_hidden_columns(hidden_columns):
|
||
return hidden_columns
|
||
|
||
|
||
@callback(
|
||
Output("observatoire_preview_column_list", "selected_rows"),
|
||
Input("observatoire-preview-table", "hidden_columns"),
|
||
State(
|
||
"observatoire_preview_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("observatoire-preview-columns-modal", "is_open"),
|
||
Input("observatoire-preview-columns-open", "n_clicks"),
|
||
Input("observatoire-preview-columns-close", "n_clicks"),
|
||
State("observatoire-preview-columns-modal", "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
|