Merge branch 'feature/40_vue_marché' into dev

This commit is contained in:
Colin Maudry
2025-09-30 15:02:04 +02:00
10 changed files with 430 additions and 37 deletions
+1 -1
View File
@@ -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)) 💅
+1 -1
View File
@@ -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é"]
]
),
],
+33 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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)
+6 -13
View File
@@ -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",
+170
View File
@@ -0,0 +1,170 @@
import os
import re
import smtplib
import time
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from dash import Input, Output, State, callback, dcc, html, register_page
from flask import request
from src.utils import meta_content
name = "Contact"
register_page(
__name__,
path="/contact",
title=meta_content["title"],
name=name,
description=meta_content["description"],
image_url=meta_content["image_url"],
order=6,
)
layout = html.Div(
className="container",
children=[
html.H2("Contact", id="contact"),
html.P(
"Votre message arrivera directement dans ma boîte mail, et je reviendrai vers vous rapidement."
),
html.Div(
[
html.Label("Votre nom"),
dcc.Input(
id="input-name",
type="text",
style={"width": "100%", "padding": "8px", "margin": "8px 0"},
),
html.Label("Votre adresse email"),
dcc.Input(
id="input-email",
type="email",
style={"width": "100%", "padding": "8px", "margin": "8px 0"},
),
html.Label("Message"),
dcc.Textarea(
id="input-message",
style={
"width": "100%",
"height": 200,
"padding": "8px",
"margin": "8px 0",
},
),
html.Button(
"Envoyer ",
id="submit-button",
n_clicks=0,
style={"marginTop": "10px"},
),
html.Div(
id="output-message", style={"marginTop": "10px", "color": "green"}
),
],
style={
"maxWidth": "600px",
"margin": "auto",
"padding": "20px",
"lineHeight": "20px",
},
),
dcc.Markdown("""
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
- venez discuter de la transparence de la commande publique [sur le forum teamopendata.org](https://teamopendata.org/c/commande-publique/101)
"""),
],
)
rate_limit_store = {} # {ip: last_timestamp}
RATE_LIMIT_WINDOW = 300 # 5 minutes
def is_rate_limited():
ip = request.remote_addr
now = time.time()
last_time = rate_limit_store.get(ip)
if last_time and (now - last_time) < RATE_LIMIT_WINDOW:
return True # rate limited !
rate_limit_store[ip] = now # màj du timestamp
return False
def sanitize_email(email):
return re.match(r"[^@]+@[^@]+\.[^@]+", email)
@callback(
Output("output-message", "children"),
Input("submit-button", "n_clicks"),
State("input-name", "value"),
State("input-email", "value"),
State("input-message", "value"),
prevent_initial_call=True,
)
def send_email(n_clicks, form_name, form_email, form_message):
if not all([form_name, form_email, form_message]):
return html.Div(
"Veuillez s'il vous plaît remplir tous les champs.", style={"color": "red"}
)
client_ip = request.remote_addr
if is_rate_limited():
wait_time = int(RATE_LIMIT_WINDOW - (time.time() - rate_limit_store[client_ip]))
return html.Div(
f"⏳ J'ai mis en place une protection contre le spam, et vous m'avez écrit il y a moins de 5 minutes. "
f"Veuillez s'il vous plaît attendre {wait_time} secondes avant de renvoyer un message.",
style={"color": "black"},
)
try:
# Configuration du serveur SMTP
smtp_server = os.getenv("SENDER_SERVER_DOMAIN")
smtp_port = 587
login_email = os.getenv("LOGIN_EMAIL")
from_email = os.getenv("FROM_EMAIL")
to_email = os.getenv("TO_EMAIL", from_email)
login_password = os.getenv("LOGIN_PASSWORD")
print(
smtp_server,
smtp_port,
login_email,
from_email,
to_email,
login_password,
sep="\n",
)
# Création de l'email
email = MIMEMultipart()
email["From"] = from_email
email["To"] = to_email
email["Subject"] = f"[decp.info] Message de {form_name}"
body = f"""
Nom : {form_name}
Email : {form_email}
Message :
{form_message}
"""
email.attach(MIMEText(body, "plain"))
# Send email
server = smtplib.SMTP(smtp_server, smtp_port)
server.starttls()
server.login(login_email, login_password)
server.sendmail(from_email, to_email, email.as_string())
server.quit()
return html.Div("✅ Envoi réussi", style={"color": "green"})
except Exception as e:
print(e)
return html.Div(
f"❌ Échec de l'envoi du message : {str(e)}", style={"color": "red"}
)
+2 -2
View File
@@ -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)
+188
View File
@@ -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/<uid>",
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
+8 -5
View File
@@ -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",
+20 -12
View File
@@ -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(
(
'<a href = "/acheteurs/'
+ pl.col("acheteur_id")
+ '" target="_blank">'
+ pl.col("acheteur_id")
+ "</a>"
).alias("acheteur_id")
)
for column, path in [("acheteur_id", "acheteurs"), ("uid", "marches")]:
dff = dff.with_columns(
(
f'<a href = "/{path}/'
+ pl.col(column)
+ '" target="_blank">'
+ pl.col(column)
+ "</a>"
).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'<a href="/{org_type}s/{org_id}">{marche[org_type + "_nom"]}</a>'
)
marche["id"] = f'<a href="/marches/{marche["uid"]}">{marche["id"]}</a>'
marche["uid"] = f'<a href="/marches/{marche["uid"]}">{marche["uid"]}</a>'
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",
}