From 7c157d9d71bc7fb639efef39d3b05c9de0bb61ac Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 30 Sep 2025 14:57:41 +0200 Subject: [PATCH] =?UTF-8?q?Vue=20march=C3=A9=20simple=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/app.py | 2 +- src/assets/style.css | 35 +++++++- src/pages/about.py | 2 +- src/pages/acheteur.py | 19 ++--- src/pages/home.py | 4 +- src/pages/marche.py | 188 +++++++++++++++++++++++++++++++++++++++++ src/pages/titulaire.py | 13 +-- src/utils.py | 32 ++++--- 9 files changed, 260 insertions(+), 37 deletions(-) create mode 100644 src/pages/marche.py diff --git a/README.md b/README.md index ee53ae6..d436933 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ python run.py #### 2.1.0 -- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) et [titulaire](https://decp.info/titulaires/51903758414786) ([#28](https://github.com/ColinMaudry/decp.info/issues/28) et [#35](https://github.com/ColinMaudry/decp.info/issues/35)) 🔎 +- 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)) 💅 diff --git a/src/app.py b/src/app.py index 32b659a..71fdb43 100644 --- a/src/app.py +++ b/src/app.py @@ -96,7 +96,7 @@ app.layout = html.Div( page["name"], href=page["relative_path"], className="nav" ) for page in page_registry.values() - if page["name"] not in ["Acheteur", "Titulaire"] + if page["name"] not in ["Acheteur", "Titulaire", "Marché"] ] ), ], diff --git a/src/assets/style.css b/src/assets/style.css index 66f426a..bf70592 100644 --- a/src/assets/style.css +++ b/src/assets/style.css @@ -88,8 +88,8 @@ td[data-dash-column="objet"] { color: #333; width: 400px !important; max-width: 400px !important; - height: 100px !important; - max-height: 100px !important; + height: 150px !important; + max-height: 150px !important; overflow: hidden; } @@ -179,6 +179,9 @@ summary > h3 { justify-content: space-between; } +.wrapper > div { +} + .org_title { grid-column: 1 / 3; grid-row: 1; @@ -207,3 +210,31 @@ summary > h3 { grid-column: 3; grid-row: 2; } + +/* Vue marché */ + +.marche_infos p { + margin-top: 6px; + margin-bottom: 6px; +} + +.marche_title { + grid-column: 1/3; + grid-row: 1; +} + +.marche_map { + grid-column: 3; + grid-row: 2; + width: 400px; +} + +.marche_infos_1 { + grid-column: 1; + grid-row: 2; +} + +.marche_infos_2 { + grid-column: 2; + grid-row: 2; +} diff --git a/src/pages/about.py b/src/pages/about.py index d4b60aa..f2278e3 100644 --- a/src/pages/about.py +++ b/src/pages/about.py @@ -48,7 +48,7 @@ les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter - ✉️ [inscription à la liste de diffusion](https://6254d9a3.sibforms.com/serve/MUIFAEonUVkoSVrdgey18CTgLyI16xw4yeu-M-YOUzhWE_AgfQfbgkyT7GvA_RYLro9MfuRqkzQxSvu7-uzbMSv2a2ZQPsliM7wtiiqIL8kR2zOvl6m11fb5qjcOxMAYsLiY_YBi3P7NY95CTJ8vRY4CpsDclF2iLooOElKkTgIgi5nePe7zAIrgiYM5v2EuALlGJZMEG9vBP-Cu) (annonces des mises à jour et évènements, maximum une fois par mois) - 💾 [données consolidées en Open Data](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire/) -- 🗞️ [mon blog](https://colin.maudry.com), qui parle beaucoup de transparence des marchés publics +- 🗞️ [mon blog](https://colin.maudry.com) - 📔 [wiki du projet](https://github.com/ColinMaudry/decp-processing/wiki) - 🚰 code source - [de decp.info](https://github.com/ColinMaudry/decp.info) diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 6af4f51..4245b00 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -5,7 +5,7 @@ from dash import Input, Output, State, callback, dash_table, dcc, html, register from src.figures import point_on_map from src.utils import ( - add_org_links_in_dict, + add_links_in_dict, format_montant, format_number, get_annuaire_data, @@ -177,6 +177,7 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> pl.LazyFrame: lff = lff.fill_null("") lff = lff.select( "id", + "uid", "objet", "dateNotification", "titulaire_id", @@ -201,23 +202,15 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> pl.LazyFrame: Input(component_id="acheteur_data", component_property="data"), ) def get_last_marches_table(data) -> html.Div: - columns = [ - "id", - "objet", - "dateNotification", - "titulaire_nom", - "montant", - "codeCPV", - "dureeMois", - ] - dff = pl.DataFrame(data) dff = format_montant(dff) columns, tooltip = setup_table_columns( - dff, hideable=False, exclude=["titulaire_id", "titulaire_typeIdentifiant"] + dff, + hideable=False, + exclude=["titulaire_id", "titulaire_typeIdentifiant", "uid"], ) data = dff.to_dicts() - data = add_org_links_in_dict(data, "titulaire") + data = add_links_in_dict(data, "titulaire") table = html.Div( className="marches_table", diff --git a/src/pages/home.py b/src/pages/home.py index b10465a..8d173c3 100644 --- a/src/pages/home.py +++ b/src/pages/home.py @@ -5,7 +5,7 @@ import polars as pl from dash import Input, Output, State, callback, dash_table, dcc, html, register_page from src.utils import ( - add_org_links, + add_links, add_resource_link, filter_table_data, format_montant, @@ -197,7 +197,7 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp) dff = dff.slice(start_row, page_size) # Ajout des liens vers l'annuaire des entreprises - dff = add_org_links(dff) + dff = add_links(dff) # Ajout des liens vers les fichiers Open Data dff = add_resource_link(dff) diff --git a/src/pages/marche.py b/src/pages/marche.py new file mode 100644 index 0000000..88c1baf --- /dev/null +++ b/src/pages/marche.py @@ -0,0 +1,188 @@ +from datetime import datetime + +import polars as pl +from dash import Input, Output, callback, dcc, html, register_page +from polars import selectors as cs + +from src.utils import data_schema, format_montant, lf, meta_content + +register_page( + __name__, + path_template="/marches/", + title=meta_content["title"], + name="Marché", + description=meta_content["description"], + image_url=meta_content["image_url"], + order=7, +) + +layout = [ + dcc.Store(id="marche_data"), + dcc.Store(id="titulaires_data"), + dcc.Location(id="url", refresh="callback-nav"), + html.Div( + className="container marche_infos", + children=[ + html.P("Vous consultez un résumé des données de ce marché public"), + html.Ul( + [ + html.Li( + "après son attribution aux titulaires qui l'ont remporté à la suite d'un appel d'offres (ou sans appel d'offres via une attribution directe)" + ), + html.Li( + "après avoir appliqué les éventuelles modifications de montant, durée ou titulaires renseignées par l'acheteur" + ), + ] + ), + html.P( + "Le montant total payé aux titulaires, la durée du marché et la liste des titulaires peuvent cependant encore évoluer jusqu'à la fin de l'exécution du marché." + ), + html.Div( + className="wrapper", + children=[ + html.Div( + className="marche_map", + id="marche_map", + children=[ + html.H4("Titulaires"), + html.Ul(id="marche_infos_titulaires"), + ], + ), + html.Div(className="marche_infos_1", id="marche_infos_1"), + html.Div(className="marche_infos_2", id="marche_infos_2"), + ], + ), + ], + ), +] + + +@callback( + Output("marche_data", "data"), + Output("titulaires_data", "data"), + Input(component_id="url", component_property="pathname"), +) +def get_marche_data(url): + marche_uid = url.split("/")[-1] + + # Récupération des données du marché à partir du lf global + lff = lf.filter(pl.col("uid") == pl.lit(marche_uid)) + + # Données des titulaires du marché + dff_titulaires = lff.select(cs.starts_with("titulaire")).collect(engine="streaming") + + # Données du marché + dff_marche = ( + lff.select(~cs.starts_with("titulaires")).unique().collect(engine="streaming") + ) + dff_marche = format_montant(dff_marche) + + assert dff_marche.height == 1 + + return dff_marche.to_dicts()[0], dff_titulaires.to_dicts() + + +@callback( + Output("marche_infos_1", "children"), + Output("marche_infos_2", "children"), + Output("marche_infos_titulaires", "children"), + Input("marche_data", "data"), + Input("titulaires_data", "data"), +) +def update_marche_info(marche, titulaires): + def make_parameter(col): + column_object = data_schema.get(col) + column_name = column_object.get("friendly_name") if column_object else col + + if marche[col]: + if col == "acheteur_nom": + value = html.A( + href=f"/acheteurs/{marche['acheteur_id']}", + children=marche["acheteur_nom"], + ) + elif col == "sourceDataset": + value = html.A( + href=marche["sourceFile"], children=marche["sourceDataset"] + ) + column_name = "Source des données" + + # Dates + elif col in ["dateNotification", "datePublicationDonnees"]: + print(marche[col]) + + value = datetime.fromisoformat(marche[col]).strftime("%d/%m/%Y") + + # Listes + elif ( + col + in [ + "techniques", + "typesPrix", + "considerationsSociales", + "considerationsEnvironnementales", + ] + and "," in marche[col] + ): + col_values = marche[col].split(", ") + lines = [] + for val in col_values: + lines.append(html.Li(val)) + _content = html.Div( + [html.P([column_name, " : "]), html.Ul(children=lines)] + ) + return _content + else: + value = marche.get(col) + else: + value = "" + + param_content = html.P([column_name, " : ", html.Strong(value)]) + return param_content + + marche_infos = [ + make_parameter("id"), + make_parameter("objet"), + make_parameter("dateNotification"), # date + make_parameter("nature"), + make_parameter("acheteur_nom"), # lien + make_parameter("montant"), + make_parameter("codeCPV"), + make_parameter("procedure"), + make_parameter("techniques"), # list + make_parameter("dureeMois"), + make_parameter("offresRecues"), + make_parameter("datePublicationDonnees"), # date + make_parameter("formePrix"), + make_parameter("typesPrix"), # list + make_parameter("attributionAvance"), + make_parameter("tauxAvance"), + make_parameter("marcheInnovant"), # label + make_parameter("modalitesExecution"), + make_parameter("considerationsSociales"), # list + make_parameter("considerationsEnvironnementales"), # list + make_parameter("ccag"), + make_parameter("sousTraitanceDeclaree"), + make_parameter("typeGroupementOperateurs"), + make_parameter("origineFrance"), + make_parameter("origineUE"), + make_parameter("idAccordCadre"), + make_parameter("sourceDataset"), # lien + ] + + half = round(len(marche_infos) / 2) + # pas inclus pour l'instant : lieu d'exécution, modifications + + titulaires_lines = [] + for titulaire in titulaires: + if titulaire["titulaire_typeIdentifiant"] == "SIRET": + content = html.Li( + html.A( + href=f"/titulaires/{titulaire['titulaire_id']}", + children=titulaire["titulaire_nom"], + ) + ) + else: + content = html.Li(titulaire["titulaire_nom"]) + titulaires_lines.append(content) + + return marche_infos[:half], marche_infos[half:], titulaires_lines diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index 2816f0f..bca19c4 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -5,7 +5,7 @@ from dash import Input, Output, State, callback, dash_table, dcc, html, register from src.figures import point_on_map from src.utils import ( - add_org_links_in_dict, + add_links_in_dict, format_montant, format_number, get_annuaire_data, @@ -181,6 +181,7 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> pl.LazyFrame: ) lff = lff.fill_null("") lff = lff.select( + "id", "uid", "objet", "dateNotification", @@ -217,11 +218,13 @@ def get_last_marches_table(data) -> html.Div: dff = pl.DataFrame(data) dff = format_montant(dff) - columns, tooltip = setup_table_columns(dff, hideable=False, exclude=["acheteur_id"]) + columns, tooltip = setup_table_columns( + dff, hideable=False, exclude=["acheteur_id", "id"] + ) data = dff.to_dicts() # Idéalement on utiliserait add_org_links(), mais le résultat attendu # est différent de home.py (Tableau) - data = add_org_links_in_dict(data, "acheteur") + data = add_links_in_dict(data, "acheteur") table = html.Div( className="marches_table", @@ -248,8 +251,8 @@ def get_last_marches_table(data) -> html.Div: "whiteSpace": "normal", }, { - "if": {"column_id": " acheteur_nom"}, - "minWidth": "200px", + "if": {"column_id": "acheteur_nom"}, + "maxWidth": "400px", "textAlign": "left", "overflow": "hidden", "lineHeight": "18px", diff --git a/src/utils.py b/src/utils.py index dc55c19..4aaa579 100644 --- a/src/utils.py +++ b/src/utils.py @@ -49,7 +49,7 @@ def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame: return dff -def add_org_links(dff: pl.DataFrame): +def add_links(dff: pl.DataFrame): dff = dff.with_columns( pl.when(pl.col("titulaire_typeIdentifiant") == "SIRET") .then( @@ -62,25 +62,29 @@ def add_org_links(dff: pl.DataFrame): .otherwise(pl.col("titulaire_id")) .alias("titulaire_id") ) - dff = dff.with_columns( - ( - '' - + pl.col("acheteur_id") - + "" - ).alias("acheteur_id") - ) + + for column, path in [("acheteur_id", "acheteurs"), ("uid", "marches")]: + dff = dff.with_columns( + ( + f'' + + pl.col(column) + + "" + ).alias(column) + ) return dff -def add_org_links_in_dict(data: list, org_type: str) -> list: +def add_links_in_dict(data: list, org_type: str) -> list: new_data = [] for marche in data: org_id = marche[org_type + "_id"] marche[org_type + "_nom"] = ( f'{marche[org_type + "_nom"]}' ) + marche["id"] = f'{marche["id"]}' + marche["uid"] = f'{marche["uid"]}' new_data.append(marche) return new_data @@ -280,7 +284,7 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup if column_object: tooltip[column_id] = { - "value": f"""**{column_object.get("friendly_name", column_id)}** + "value": f"""**{column_object.get("friendly_name")}** ({column_id}) """ + column_object["description"], @@ -305,3 +309,7 @@ meta_content = { # Récupération du schéma des données tabulaires data_schema: dict = get(os.getenv("DATA_SCHEMA_PATH"), follow_redirects=True).json() +data_schema["source"] = { + "description": "Code de la source des données, avec un lien vers le fichier Open Data dont proviennent les données de ce marché public.", + "friendly_name": "Source des données", +}