Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83119b86e9 | |||
| af89bb0630 | |||
| eb5be1972e | |||
| b303a8bea6 | |||
| 472fbb7cbb | |||
| 6891aabd4d | |||
| 3beedbcfe4 | |||
| 37e893d642 | |||
| 64fc40e5aa | |||
| cb885eeb90 | |||
| 61dbf237cf | |||
| 39af4bafbe | |||
| f480439985 | |||
| ad9b4f0fdf | |||
| 0532f6b0a7 | |||
| 6fdf01e5e1 | |||
| 15de2aaf17 | |||
| 55e2992559 | |||
| f16cca036d | |||
| deba0a244e | |||
| ef11184286 | |||
| edd52d471e | |||
| 7b3ea9c580 | |||
| bfc4065cbb | |||
| 41a832d7be | |||
| 1561cb3c9a | |||
| f1725a95f8 | |||
| b59afd1cbc | |||
| 75b42f4baf | |||
| 8f5d38ff78 | |||
| bd149ff11f | |||
| cd81aa9532 | |||
| 2a381f6dbb | |||
| e609bc3e32 | |||
| c3ebdb0057 | |||
| 55bb92468e | |||
| 965aef06f9 | |||
| 3ce37784dc | |||
| cee51d929c | |||
| b11aa20743 | |||
| 674639cf6e | |||
| 427eb111bf |
@@ -1,3 +1,11 @@
|
||||
#### 2.6.0 (5 février 2026)
|
||||
|
||||
- Suite de la refonte graphique
|
||||
- Persistence des filtres, des tris et des choix de colonnes sur toutes les pages
|
||||
- Joli tableau pour choisir les colonnes à afficher
|
||||
- Meilleure gestion des acheteurs et titulaires absents de la base SIRENE
|
||||
- Amélioration du SEO (liens canoniques)
|
||||
|
||||
##### 2.5.1 (29 janvier 2026)
|
||||
|
||||
- Mise en production un peu hâtive ([#67](https://github.com/ColinMaudry/decp.info/issues/67), [#68](https://github.com/ColinMaudry/decp.info/issues/68))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# decp.info
|
||||
|
||||
> v2.5.1
|
||||
> v2.6.0
|
||||
> Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
||||
|
||||
=> [decp.info](https://decp.info)
|
||||
|
||||
+18
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "decp.info"
|
||||
description = "Interface d'exploration et d'analyse des marchés publics français."
|
||||
version = "2.5.1"
|
||||
version = "2.6.0"
|
||||
requires-python = ">= 3.10"
|
||||
authors = [
|
||||
{ name = "Colin Maudry", email = "colin@colmo.tech" }
|
||||
@@ -22,5 +22,22 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
"pytest-env",
|
||||
"pre-commit",
|
||||
"selenium",
|
||||
"webdriver-manager",
|
||||
"dash[testing]",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = [
|
||||
"src"
|
||||
]
|
||||
testpaths = [
|
||||
"tests"
|
||||
]
|
||||
env = [
|
||||
"DATA_FILE_PARQUET_PATH=tests/test.parquet"
|
||||
]
|
||||
addopts = "-p no:warnings"
|
||||
|
||||
+5
-1
@@ -9,6 +9,10 @@ from flask import Response
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# if os.getenv("PYTEST_CURRENT_TEST"):
|
||||
# os.environ["DATA_FILE_PARQUET_PATH"]
|
||||
|
||||
|
||||
development = os.getenv("DEVELOPMENT").lower() == "true"
|
||||
|
||||
meta_tags = [
|
||||
@@ -22,7 +26,7 @@ meta_tags = [
|
||||
if development:
|
||||
meta_tags.append({"name": "robots", "content": "noindex"})
|
||||
|
||||
app = Dash(
|
||||
app: Dash = Dash(
|
||||
title="decp.info",
|
||||
use_pages=True,
|
||||
compress=True,
|
||||
|
||||
@@ -27,18 +27,50 @@ b {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 36px 0 20px 0;
|
||||
}
|
||||
|
||||
/* Base Button Styles */
|
||||
/* Base Button Styles
|
||||
button {
|
||||
font-weight: 400;
|
||||
background-color: #fff;
|
||||
border-radius: 3px;
|
||||
appearance: auto;
|
||||
border: solid var(--primary-color) 1px;
|
||||
/* couleur thème foncée */
|
||||
} */
|
||||
|
||||
button.btn.btn-primary,
|
||||
button.show-hide {
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
outline: 0;
|
||||
color: #fff;
|
||||
border: 0;
|
||||
height: 30px;
|
||||
padding-top: 2px;
|
||||
background-image: linear-gradient(
|
||||
rgb(209, 96, 73),
|
||||
rgb(179, 56, 33) 26%,
|
||||
rgb(159, 36, 22)
|
||||
);
|
||||
}
|
||||
|
||||
button.btn.btn-primary:hover,
|
||||
button.show-hide:hover {
|
||||
background-image: linear-gradient(
|
||||
rgb(239, 126, 103),
|
||||
rgb(209, 86, 63) 26%,
|
||||
rgb(189, 66, 52)
|
||||
);
|
||||
}
|
||||
|
||||
button[disabled] {
|
||||
@@ -160,12 +192,13 @@ p.version > a {
|
||||
.table-menu {
|
||||
font-size: 16px;
|
||||
margin: 12px 0 12px 0;
|
||||
height: 36px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-menu > * {
|
||||
margin: 8px 16px 8px 0;
|
||||
float: left;
|
||||
}
|
||||
|
||||
#source_table {
|
||||
@@ -207,17 +240,21 @@ table.cell-table th {
|
||||
.dash-table-container
|
||||
.dash-spreadsheet-container
|
||||
.dash-spreadsheet-inner
|
||||
th.dash-header {
|
||||
th.dash-header,
|
||||
.dash-table-container
|
||||
.dash-spreadsheet-container
|
||||
.dash-spreadsheet-inner
|
||||
th.dash-select-header {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-family: "Inter", sans-serif;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
padding-right: 12px;
|
||||
padding: 2px 12px 4px 2px;
|
||||
border: 1px solid rgb(179, 56, 33) !important;
|
||||
background-color: rgb(179, 56, 33);
|
||||
border-bottom: none !important;
|
||||
height: 34px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.dash-table-container
|
||||
@@ -225,7 +262,13 @@ table.cell-table th {
|
||||
.dash-spreadsheet-inner
|
||||
table.cell-table
|
||||
tr:first-of-type
|
||||
th.dash-header:first-of-type {
|
||||
th.dash-header:first-of-type,
|
||||
.dash-table-container
|
||||
.dash-spreadsheet-container
|
||||
.dash-spreadsheet-inner
|
||||
table.cell-table
|
||||
tr:first-of-type
|
||||
th.dash-select-header:first-of-type {
|
||||
border-top-left-radius: 3px !important;
|
||||
}
|
||||
|
||||
@@ -258,6 +301,15 @@ table.cell-table th {
|
||||
vertical-align: center;
|
||||
}
|
||||
|
||||
.dash-table-container
|
||||
.dash-spreadsheet-container
|
||||
.dash-spreadsheet-inner
|
||||
.cell-table
|
||||
.dash-filter
|
||||
input[type="text"]::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.dash-filter--case {
|
||||
display: none;
|
||||
}
|
||||
@@ -307,22 +359,44 @@ table.cell-table th {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.show-hide {
|
||||
button.show-hide {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
margin: 0 0 10px 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
.show-hide::before {
|
||||
background: inherit;
|
||||
content: "Colonnes affichées";
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
|
||||
|
||||
#column_list .show-hide,
|
||||
#table .show-hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.show-hide-menu-item > input {
|
||||
margin-right: 10px;
|
||||
} */
|
||||
|
||||
#btn-copy-url:before {
|
||||
}
|
||||
|
||||
/* Dropdowns */
|
||||
.Select-placeholder {
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
/* Checkboxes */
|
||||
|
||||
input[type="checkbox"] {
|
||||
height: 17px;
|
||||
width: 17px;
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
|
||||
+91
-45
@@ -6,10 +6,10 @@ import plotly.graph_objects as go
|
||||
import polars as pl
|
||||
from dash import dash_table, dcc, html
|
||||
|
||||
from src.utils import format_number
|
||||
from src.utils import data_schema, df, format_number
|
||||
|
||||
|
||||
def get_map_count_marches(df: pl.DataFrame):
|
||||
def get_map_count_marches():
|
||||
lf = df.lazy()
|
||||
lf = lf.with_columns(
|
||||
pl.col("lieuExecution_code").str.head(2).str.zfill(2).alias("Département")
|
||||
@@ -31,16 +31,16 @@ def get_map_count_marches(df: pl.DataFrame):
|
||||
for f in departements["features"]:
|
||||
f["id"] = f["properties"]["code"]
|
||||
|
||||
df = lf.collect(engine="streaming")
|
||||
df_map = lf.collect(engine="streaming")
|
||||
|
||||
fig = px.choropleth(
|
||||
df,
|
||||
df_map,
|
||||
geojson=departements,
|
||||
locations="Département",
|
||||
color="uid",
|
||||
color_continuous_scale="Reds",
|
||||
title="Nombres de marchés attribués par département (lieu d'exécution)",
|
||||
range_color=(df["uid"].min(), df["uid"].max()),
|
||||
range_color=(df_map["uid"].min(), df_map["uid"].max()),
|
||||
labels={"uid": "Marchés attribués"},
|
||||
scope="europe",
|
||||
width=900,
|
||||
@@ -91,11 +91,8 @@ def get_yearly_statistics(statistics, today_str) -> html.Div:
|
||||
page_size=10,
|
||||
sort_action="none",
|
||||
filter_action="none",
|
||||
style_header={
|
||||
"border": "solid 1px rgb(179, 56, 33)",
|
||||
"backgroundColor": "rgb(179, 56, 33)",
|
||||
"color": "white",
|
||||
},
|
||||
style_header={"fontFamily": "Inter", "fontSize": "16px"},
|
||||
style_cell={"fontFamily": "Inter", "fontSize": "16px"},
|
||||
)
|
||||
|
||||
return html.Div(children=table, className="marches_table")
|
||||
@@ -210,11 +207,8 @@ def get_sources_tables(source_path) -> html.Div:
|
||||
],
|
||||
sort_action="native",
|
||||
markdown_options={"html": True},
|
||||
style_header={
|
||||
"border": "solid 1px rgb(179, 56, 33)",
|
||||
"backgroundColor": "rgb(179, 56, 33)",
|
||||
"color": "white",
|
||||
},
|
||||
style_header={"fontFamily": "Inter", "fontSize": "16px"},
|
||||
style_cell={"fontFamily": "Inter", "fontSize": "16px"},
|
||||
)
|
||||
|
||||
return html.Div(children=datatable)
|
||||
@@ -250,6 +244,7 @@ def point_on_map(lat, lon):
|
||||
fig.update_layout(map_center={"lat": 46.6, "lon": 1.89}, map_zoom=4)
|
||||
|
||||
graph = dcc.Graph(id="map", figure=fig)
|
||||
graph = html.Div(style={"width": "400px"})
|
||||
return graph
|
||||
|
||||
|
||||
@@ -264,10 +259,12 @@ class DataTable(dash_table.DataTable):
|
||||
page_action: Literal["native", "custom", "none"] = "native",
|
||||
sort_action: Literal["native", "custom", "none"] = "native",
|
||||
filter_action: Literal["native", "custom", "none"] = "native",
|
||||
style_cell_conditional: list | None = None,
|
||||
style_cell: dict | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
# Styles de base
|
||||
style_cell_conditional = [
|
||||
style_cell_conditional_common = [
|
||||
{
|
||||
"if": {"column_id": "objet"},
|
||||
"minWidth": "350px",
|
||||
@@ -295,20 +292,29 @@ class DataTable(dash_table.DataTable):
|
||||
"lineHeight": "18px",
|
||||
"whiteSpace": "normal",
|
||||
},
|
||||
{
|
||||
"if": {"column_id": "montant"},
|
||||
"textAlign": "right",
|
||||
},
|
||||
{
|
||||
"if": {"column_id": "dureeMois"},
|
||||
"textAlign": "right",
|
||||
},
|
||||
{
|
||||
"if": {"column_id": "titulaire_distance"},
|
||||
"textAlign": "right",
|
||||
},
|
||||
]
|
||||
|
||||
style_cell_common = {"fontFamily": "Inter", "fontSize": "16px"}
|
||||
|
||||
for key in data_schema.keys():
|
||||
field = data_schema[key]
|
||||
if field["type"] in ["number", "integer"]:
|
||||
rule = {
|
||||
"if": {"column_id": field["name"]},
|
||||
"textAlign": "right",
|
||||
# "fontFamily": "Fira Code",
|
||||
}
|
||||
style_cell_conditional_common.append(rule)
|
||||
|
||||
style_cell_conditional = (
|
||||
style_cell_conditional or []
|
||||
) + style_cell_conditional_common
|
||||
if style_cell:
|
||||
style_cell.update(style_cell_common)
|
||||
else:
|
||||
style_cell = style_cell_common
|
||||
style_header = style_cell
|
||||
|
||||
# Initialisation de la classe parente avec les arguments
|
||||
super().__init__(
|
||||
id=dtid,
|
||||
@@ -320,16 +326,17 @@ class DataTable(dash_table.DataTable):
|
||||
page_action=page_action,
|
||||
filter_options={
|
||||
"case": "insensitive",
|
||||
"placeholder_text": "",
|
||||
"placeholder_text": "Filtre de colonne...",
|
||||
},
|
||||
sort_action=sort_action,
|
||||
sort_mode="multi",
|
||||
sort_by=[],
|
||||
row_deletable=False,
|
||||
page_current=0,
|
||||
style_cell_conditional=style_cell_conditional,
|
||||
data_timestamp=0,
|
||||
markdown_options={"html": True},
|
||||
style_header=style_header,
|
||||
style_cell=style_cell,
|
||||
tooltip_duration=8000,
|
||||
tooltip_delay=350,
|
||||
hidden_columns=hidden_columns,
|
||||
@@ -360,13 +367,6 @@ def get_duplicate_matrix() -> html.Div:
|
||||
|
||||
Passez votre souris sur une case pour avoir les pourcentages exacts. À noter que ces statistiques sont produites avant le dédoublonnement qui a lieu avant la publication en Open Data et sur ce site.""")
|
||||
|
||||
# Assuming result_df is your DataFrame with structure:
|
||||
# | sourceDataset | unique | dataset1 | dataset2 | dataset3 |
|
||||
# |---------------|--------|----------|----------|----------|
|
||||
# | dataset1 | 0.8 | | 0.15 | 0.2 |
|
||||
# | dataset2 | 0.75 | 0.15 | | 0.12 |
|
||||
# | dataset3 | 0.85 | 0.2 | 0.12 | |
|
||||
|
||||
# Extract data
|
||||
z_data = result_df.select(pl.all().exclude("sourceDataset")).fill_null(0).to_numpy()
|
||||
x_labels = result_df.columns[1:] # columns after "sourceDataset"
|
||||
@@ -378,12 +378,6 @@ def get_duplicate_matrix() -> html.Div:
|
||||
z=z_data,
|
||||
x=x_labels,
|
||||
y=y_labels,
|
||||
# colorscale=[
|
||||
# [0, "white"], # 0% → white
|
||||
# [0.10, "lightblue"], # 1% → light blue (soft start)
|
||||
# [0.50, "steelblue"], # 50% → medium blue
|
||||
# [1, "darkblue"], # 100% → dark blue
|
||||
# ],
|
||||
colorscale=[
|
||||
[0.0, "white"], # 0% → white
|
||||
[0.10, "lightsalmon"], # 10% → light warm tone
|
||||
@@ -391,8 +385,6 @@ def get_duplicate_matrix() -> html.Div:
|
||||
],
|
||||
zmin=0,
|
||||
zmax=1,
|
||||
# texttemplate="%{z:.0%}", # Format as percentage
|
||||
# textfont={"size": 10, "color": "black"}, # Smaller font
|
||||
hoverongaps=False,
|
||||
showscale=True,
|
||||
hovertemplate=(
|
||||
@@ -422,3 +414,57 @@ def get_duplicate_matrix() -> html.Div:
|
||||
dcc.Graph(figure=fig),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def make_column_picker(page: str):
|
||||
table_data = []
|
||||
table_columns = [
|
||||
{
|
||||
"id": col,
|
||||
"name": data_schema[col]["title"],
|
||||
"description": data_schema[col]["description"],
|
||||
}
|
||||
for col in df.columns
|
||||
]
|
||||
for column in table_columns:
|
||||
new_column = {
|
||||
"id": column["id"],
|
||||
"name": column["name"],
|
||||
"description": data_schema[column["id"]]["description"],
|
||||
}
|
||||
table_data.append(new_column)
|
||||
|
||||
table = (
|
||||
DataTable(
|
||||
row_selectable="multi",
|
||||
data=table_data,
|
||||
filter_action="native",
|
||||
sort_action="none",
|
||||
style_cell={
|
||||
"textAlign": "left",
|
||||
},
|
||||
columns=[
|
||||
{
|
||||
"name": "Nom",
|
||||
"id": "name",
|
||||
},
|
||||
{
|
||||
"name": "Description",
|
||||
"id": "description",
|
||||
},
|
||||
],
|
||||
style_cell_conditional=[
|
||||
{
|
||||
"if": {"column_id": "description"},
|
||||
"minWidth": "450px",
|
||||
"overflow": "hidden",
|
||||
"lineHeight": "18px",
|
||||
"whiteSpace": "normal",
|
||||
}
|
||||
],
|
||||
page_action="none",
|
||||
dtid=f"{page}_column_list",
|
||||
),
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
+175
-25
@@ -1,11 +1,24 @@
|
||||
import datetime
|
||||
|
||||
import dash_bootstrap_components as dbc
|
||||
import polars as pl
|
||||
from dash import Input, Output, State, callback, dcc, html, register_page
|
||||
from dash import (
|
||||
ClientsideFunction,
|
||||
Input,
|
||||
Output,
|
||||
State,
|
||||
callback,
|
||||
clientside_callback,
|
||||
dcc,
|
||||
html,
|
||||
register_page,
|
||||
)
|
||||
|
||||
from src.callbacks import get_top_org_table
|
||||
from src.figures import DataTable, point_on_map
|
||||
from src.figures import DataTable, make_column_picker, point_on_map
|
||||
from src.utils import (
|
||||
add_canonical_link,
|
||||
columns,
|
||||
df,
|
||||
df_acheteurs,
|
||||
filter_table_data,
|
||||
@@ -43,16 +56,22 @@ datatable = html.Div(
|
||||
className="marches_table",
|
||||
children=DataTable(
|
||||
dtid="acheteur_datatable",
|
||||
persistence=True,
|
||||
persistence_type="local",
|
||||
persisted_props=["filter_query", "sort_by"],
|
||||
page_action="custom",
|
||||
filter_action="custom",
|
||||
sort_action="custom",
|
||||
page_size=10,
|
||||
hidden_columns=get_default_hidden_columns(page="acheteur"),
|
||||
hidden_columns=[],
|
||||
columns=[{"id": col, "name": col} for col in df.columns],
|
||||
),
|
||||
)
|
||||
|
||||
layout = [
|
||||
dcc.Store(id="acheteur_data", storage_type="memory"),
|
||||
dcc.Store(id="acheteur-hidden-columns", storage_type="local"),
|
||||
dcc.Store(id="filter-cleanup-trigger-acheteur"),
|
||||
dcc.Location(id="acheteur_url", refresh="callback-nav"),
|
||||
html.Div(
|
||||
children=[
|
||||
@@ -71,7 +90,7 @@ layout = [
|
||||
className="org_year",
|
||||
children=dcc.Dropdown(
|
||||
id="acheteur_year",
|
||||
options=["Toutes"]
|
||||
options=["Toutes les années"]
|
||||
+ [
|
||||
str(year)
|
||||
for year in range(
|
||||
@@ -96,7 +115,6 @@ layout = [
|
||||
html.A(
|
||||
id="acheteur_lien_annuaire",
|
||||
children="Plus de détails sur l'Annuaire des entreprises",
|
||||
target="_blank",
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -109,6 +127,7 @@ layout = [
|
||||
html.Button(
|
||||
"Téléchargement au format Excel",
|
||||
id="btn-download-data-acheteur",
|
||||
className="btn btn-primary",
|
||||
),
|
||||
dcc.Download(id="download-data-acheteur"),
|
||||
],
|
||||
@@ -132,16 +151,52 @@ layout = [
|
||||
children=[
|
||||
html.Div(
|
||||
[
|
||||
# Bouton modal des colonnes affichées
|
||||
dbc.Button(
|
||||
"Colonnes affichées",
|
||||
id="acheteur_columns_open",
|
||||
className="column_list",
|
||||
),
|
||||
html.P("lignes", id="acheteur_nb_rows"),
|
||||
html.Button(
|
||||
"Téléchargement désactivé au-delà de 65 000 lignes",
|
||||
id="btn-download-filtered-data-acheteur",
|
||||
className="btn btn-primary",
|
||||
disabled=True,
|
||||
),
|
||||
dcc.Download(id="acheteur-download-filtered-data"),
|
||||
dbc.Button(
|
||||
"Remise à zéro",
|
||||
title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
|
||||
id="btn-acheteur-reset",
|
||||
),
|
||||
],
|
||||
className="table-menu",
|
||||
),
|
||||
dbc.Modal(
|
||||
[
|
||||
dbc.ModalHeader(
|
||||
dbc.ModalTitle("Choix des colonnes à afficher")
|
||||
),
|
||||
dbc.ModalBody(
|
||||
id="acheteur_columns_body",
|
||||
children=make_column_picker("acheteur"),
|
||||
),
|
||||
dbc.ModalFooter(
|
||||
dbc.Button(
|
||||
"Fermer",
|
||||
id="acheteur_columns_close",
|
||||
className="ms-auto",
|
||||
n_clicks=0,
|
||||
)
|
||||
),
|
||||
],
|
||||
id="acheteur_columns",
|
||||
is_open=False,
|
||||
fullscreen="md-down",
|
||||
scrollable=True,
|
||||
size="xl",
|
||||
),
|
||||
datatable,
|
||||
],
|
||||
),
|
||||
@@ -162,26 +217,40 @@ layout = [
|
||||
)
|
||||
def update_acheteur_infos(url):
|
||||
acheteur_siret = url.split("/")[-1]
|
||||
if len(acheteur_siret) != 14:
|
||||
acheteur_siret = (
|
||||
f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})"
|
||||
)
|
||||
# if len(acheteur_siret) != 14:
|
||||
# acheteur_siret = (
|
||||
# f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})"
|
||||
# )
|
||||
data = get_annuaire_data(acheteur_siret)
|
||||
data_etablissement = data["matching_etablissements"][0]
|
||||
acheteur_map = point_on_map(
|
||||
data_etablissement["latitude"], data_etablissement["longitude"]
|
||||
)
|
||||
code_departement, nom_departement, nom_region = get_departement_region(
|
||||
data_etablissement["code_postal"]
|
||||
)
|
||||
departement = f"{nom_departement} ({code_departement})"
|
||||
lien_annuaire = (
|
||||
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
|
||||
)
|
||||
data_etablissement = data.get("matching_etablissements") if data else None
|
||||
if data_etablissement:
|
||||
data_etablissement = data_etablissement[0]
|
||||
|
||||
acheteur_map = point_on_map(
|
||||
data_etablissement["latitude"], data_etablissement["longitude"]
|
||||
)
|
||||
code_departement, nom_departement, nom_region = get_departement_region(
|
||||
data_etablissement["code_postal"]
|
||||
)
|
||||
departement = f"{nom_departement} ({code_departement})"
|
||||
lien_annuaire = (
|
||||
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
|
||||
)
|
||||
raison_sociale = data["nom_raison_sociale"]
|
||||
libelle_commune = data_etablissement["libelle_commune"]
|
||||
|
||||
else:
|
||||
acheteur_map = html.Div()
|
||||
code_departement, nom_departement, nom_region = "", "", ""
|
||||
departement = ""
|
||||
lien_annuaire = ""
|
||||
raison_sociale = ""
|
||||
libelle_commune = ""
|
||||
|
||||
return (
|
||||
acheteur_siret,
|
||||
data["nom_raison_sociale"],
|
||||
data_etablissement["libelle_commune"],
|
||||
raison_sociale,
|
||||
libelle_commune,
|
||||
acheteur_map,
|
||||
departement,
|
||||
nom_region,
|
||||
@@ -229,7 +298,7 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> tuple:
|
||||
acheteur_siret = url.split("/")[-1]
|
||||
lff = df.lazy()
|
||||
lff = lff.filter(pl.col("acheteur_id") == acheteur_siret)
|
||||
if acheteur_year and acheteur_year != "Toutes":
|
||||
if acheteur_year and acheteur_year != "Toutes les années":
|
||||
acheteur_year = int(acheteur_year)
|
||||
lff = lff.filter(pl.col("dateNotification").dt.year() == acheteur_year)
|
||||
lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True)
|
||||
@@ -249,6 +318,8 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> tuple:
|
||||
Output("btn-download-filtered-data-acheteur", "disabled"),
|
||||
Output("btn-download-filtered-data-acheteur", "children"),
|
||||
Output("btn-download-filtered-data-acheteur", "title"),
|
||||
Output("filter-cleanup-trigger-acheteur", "data"),
|
||||
Input("acheteur_url", "href"),
|
||||
Input("acheteur_data", "data"),
|
||||
Input("acheteur_datatable", "page_current"),
|
||||
Input("acheteur_datatable", "page_size"),
|
||||
@@ -257,7 +328,7 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> tuple:
|
||||
State("acheteur_datatable", "data_timestamp"),
|
||||
)
|
||||
def get_last_marches_data(
|
||||
data, page_current, page_size, filter_query, sort_by, data_timestamp
|
||||
href, data, page_current, page_size, filter_query, sort_by, data_timestamp
|
||||
) -> tuple:
|
||||
return prepare_table_data(
|
||||
data, data_timestamp, filter_query, page_current, page_size, sort_by, "acheteur"
|
||||
@@ -290,7 +361,7 @@ def download_acheteur_data(
|
||||
|
||||
def to_bytes(buffer):
|
||||
df_to_download.write_excel(
|
||||
buffer, worksheet="DECP" if annee in ["Toutes", None] else annee
|
||||
buffer, worksheet="DECP" if annee in ["Toutes les années", None] else annee
|
||||
)
|
||||
|
||||
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
||||
@@ -331,3 +402,82 @@ def download_filtered_acheteur_data(
|
||||
return dcc.send_bytes(
|
||||
to_bytes, filename=f"decp_filtrées_{acheteur_nom}_{date}.xlsx"
|
||||
)
|
||||
|
||||
|
||||
# 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-acheteur", "data", allow_duplicate=True),
|
||||
Input("filter-cleanup-trigger-acheteur", "data"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
|
||||
|
||||
@callback(
|
||||
Output("acheteur-hidden-columns", "data", allow_duplicate=True),
|
||||
Input("acheteur_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("acheteur_datatable", "hidden_columns", allow_duplicate=True),
|
||||
Input(
|
||||
"acheteur-hidden-columns",
|
||||
"data",
|
||||
),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def store_hidden_columns(hidden_columns):
|
||||
return hidden_columns
|
||||
|
||||
|
||||
@callback(
|
||||
Output("acheteur_column_list", "selected_rows"),
|
||||
Input("acheteur_datatable", "hidden_columns"),
|
||||
State("acheteur_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("acheteur")
|
||||
|
||||
# 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("acheteur_columns", "is_open"),
|
||||
Input("acheteur_columns_open", "n_clicks"),
|
||||
Input("acheteur_columns_close", "n_clicks"),
|
||||
State("acheteur_columns", "is_open"),
|
||||
)
|
||||
def toggle_acheteur_columns(click_open, click_close, is_open):
|
||||
if click_open or click_close:
|
||||
return not is_open
|
||||
return is_open
|
||||
|
||||
|
||||
@callback(
|
||||
Output("acheteur_datatable", "filter_query", allow_duplicate=True),
|
||||
Output("acheteur_datatable", "sort_by"),
|
||||
Input("btn-acheteur-reset", "n_clicks"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def reset_view(n_clicks):
|
||||
return "", []
|
||||
|
||||
|
||||
@callback(Input("acheteur_url", "pathname"))
|
||||
def cb_add_canonical_link(pathname):
|
||||
add_canonical_link(pathname)
|
||||
|
||||
@@ -46,20 +46,20 @@ layout = html.Div(
|
||||
"width": "500px",
|
||||
"border": "1px solid #ccc",
|
||||
"borderRight": "none",
|
||||
"borderRadius": "4px 0 0 4px",
|
||||
"borderRadius": "3px 0 0 3px",
|
||||
"padding": "5px 10px",
|
||||
"outline": "none",
|
||||
"height": "34px",
|
||||
},
|
||||
),
|
||||
html.Button(
|
||||
"🔍",
|
||||
"=>",
|
||||
id="search-button",
|
||||
className="btn btn-primary",
|
||||
style={
|
||||
"border": "1px solid #ccc",
|
||||
"borderRadius": "0 4px 4px 0",
|
||||
"borderRadius": "0 3px 3px 0",
|
||||
"marginLeft": "0",
|
||||
"backgroundColor": "#f0f0f0",
|
||||
"cursor": "pointer",
|
||||
"height": "auto", # Ensure it matches input height if necessary, often relying on padding/line-height
|
||||
},
|
||||
),
|
||||
|
||||
@@ -65,7 +65,7 @@ layout = [
|
||||
"""),
|
||||
html.H4("Statistiques par année"),
|
||||
get_yearly_statistics(statistics, today_str),
|
||||
dcc.Graph(figure=get_map_count_marches(df)),
|
||||
dcc.Graph(figure=get_map_count_marches()),
|
||||
get_duplicate_matrix(),
|
||||
html.H3("Nombre de marchés par source dans le temps"),
|
||||
dcc.Graph(
|
||||
|
||||
+267
-111
@@ -4,6 +4,7 @@ import urllib.parse
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import dash_bootstrap_components as dbc
|
||||
import polars as pl
|
||||
from dash import (
|
||||
ClientsideFunction,
|
||||
@@ -18,8 +19,11 @@ from dash import (
|
||||
register_page,
|
||||
)
|
||||
|
||||
from figures import make_column_picker
|
||||
from src.figures import DataTable
|
||||
from src.utils import (
|
||||
add_canonical_link,
|
||||
columns,
|
||||
df,
|
||||
filter_table_data,
|
||||
get_default_hidden_columns,
|
||||
@@ -50,19 +54,24 @@ register_page(
|
||||
datatable = html.Div(
|
||||
className="marches_table",
|
||||
children=DataTable(
|
||||
dtid="table",
|
||||
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=get_default_hidden_columns(None),
|
||||
hidden_columns=[],
|
||||
columns=[{"id": col, "name": col} for col in df.columns],
|
||||
),
|
||||
)
|
||||
|
||||
layout = [
|
||||
dcc.Location(id="tableau_url", refresh=False),
|
||||
dcc.Store(id="filter-cleanup-trigger"),
|
||||
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",
|
||||
@@ -121,85 +130,13 @@ layout = [
|
||||
],
|
||||
),
|
||||
dcc.Markdown(
|
||||
f"Ce tableau vous permet d'appliquer un filtre sur une ou plusieurs colonnes, et ainsi produire la liste de marchés dont vous avez besoin ([exemple de filtre](/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)). Par défaut seules quelques colonnes sont affichées, mais vous pouvez en afficher jusqu'à {str(df.width)} en cliquant sur le bouton **Colonnes affichées**. Cet outil est assez puissant, je vous recommande de lire le mode d'emploi pour en tirer pleinement partie.",
|
||||
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'à {str(df.width)} 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(
|
||||
html.Details(
|
||||
children=[
|
||||
html.Summary(
|
||||
html.H4("Mode d'emploi", style={"textDecoration": "underline"}),
|
||||
),
|
||||
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.
|
||||
|
||||
##### 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é et n'est pas sensible à la casse (majuscules/minuscules).
|
||||
- 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 (voir "Partager une vue" plus bas), le format équivalent `icontains rennes` est utilisé. Mais dans vos filtres pas besoin de taper `icontains` !
|
||||
- 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.
|
||||
- lorsque vous ouvrez une URL de vue (voir "Partager une vue" plus bas), le format équivalent `i<` ou `i>` est utilisé, mais c'est un bug : vous n'avez pas besoin de taper le `i` pour appliquer ce filtre.
|
||||
- Champs date (Date de notification, ...) : vous pouvez également utiliser **>** ou **<**. Exemples :
|
||||
- `< 2024-01-31` pour "avant le 31 janvier 2024"
|
||||
- `2024` pour "en 2024", `> 2022` pour "à partir de 2022".
|
||||
- Pour les champs textuels et les champs dates :
|
||||
- pour chercher du texte qui **commence par** votre texte, entrez `texte*`. C'est par exemple utile pour filtrer des acheteurs ou titulaires par numéro SIREN (`123456789*`) ou les marchés sur une année en particulier (`2024*`)
|
||||
- pour chercher du texte qui **finit par** votre texte, entrez `*texte`
|
||||
|
||||
Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page.
|
||||
|
||||
##### 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 {str(df.width)} colonnes, ce serait dommage de vous limiter !
|
||||
|
||||
Pour afficher plus de colonnes, cliquez sur le bouton **Colonnes affichées** 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é. Cliquez sur l'icône <img src="/assets/copy.svg" alt="drawing" width="20"/> 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.)
|
||||
|
||||
""",
|
||||
),
|
||||
],
|
||||
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",
|
||||
@@ -207,10 +144,100 @@ layout = [
|
||||
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é et n'est pas sensible à la casse (majuscules/minuscules).
|
||||
- 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`
|
||||
- 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, ...) : vous pouvez également utiliser **>** ou **<**. Exemples :
|
||||
- `< 2024-01-31` pour "avant le 31 janvier 2024"
|
||||
- `2024` pour "en 2024", `> 2022` pour "à partir de 2022".
|
||||
- Pour les champs textuels et les champs dates :
|
||||
- pour chercher du texte qui **commence par** votre texte, entrez `texte*`. C'est par exemple utile pour filtrer des acheteurs ou titulaires par numéro SIREN (`123456789*`) ou les marchés sur une année en particulier (`2024*`)
|
||||
- pour chercher du texte qui **finit par** votre texte, entrez `*texte`
|
||||
|
||||
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 {str(df.width)} 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"}),
|
||||
html.Button(
|
||||
dbc.Button(
|
||||
"Téléchargement désactivé au-delà de 65 000 lignes",
|
||||
id="btn-download-data",
|
||||
disabled=True,
|
||||
@@ -218,9 +245,36 @@ layout = [
|
||||
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,
|
||||
],
|
||||
),
|
||||
@@ -228,21 +282,24 @@ layout = [
|
||||
|
||||
|
||||
@callback(
|
||||
Output("table", "data"),
|
||||
Output("table", "columns"),
|
||||
Output("table", "tooltip_header"),
|
||||
Output("table", "data_timestamp"),
|
||||
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"),
|
||||
Input("table", "page_current"),
|
||||
Input("table", "page_size"),
|
||||
Input("table", "filter_query"),
|
||||
Input("table", "sort_by"),
|
||||
State("table", "data_timestamp"),
|
||||
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(page_current, page_size, filter_query, sort_by, data_timestamp):
|
||||
def update_table(href, page_current, page_size, filter_query, sort_by, data_timestamp):
|
||||
# if ctx.triggered_id != "url":
|
||||
# search_params = None
|
||||
# else:
|
||||
@@ -255,9 +312,9 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp)
|
||||
@callback(
|
||||
Output("download-data", "data"),
|
||||
Input("btn-download-data", "n_clicks"),
|
||||
State("table", "filter_query"),
|
||||
State("table", "sort_by"),
|
||||
State("table", "hidden_columns"),
|
||||
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):
|
||||
@@ -281,20 +338,21 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
|
||||
|
||||
|
||||
@callback(
|
||||
Output("table", "filter_query"),
|
||||
Output("table", "sort_by"),
|
||||
Output("table", "hidden_columns"),
|
||||
Output("tableau_url", "search", allow_duplicate=True),
|
||||
Output("filter-cleanup-trigger", "data"),
|
||||
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"),
|
||||
prevent_initial_call=True,
|
||||
State("tableau_datatable", "filter_query"),
|
||||
State("tableau_datatable", "sort_by"),
|
||||
)
|
||||
def restore_view_from_url(search):
|
||||
if not search:
|
||||
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("?"))
|
||||
logger.debug("params", params)
|
||||
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
|
||||
@@ -304,28 +362,37 @@ def restore_view_from_url(search):
|
||||
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:
|
||||
columns = params["colonnes"][0].split(",")
|
||||
verified_columns = [column for column in columns if column in schema.names()]
|
||||
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", "data", allow_duplicate=True),
|
||||
Input("filter-cleanup-trigger", "data"),
|
||||
Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True),
|
||||
Input("filter-cleanup-trigger-tableau", "data"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
|
||||
@@ -333,9 +400,9 @@ clientside_callback(
|
||||
@callback(
|
||||
Output("share-url", "value"),
|
||||
Output("copy-container", "children"),
|
||||
Input("table", "filter_query"),
|
||||
Input("table", "sort_by"),
|
||||
Input("table", "hidden_columns"),
|
||||
Input("tableau_datatable", "filter_query"),
|
||||
Input("tableau_datatable", "sort_by"),
|
||||
Input("tableau_datatable", "hidden_columns"),
|
||||
State("tableau_url", "href"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
@@ -354,9 +421,9 @@ def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href):
|
||||
params["tris"] = json.dumps(sort_by)
|
||||
|
||||
if hidden_columns:
|
||||
columns = invert_columns(hidden_columns)
|
||||
columns = ",".join(columns)
|
||||
params["colonnes"] = 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
|
||||
@@ -372,6 +439,13 @@ def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href):
|
||||
"cursor": "pointer",
|
||||
},
|
||||
className="fa fa-link",
|
||||
children=[
|
||||
dbc.Button(
|
||||
"Partager",
|
||||
className="btn btn-primary",
|
||||
title="Copier l'adresse de cette vue (filtres, tris, choix de colonnes) pour la partager.",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return full_url, copy_button
|
||||
@@ -389,3 +463,85 @@ def show_confirmation(n_clicks):
|
||||
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):
|
||||
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 "", []
|
||||
|
||||
|
||||
@callback(Input("tableau_url", "pathname"))
|
||||
def cb_add_canonical_link(pathname):
|
||||
add_canonical_link(pathname)
|
||||
|
||||
|
||||
# @callback(Input("tableau_url", "pathname"), Output("btn-copy-url", "children"))
|
||||
# def cb_add_canonical_link(pathname):
|
||||
# add_canonical_link(pathname)
|
||||
#
|
||||
|
||||
+174
-25
@@ -1,11 +1,24 @@
|
||||
import datetime
|
||||
|
||||
import dash_bootstrap_components as dbc
|
||||
import polars as pl
|
||||
from dash import Input, Output, State, callback, dcc, html, register_page
|
||||
from dash import (
|
||||
ClientsideFunction,
|
||||
Input,
|
||||
Output,
|
||||
State,
|
||||
callback,
|
||||
clientside_callback,
|
||||
dcc,
|
||||
html,
|
||||
register_page,
|
||||
)
|
||||
|
||||
from src.callbacks import get_top_org_table
|
||||
from src.figures import DataTable, point_on_map
|
||||
from src.figures import DataTable, make_column_picker, point_on_map
|
||||
from src.utils import (
|
||||
add_canonical_link,
|
||||
columns,
|
||||
df,
|
||||
df_titulaires,
|
||||
filter_table_data,
|
||||
@@ -43,16 +56,22 @@ datatable = html.Div(
|
||||
className="marches_table",
|
||||
children=DataTable(
|
||||
dtid="titulaire_datatable",
|
||||
persistence=True,
|
||||
persistence_type="local",
|
||||
persisted_props=["filter_query", "sort_by"],
|
||||
page_action="custom",
|
||||
filter_action="custom",
|
||||
sort_action="custom",
|
||||
page_size=10,
|
||||
hidden_columns=get_default_hidden_columns(page="titulaire"),
|
||||
hidden_columns=[],
|
||||
columns=[{"id": col, "name": col} for col in df.columns],
|
||||
),
|
||||
)
|
||||
|
||||
layout = [
|
||||
dcc.Store(id="titulaire_data", storage_type="memory"),
|
||||
dcc.Store(id="titulaire-hidden-columns", storage_type="local"),
|
||||
dcc.Store(id="filter-cleanup-trigger-titulaire"),
|
||||
dcc.Location(id="titulaire_url", refresh="callback-nav"),
|
||||
html.Div(
|
||||
children=[
|
||||
@@ -71,7 +90,7 @@ layout = [
|
||||
className="org_year",
|
||||
children=dcc.Dropdown(
|
||||
id="titulaire_year",
|
||||
options=["Toutes"]
|
||||
options=["Toutes les années"]
|
||||
+ [
|
||||
str(year)
|
||||
for year in range(
|
||||
@@ -96,7 +115,6 @@ layout = [
|
||||
html.A(
|
||||
id="titulaire_lien_annuaire",
|
||||
children="Plus de détails sur l'Annuaire des entreprises",
|
||||
target="_blank",
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -109,6 +127,7 @@ layout = [
|
||||
html.Button(
|
||||
"Téléchargement au format Excel",
|
||||
id="btn-download-data-titulaire",
|
||||
className="btn btn-primary",
|
||||
),
|
||||
dcc.Download(id="download-data-titulaire"),
|
||||
],
|
||||
@@ -132,16 +151,53 @@ layout = [
|
||||
children=[
|
||||
html.Div(
|
||||
[
|
||||
# Bouton modal des colonnes affichées
|
||||
dbc.Button(
|
||||
"Colonnes affichées",
|
||||
id="titulaire_columns_open",
|
||||
className="column_list",
|
||||
),
|
||||
html.P("lignes", id="titulaire_nb_rows"),
|
||||
html.Button(
|
||||
"Téléchargement désactivé au-delà de 65 000 lignes",
|
||||
id="btn-download-filtered-data-titulaire",
|
||||
disabled=True,
|
||||
className="btn btn-primary",
|
||||
),
|
||||
dcc.Download(id="titulaire-download-filtered-data"),
|
||||
dbc.Button(
|
||||
"Remise à zéro",
|
||||
title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
|
||||
id="btn-titulaire-reset",
|
||||
className="btn btn-primary",
|
||||
),
|
||||
],
|
||||
className="table-menu",
|
||||
),
|
||||
dbc.Modal(
|
||||
[
|
||||
dbc.ModalHeader(
|
||||
dbc.ModalTitle("Choix des colonnes à afficher")
|
||||
),
|
||||
dbc.ModalBody(
|
||||
id="titulaire_columns_body",
|
||||
children=make_column_picker("titulaire"),
|
||||
),
|
||||
dbc.ModalFooter(
|
||||
dbc.Button(
|
||||
"Fermer",
|
||||
id="titulaire_columns_close",
|
||||
className="ms-auto",
|
||||
n_clicks=0,
|
||||
)
|
||||
),
|
||||
],
|
||||
id="titulaire_columns",
|
||||
is_open=False,
|
||||
fullscreen="md-down",
|
||||
scrollable=True,
|
||||
size="xl",
|
||||
),
|
||||
datatable,
|
||||
],
|
||||
),
|
||||
@@ -162,26 +218,38 @@ layout = [
|
||||
)
|
||||
def update_titulaire_infos(url):
|
||||
titulaire_siret = url.split("/")[-1]
|
||||
if len(titulaire_siret) != 14:
|
||||
titulaire_siret = (
|
||||
f"Le SIRET renseigné doit faire 14 caractères ({titulaire_siret})"
|
||||
)
|
||||
data = get_annuaire_data(titulaire_siret)
|
||||
data_etablissement = data["matching_etablissements"][0]
|
||||
titulaire_map = point_on_map(
|
||||
data_etablissement["latitude"], data_etablissement["longitude"]
|
||||
)
|
||||
code_departement, nom_departement, nom_region = get_departement_region(
|
||||
data_etablissement["code_postal"]
|
||||
)
|
||||
departement = f"{nom_departement} ({code_departement})"
|
||||
lien_annuaire = (
|
||||
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
|
||||
)
|
||||
data_etablissement = data.get("matching_etablissements") if data else None
|
||||
if data_etablissement:
|
||||
data_etablissement = data_etablissement[0]
|
||||
|
||||
titulaire_map = point_on_map(
|
||||
data_etablissement["latitude"], data_etablissement["longitude"]
|
||||
)
|
||||
code_departement, nom_departement, nom_region = get_departement_region(
|
||||
data_etablissement["code_postal"]
|
||||
)
|
||||
departement = f"{nom_departement} ({code_departement})"
|
||||
lien_annuaire = (
|
||||
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
|
||||
)
|
||||
raison_sociale = data["nom_raison_sociale"]
|
||||
libelle_commune = data_etablissement["libelle_commune"]
|
||||
|
||||
else:
|
||||
titulaire_map = html.Div()
|
||||
code_departement, nom_departement, nom_region = "", "", ""
|
||||
departement = ""
|
||||
lien_annuaire = ""
|
||||
raison_sociale = html.Span(
|
||||
f"N° SIREN inconnu de l'INSEE ({titulaire_siret[:9]})"
|
||||
)
|
||||
libelle_commune = ""
|
||||
|
||||
return (
|
||||
titulaire_siret,
|
||||
data["nom_raison_sociale"],
|
||||
data_etablissement["libelle_commune"],
|
||||
raison_sociale,
|
||||
libelle_commune,
|
||||
titulaire_map,
|
||||
departement,
|
||||
nom_region,
|
||||
@@ -235,7 +303,7 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> tuple:
|
||||
(pl.col("titulaire_id") == titulaire_siret)
|
||||
& (pl.col("titulaire_typeIdentifiant") == "SIRET")
|
||||
)
|
||||
if titulaire_year and titulaire_year != "Toutes":
|
||||
if titulaire_year and titulaire_year != "Toutes les années":
|
||||
lff = lff.filter(
|
||||
pl.col("dateNotification").cast(pl.String).str.starts_with(titulaire_year)
|
||||
)
|
||||
@@ -258,6 +326,8 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> tuple:
|
||||
Output("btn-download-filtered-data-titulaire", "disabled"),
|
||||
Output("btn-download-filtered-data-titulaire", "children"),
|
||||
Output("btn-download-filtered-data-titulaire", "title"),
|
||||
Output("filter-cleanup-trigger-titulaire", "data"),
|
||||
Input(component_id="titulaire_url", component_property="href"),
|
||||
Input("titulaire_data", "data"),
|
||||
Input("titulaire_datatable", "page_current"),
|
||||
Input("titulaire_datatable", "page_size"),
|
||||
@@ -266,7 +336,7 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> tuple:
|
||||
State("titulaire_datatable", "data_timestamp"),
|
||||
)
|
||||
def get_last_marches_data(
|
||||
data, page_current, page_size, filter_query, sort_by, data_timestamp
|
||||
href, data, page_current, page_size, filter_query, sort_by, data_timestamp
|
||||
) -> list[dict]:
|
||||
return prepare_table_data(
|
||||
data,
|
||||
@@ -305,7 +375,7 @@ def download_titulaire_data(
|
||||
|
||||
def to_bytes(buffer):
|
||||
df_to_download.write_excel(
|
||||
buffer, worksheet="DECP" if annee in ["Toutes", None] else annee
|
||||
buffer, worksheet="DECP" if annee in ["Toutes les années", None] else annee
|
||||
)
|
||||
|
||||
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
||||
@@ -346,3 +416,82 @@ def download_filtered_titulaire_data(
|
||||
return dcc.send_bytes(
|
||||
to_bytes, filename=f"decp_filtrées_{titulaire_nom}_{date}.xlsx"
|
||||
)
|
||||
|
||||
|
||||
# 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-titulaire", "data", allow_duplicate=True),
|
||||
Input("filter-cleanup-trigger-titulaire", "data"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
|
||||
|
||||
@callback(
|
||||
Output("titulaire-hidden-columns", "data", allow_duplicate=True),
|
||||
Input("titulaire_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("titulaire_datatable", "hidden_columns", allow_duplicate=True),
|
||||
Input(
|
||||
"titulaire-hidden-columns",
|
||||
"data",
|
||||
),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def store_hidden_columns(hidden_columns):
|
||||
return hidden_columns
|
||||
|
||||
|
||||
@callback(
|
||||
Output("titulaire_column_list", "selected_rows"),
|
||||
Input("titulaire_datatable", "hidden_columns"),
|
||||
State("titulaire_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("titulaire")
|
||||
|
||||
# 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("titulaire_columns", "is_open"),
|
||||
Input("titulaire_columns_open", "n_clicks"),
|
||||
Input("titulaire_columns_close", "n_clicks"),
|
||||
State("titulaire_columns", "is_open"),
|
||||
)
|
||||
def toggle_titulaire_columns(click_open, click_close, is_open):
|
||||
if click_open or click_close:
|
||||
return not is_open
|
||||
return is_open
|
||||
|
||||
|
||||
@callback(
|
||||
Output("titulaire_datatable", "filter_query", allow_duplicate=True),
|
||||
Output("titulaire_datatable", "sort_by"),
|
||||
Input("btn-titulaire-reset", "n_clicks"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def reset_view(n_clicks):
|
||||
return "", []
|
||||
|
||||
|
||||
@callback(Input("titulaire_url", "pathname"))
|
||||
def cb_add_canonical_link(pathname):
|
||||
add_canonical_link(pathname)
|
||||
|
||||
+55
-32
@@ -2,11 +2,14 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from time import localtime, sleep
|
||||
|
||||
import dash
|
||||
import polars as pl
|
||||
import polars.selectors as cs
|
||||
from httpx import get, post
|
||||
from dash import no_update
|
||||
from httpx import HTTPError, get, post
|
||||
from polars.exceptions import ComputeError
|
||||
from unidecode import unidecode
|
||||
|
||||
@@ -32,14 +35,14 @@ def split_filter_part(filter_part):
|
||||
["icontains", "contains"],
|
||||
# [" ", "contains"]
|
||||
]
|
||||
logger.debug("filter part", filter_part)
|
||||
logger.debug("filter part " + filter_part)
|
||||
for operator_group in operators:
|
||||
if operator_group[0] in filter_part:
|
||||
name_part, value_part = filter_part.split(operator_group[0], 1)
|
||||
name_part = name_part.strip()
|
||||
value = value_part.strip()
|
||||
name = name_part[name_part.find("{") + 1 : name_part.rfind("}")]
|
||||
logger.debug("=>", name, operator_group[1], value)
|
||||
logger.debug("=> " + " ".join([name, operator_group[1], value]))
|
||||
|
||||
return name, operator_group[1], value
|
||||
|
||||
@@ -56,7 +59,7 @@ def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
|
||||
return dff
|
||||
|
||||
|
||||
def add_links(dff: pl.DataFrame, target: str = "_blank"):
|
||||
def add_links(dff: pl.DataFrame):
|
||||
for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
|
||||
if col in dff.columns:
|
||||
if col.startswith("titulaire_"):
|
||||
@@ -70,7 +73,7 @@ def add_links(dff: pl.DataFrame, target: str = "_blank"):
|
||||
.then(
|
||||
'<a href = "/titulaires/'
|
||||
+ pl.col("titulaire_id")
|
||||
+ f'" target="{target}">'
|
||||
+ '">'
|
||||
+ pl.col(col)
|
||||
+ "</a>"
|
||||
)
|
||||
@@ -82,7 +85,7 @@ def add_links(dff: pl.DataFrame, target: str = "_blank"):
|
||||
(
|
||||
'<a href = "/acheteurs/'
|
||||
+ pl.col("acheteur_id")
|
||||
+ f'" target="{target}">'
|
||||
+ '">'
|
||||
+ pl.col(col)
|
||||
+ "</a>"
|
||||
).alias(col)
|
||||
@@ -92,7 +95,7 @@ def add_links(dff: pl.DataFrame, target: str = "_blank"):
|
||||
(
|
||||
'<a href = "/marches/'
|
||||
+ pl.col("uid")
|
||||
+ f'" target="{target}">'
|
||||
+ '">'
|
||||
+ pl.col("uid")
|
||||
+ "</a>"
|
||||
).alias("uid")
|
||||
@@ -205,8 +208,13 @@ def format_values(dff: pl.DataFrame) -> pl.DataFrame:
|
||||
|
||||
def get_annuaire_data(siret: str) -> dict:
|
||||
url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}"
|
||||
response = get(url)
|
||||
return response.json()["results"][0]
|
||||
try:
|
||||
response = get(url).raise_for_status()
|
||||
response = response.json()["results"][0]
|
||||
except (HTTPError, IndexError):
|
||||
response = None
|
||||
logger.warning("Could not fetch data from recherche-entreprises.api.")
|
||||
return response
|
||||
|
||||
|
||||
def get_decp_data() -> pl.DataFrame:
|
||||
@@ -234,6 +242,15 @@ def get_decp_data() -> pl.DataFrame:
|
||||
# Convertir les colonnes booléennes en chaînes de caractères
|
||||
lff = booleans_to_strings(lff)
|
||||
|
||||
# Mention pour les org dont on a pas le nom
|
||||
for col in ["acheteur_nom", "titulaire_nom"]:
|
||||
lff = lff.with_columns(
|
||||
pl.when(pl.col(col).is_null())
|
||||
.then(pl.lit("[Identifiant non reconnu dans la base INSEE]"))
|
||||
.otherwise(pl.col(col))
|
||||
.name.keep()
|
||||
)
|
||||
|
||||
# Bizarrement je ne peux pas faire lff = lff.fill_null("") ici
|
||||
# ça génère une erreur dans la page acheteur (acheteur_data.table) :
|
||||
# AttributeError: partially initialized module 'pandas' has no attribute 'NaT' (most likely due to a circular import)
|
||||
@@ -284,7 +301,7 @@ def filter_table_data(
|
||||
lff: pl.LazyFrame, filter_query: str, filter_source: str
|
||||
) -> pl.LazyFrame:
|
||||
_schema = lff.collect_schema()
|
||||
track_search(f"{filter_source}: {filter_query}")
|
||||
track_search(filter_query, filter_source)
|
||||
filtering_expressions = filter_query.split(" && ")
|
||||
for filter_part in filtering_expressions:
|
||||
col_name, operator, filter_value = split_filter_part(filter_part)
|
||||
@@ -426,12 +443,11 @@ def get_default_hidden_columns(page):
|
||||
"codeCPV",
|
||||
"dureeRestanteMois",
|
||||
]
|
||||
elif page == "titulaire":
|
||||
displayed_columns = os.getenv("DISPLAYED_COLUMNS")
|
||||
else:
|
||||
displayed_columns = os.getenv("DISPLAYED_COLUMNS")
|
||||
if displayed_columns is None:
|
||||
raise ValueError("DISPLAYED_COLUMNS n'est pas configuré")
|
||||
else:
|
||||
displayed_columns = displayed_columns.replace(" ", "").split(",")
|
||||
logger.warning(f"Invalid page: {page}")
|
||||
|
||||
hidden_columns = []
|
||||
|
||||
@@ -456,7 +472,7 @@ def get_data_schema() -> dict:
|
||||
else:
|
||||
raise Exception(f"Chemin vers le schéma invalide: {path}")
|
||||
|
||||
new_schema = {}
|
||||
new_schema = OrderedDict()
|
||||
|
||||
for col in original_schema["fields"]:
|
||||
new_schema[col["name"]] = col
|
||||
@@ -464,21 +480,15 @@ def get_data_schema() -> dict:
|
||||
return new_schema
|
||||
|
||||
|
||||
def track_search(query):
|
||||
if (
|
||||
len(query) >= 4
|
||||
and os.getenv("DEVELOPMENT").lower != "true"
|
||||
and os.getenv("MATOMO_DOMAIN")
|
||||
):
|
||||
if os.getenv("DEVELOPMENT").lower() == "true":
|
||||
url = "https://test.decp.info"
|
||||
else:
|
||||
url = "https://decp.info"
|
||||
def track_search(query, category):
|
||||
if len(query) >= 4 and not development and os.getenv("MATOMO_DOMAIN"):
|
||||
url = "https://decp.info"
|
||||
params = {
|
||||
"idsite": os.getenv("MATOMO_ID_SITE"),
|
||||
"url": url,
|
||||
"rec": "1",
|
||||
"action_name": "front_page_search",
|
||||
"action_name": "search" if category == "home_page_search" else "filter",
|
||||
"search_cat": category,
|
||||
"rand": uuid.uuid4().hex,
|
||||
"apiv": "1",
|
||||
"h": localtime().tm_hour,
|
||||
@@ -506,7 +516,7 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
|
||||
return dff.select(pl.lit(False).alias("matches"))
|
||||
|
||||
# Enregistrement des recherche dans Matomo
|
||||
track_search(query)
|
||||
track_search(query, "home_page_search")
|
||||
|
||||
# Normalize query
|
||||
normalized_query = unidecode(query.strip()).upper()
|
||||
@@ -555,7 +565,7 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
|
||||
)
|
||||
|
||||
# Format result
|
||||
dff = add_links(dff, target="")
|
||||
dff = add_links(dff)
|
||||
dff = dff.with_columns(
|
||||
pl.concat_str(
|
||||
pl.col(f"{org_type}_departement_nom"),
|
||||
@@ -591,6 +601,8 @@ def prepare_table_data(
|
||||
if os.getenv("DEVELOPMENT").lower() == "true":
|
||||
logger.debug(" + + + + + + + + + + + + + + + + + + ")
|
||||
|
||||
trigger_cleanup = no_update
|
||||
|
||||
# Récupération des données
|
||||
if isinstance(data, list):
|
||||
lff: pl.LazyFrame = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
|
||||
@@ -600,9 +612,10 @@ def prepare_table_data(
|
||||
# Application des filtres
|
||||
if filter_query:
|
||||
lff = filter_table_data(lff, filter_query, source_table)
|
||||
trigger_cleanup = no_update if source_table == "tableau" else str(uuid.uuid4())
|
||||
|
||||
# Application des tris
|
||||
if len(sort_by) > 0:
|
||||
if sort_by and len(sort_by) > 0:
|
||||
lff = sort_table_data(lff, sort_by)
|
||||
|
||||
# Matérialisation des filtres
|
||||
@@ -637,7 +650,7 @@ def prepare_table_data(
|
||||
dff = format_values(dff)
|
||||
|
||||
# Récupération des colonnes et tooltip
|
||||
columns, tooltip = setup_table_columns(dff)
|
||||
table_columns, tooltip = setup_table_columns(dff)
|
||||
|
||||
dicts = dff.to_dicts()
|
||||
|
||||
@@ -646,13 +659,14 @@ def prepare_table_data(
|
||||
|
||||
return (
|
||||
dicts,
|
||||
columns,
|
||||
table_columns,
|
||||
tooltip,
|
||||
data_timestamp + 1,
|
||||
nb_rows,
|
||||
download_disabled,
|
||||
download_text,
|
||||
download_title,
|
||||
trigger_cleanup,
|
||||
)
|
||||
|
||||
|
||||
@@ -668,7 +682,7 @@ def get_button_properties(height):
|
||||
else:
|
||||
download_disabled = False
|
||||
download_text = "Télécharger au format Excel"
|
||||
download_title = ""
|
||||
download_title = "Télécharger les données telles qu'affichées au format Excel"
|
||||
return download_disabled, download_text, download_title
|
||||
|
||||
|
||||
@@ -726,6 +740,14 @@ def make_org_jsonld(org_id, org_type, org_name=None, type_org_id="SIRET") -> dic
|
||||
return jsonld
|
||||
|
||||
|
||||
def add_canonical_link(pathname):
|
||||
@dash.hooks.index()
|
||||
def update_index(html_string):
|
||||
url = f"https://{domain_name}{pathname}"
|
||||
canonical_tag = f'<link rel="canonical" href="{url}" />'
|
||||
return html_string.replace("</head>", f"{canonical_tag}\n </head>")
|
||||
|
||||
|
||||
df: pl.DataFrame = get_decp_data()
|
||||
schema = df.collect_schema()
|
||||
|
||||
@@ -763,3 +785,4 @@ meta_content = {
|
||||
),
|
||||
}
|
||||
data_schema = get_data_schema()
|
||||
columns = df.columns
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import datetime
|
||||
import os
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def test_data():
|
||||
data = [
|
||||
{
|
||||
"uid": "1",
|
||||
"id": "1",
|
||||
"acheteur_nom": "ACHETEUR 1",
|
||||
"acheteur_id": "a1",
|
||||
"titulaire_nom": "TITULAIRE 1",
|
||||
"titulaire_id": "t1",
|
||||
"montant": 10,
|
||||
"dateNotification": datetime.date(2025, 1, 1),
|
||||
"codeCPV": "71600000",
|
||||
"donneesActuelles": True,
|
||||
"acheteur_departement_code": "75",
|
||||
"acheteur_departement_nom": "Paris",
|
||||
"acheteur_commune_nom": "Paris",
|
||||
"titulaire_departement_code": "35",
|
||||
"titulaire_departement_nom": "Ille-et-Vilaine",
|
||||
"titulaire_commune_nom": "Rennes",
|
||||
"titulaire_distance": 10,
|
||||
"titulaire_typeIdentifiant": "SIRET",
|
||||
"objet": "Objet test",
|
||||
"dureeRestanteMois": 12,
|
||||
"lieuExecution_code": "75001",
|
||||
"sourceFile": "test.xml",
|
||||
"sourceDataset": "test_dataset",
|
||||
"datePublicationDonnees": datetime.date(2025, 1, 1),
|
||||
}
|
||||
]
|
||||
path = "tests/test.parquet"
|
||||
path = os.path.abspath(path)
|
||||
print(f"Writing test data to: {path}") # <-- This will show you the real path
|
||||
|
||||
pl.DataFrame(data).write_parquet("tests/test.parquet")
|
||||
yield path
|
||||
|
||||
|
||||
def pytest_setup_options():
|
||||
options = Options()
|
||||
options.add_argument("--window-size=1200,800")
|
||||
return options
|
||||
@@ -0,0 +1,60 @@
|
||||
from dash.testing.composite import DashComposite
|
||||
from selenium.webdriver import Keys
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
|
||||
|
||||
def test_001_logo_and_search(dash_duo: DashComposite):
|
||||
from src.app import app
|
||||
|
||||
dash_duo.start_server(app)
|
||||
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||
assert dash_duo.find_element(".logo > h1").text == "decp.info"
|
||||
|
||||
for org_type in ["acheteur", "titulaire"]:
|
||||
name = f"{org_type.upper()} 1"
|
||||
search_bar: WebElement = dash_duo.find_element("#search")
|
||||
|
||||
dash_duo.clear_input(search_bar)
|
||||
|
||||
search_bar.send_keys(name)
|
||||
search_bar.send_keys(Keys.ENTER)
|
||||
|
||||
dash_duo.wait_for_element(f"#results_{org_type}_datatable", timeout=2)
|
||||
result_table: WebElement = dash_duo.find_element(
|
||||
f"#results_{org_type}_datatable tbody"
|
||||
)
|
||||
|
||||
assert len(result_table.find_elements(by=By.TAG_NAME, value="tr")) == 2, (
|
||||
"The search should return only one result"
|
||||
) # header row + 1 result
|
||||
assert (
|
||||
result_table.find_element(
|
||||
by=By.CSS_SELECTOR, value=f'td[data-dash-column="{org_type}_nom"]'
|
||||
).text
|
||||
== name
|
||||
), f"The search result should have the right {org_type} name"
|
||||
|
||||
|
||||
def test_002_filter_persistence(dash_duo: DashComposite):
|
||||
from src.app import app
|
||||
|
||||
dash_duo.start_server(app)
|
||||
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||
|
||||
def open_page_and_check_filter_input():
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}")
|
||||
filter_input_selector = (
|
||||
'.marches_table th[data-dash-column="uid"] input[type="text"]'
|
||||
)
|
||||
dash_duo.wait_for_element(filter_input_selector, timeout=2)
|
||||
_filter_input: WebElement = dash_duo.find_element(filter_input_selector)
|
||||
return _filter_input
|
||||
|
||||
for page in ["tableau", "acheteurs/a1", "titulaires/t1"]:
|
||||
print("page:", page)
|
||||
filter_input = open_page_and_check_filter_input()
|
||||
filter_input.send_keys("11") # a UID that doesn't exist
|
||||
filter_input.send_keys(Keys.ENTER)
|
||||
filter_input = open_page_and_check_filter_input()
|
||||
assert filter_input.get_attribute("value") == "11"
|
||||
Reference in New Issue
Block a user