Merge branch 'feat/duckdb-migration' into dev

This commit is contained in:
Colin Maudry
2026-04-18 16:57:45 +02:00
16 changed files with 3096 additions and 187 deletions
+5 -7
View File
@@ -15,6 +15,7 @@ from dash import (
register_page,
)
from src.db import query_marches, schema
from src.figures import (
DataTable,
get_distance_histogram,
@@ -25,7 +26,6 @@ from src.figures import (
)
from src.utils import (
columns,
df,
df_acheteurs,
filter_table_data,
format_number,
@@ -70,7 +70,7 @@ datatable = html.Div(
sort_action="custom",
page_size=10,
hidden_columns=[],
columns=[{"id": col, "name": col} for col in df.columns],
columns=[{"id": col, "name": col} for col in schema.names()],
),
)
@@ -300,7 +300,7 @@ def update_acheteur_infos(url):
def update_acheteur_stats(data):
dff = pl.DataFrame(data, strict=False, infer_schema_length=5000)
if dff.height == 0:
dff = pl.DataFrame(schema=df.collect_schema())
dff = pl.DataFrame(schema=schema)
df_marches = dff.unique("id")
nb_marches = format_number(df_marches.height)
# somme_marches = format_number(int(df_marches.select(pl.sum("montant")).item()))
@@ -328,15 +328,13 @@ def update_acheteur_stats(data):
)
def get_acheteur_marches_data(url, ach_year: str) -> tuple:
acheteur_siret = url.split("/")[-1]
lff = df.lazy()
lff = lff.filter(pl.col("acheteur_id") == acheteur_siret)
lff = query_marches("acheteur_id = ?", (acheteur_siret,)).lazy()
if ach_year and ach_year != "Toutes les années":
ach_year: int = int(ach_year)
ach_year = int(ach_year)
lff = lff.filter(pl.col("dateNotification").dt.year() == ach_year)
lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True)
dff: pl.DataFrame = lff.collect(engine="streaming")
download_disabled, download_text, download_title = get_button_properties(dff.height)
data = dff.to_dicts()
return data, download_disabled, download_text, download_title
+29 -16
View File
@@ -1,7 +1,7 @@
import polars as pl
from dash import Input, Output, callback, dcc, html, register_page
from src.utils import departements, df_acheteurs_departement, df_titulaires_departement
from src.db import get_cursor
from src.utils import departements
name = "Département"
@@ -39,29 +39,42 @@ def departement_marches(url):
departement = url.split("/")[-1]
def make_link_list(org_type) -> list:
link_list = []
if org_type == "acheteur":
df = df_acheteurs_departement
elif org_type == "titulaire":
df = df_titulaires_departement
else:
table = (
"acheteurs_departement"
if org_type == "acheteur"
else "titulaires_departement"
if org_type == "titulaire"
else None
)
if table is None:
raise ValueError
col_prefix = org_type
rows = (
get_cursor()
.execute(
f"SELECT {col_prefix}_id, {col_prefix}_nom "
f"FROM {table} "
f"WHERE {col_prefix}_departement_code = ? "
f"ORDER BY {col_prefix}_nom",
[departement],
)
.fetchall()
)
df = df.filter(pl.col(f"{org_type}_departement_code") == departement)
for row in df.iter_rows(named=True):
link_list = []
for org_id, org_nom in rows:
li = html.Li(
[
dcc.Link(
row[f"{org_type}_nom"],
href=url + f"/{org_type}/{row[f'{org_type}_id']}",
title=f"Marchés publics de {row[f'{org_type}_nom']}",
org_nom,
href=url + f"/{org_type}/{org_id}",
title=f"Marchés publics de {org_nom}",
),
" ",
dcc.Link(
"(page dédiée)",
href=f"/{org_type}s/{row[f'{org_type}_id']}",
title=f"Page dédiée aux marchés publics de {row[f'{org_type}_nom']}",
href=f"/{org_type}s/{org_id}",
title=f"Page dédiée aux marchés publics de {org_nom}",
),
]
)
+28 -26
View File
@@ -1,12 +1,8 @@
import polars as pl
from dash import Input, Output, callback, dcc, html, register_page
from src.utils import (
df_acheteurs,
df_acheteurs_marches,
df_titulaires,
df_titulaires_marches,
)
from src.db import get_cursor
from src.utils import df_acheteurs, df_titulaires
name = "Liste des marchés publics"
@@ -68,28 +64,34 @@ def liste_marches(url):
org_id = url.split("/")[-1]
def make_link_list() -> list:
link_list = []
if org_type == "acheteur":
df = df_acheteurs_marches
elif org_type == "titulaire":
df = df_titulaires_marches
else:
table = (
"acheteurs_marches"
if org_type == "acheteur"
else "titulaires_marches"
if org_type == "titulaire"
else None
)
if table is None:
raise ValueError
df = df.filter(pl.col(f"{org_type}_id") == org_id)
for row in df.iter_rows(named=True):
li = html.Li(
[
dcc.Link(
row["objet"],
href=f"/marches/{row['uid']}",
title=f"Marchés public attribué : {row['objet']}",
)
]
rows = (
get_cursor()
.execute(
f"SELECT uid, objet FROM {table} WHERE {org_type}_id = ?",
[org_id],
)
link_list.append(li)
return link_list
.fetchall()
)
return [
html.Li(
dcc.Link(
objet,
href=f"/marches/{uid}",
title=f"Marchés public attribué : {objet}",
)
)
for uid, objet in rows
]
nom, verbe = make_org_nom_verbe(org_type, org_id)
+9 -12
View File
@@ -2,13 +2,12 @@ import json
from datetime import datetime
import dash_bootstrap_components as dbc
import polars as pl
from dash import Input, Output, callback, dcc, html, register_page
from polars import selectors as cs
from src.db import query_marches
from src.utils import (
data_schema,
df,
format_values,
make_org_jsonld,
meta_content,
@@ -88,19 +87,17 @@ layout = [
def get_marche_data(url) -> tuple[dict, list]:
marche_uid = url.split("/")[-1]
# Récupération des données du marché à partir du df global
# Filtre SQL côté DuckDB, puis Polars pour le post-traitement
dff_marche = query_marches("uid = ?", (marche_uid,))
if dff_marche.height == 0:
return {}, []
lff = df.lazy()
lff = lff.filter(pl.col("uid") == pl.lit(marche_uid))
# Données des titulaires du marché
lff = dff_marche.lazy()
dff_titulaires = lff.select(cs.starts_with("titulaire")).collect(engine="streaming")
dff_marche_unique = lff.unique("uid").collect(engine="streaming")
dff_marche_unique = format_values(dff_marche_unique)
# Données du marché
dff_marche = lff.unique("uid").collect(engine="streaming")
dff_marche = format_values(dff_marche)
return dff_marche.to_dicts()[0], dff_titulaires.to_dicts()
return dff_marche_unique.to_dicts()[0], dff_titulaires.to_dicts()
@callback(
+5 -5
View File
@@ -17,6 +17,7 @@ from dash import (
)
from src.cache import cache
from src.db import query_marches, schema
from src.figures import (
DataTable,
get_barchart_sources,
@@ -32,7 +33,6 @@ from src.figures import (
from src.utils import (
columns,
departements,
df,
df_acheteurs,
df_titulaires,
get_default_hidden_columns,
@@ -72,7 +72,7 @@ for code in departements.keys():
OBSERVATOIRE_COLUMNS = [
col
for col in df.columns
for col in schema.names()
if col.startswith("acheteur")
or col.startswith("titulaire")
or col
@@ -663,7 +663,7 @@ def _normalize_filter_params(filter_params: dict) -> tuple:
def _compute_dashboard_children(cache_key: tuple):
filter_params = {k: (list(v) if isinstance(v, tuple) else v) for k, v in cache_key}
lff: pl.LazyFrame = df.lazy()
lff: pl.LazyFrame = query_marches().lazy()
lff = prepare_dashboard_data(lff=lff, **filter_params)
dff = lff.collect(engine="streaming")
@@ -788,7 +788,7 @@ def update_dashboard_cards(*filter_values):
prevent_initial_call=True,
)
def download_observatoire(_n_clicks, filter_params, hidden_columns):
lff = prepare_dashboard_data(lff=df.lazy(), **(filter_params or {}))
lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {}))
if hidden_columns:
lff = lff.drop(hidden_columns)
@@ -879,7 +879,7 @@ def populate_preview_table(
if not is_open:
return (no_update,) * 9
lff = prepare_dashboard_data(lff=df.lazy(), **(filter_params or {}))
lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {}))
return prepare_table_data(
lff,
+5 -6
View File
@@ -19,17 +19,16 @@ from dash import (
register_page,
)
from src.db import query_marches, schema
from src.figures import DataTable, make_column_picker
from src.utils import (
columns,
df,
filter_table_data,
get_default_hidden_columns,
invert_columns,
logger,
meta_content,
prepare_table_data,
schema,
sort_table_data,
)
@@ -61,7 +60,7 @@ datatable = html.Div(
filter_action="custom",
sort_action="custom",
hidden_columns=[],
columns=[{"id": col, "name": col} for col in df.columns],
columns=[{"id": col, "name": col} for col in schema.names()],
),
)
@@ -128,7 +127,7 @@ layout = [
],
),
dcc.Markdown(
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.",
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'à {len(schema.names())} 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(
@@ -188,7 +187,7 @@ layout = [
##### 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 !
Par défaut, un nombre réduit de colonnes est affiché pour ne pas surcharger la page. Mais vous avez le choix parmi {len(schema.names())} 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.
@@ -316,7 +315,7 @@ def update_table(href, page_current, page_size, filter_query, sort_by, data_time
prevent_initial_call=True,
)
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
lff: pl.LazyFrame = df.lazy() # start from the original data
lff: pl.LazyFrame = query_marches().lazy()
# Les colonnes masquées sont supprimées
if hidden_columns:
+6 -9
View File
@@ -15,6 +15,7 @@ from dash import (
register_page,
)
from src.db import query_marches, schema
from src.figures import (
DataTable,
get_distance_histogram,
@@ -24,7 +25,6 @@ from src.figures import (
)
from src.utils import (
columns,
df,
df_titulaires,
filter_table_data,
format_number,
@@ -69,7 +69,7 @@ datatable = html.Div(
sort_action="custom",
page_size=10,
hidden_columns=[],
columns=[{"id": col, "name": col} for col in df.columns],
columns=[{"id": col, "name": col} for col in schema.names()],
),
)
@@ -339,21 +339,18 @@ def update_titulaire_stats(data):
)
def get_titulaire_marches_data(url, titulaire_year: str) -> tuple:
titulaire_siret = url.split("/")[-1]
lff = df.lazy()
lff = lff.filter(
(pl.col("titulaire_id") == titulaire_siret)
& (pl.col("titulaire_typeIdentifiant") == "SIRET")
)
lff = query_marches(
"titulaire_id = ? AND titulaire_typeIdentifiant = 'SIRET'",
(titulaire_siret,),
).lazy()
if titulaire_year and titulaire_year != "Toutes les années":
lff = lff.filter(
pl.col("dateNotification").cast(pl.String).str.starts_with(titulaire_year)
)
lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True)
lff = lff.fill_null("")
dff: pl.DataFrame = lff.collect(engine="streaming")
download_disabled, download_text, download_title = get_button_properties(dff.height)
data = dff.to_dicts()
return data, download_disabled, download_text, download_title