Merge branch 'feature/58_search' into dev

This commit is contained in:
Colin Maudry
2025-11-12 14:56:37 +01:00
6 changed files with 270 additions and 31 deletions
+2 -1
View File
@@ -40,8 +40,9 @@ Ne pas oublier de mettre à jour les fichier .env.
#### 2.2.0 () #### 2.2.0 ()
- Moteur de recherche (acheteurs et titulaires) en page d'accueil ([#58](https://github.com/ColinMaudry/decp.info/issues/58))
- Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/decp.info/issues/55))) - Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/decp.info/issues/55)))
- Moins de colonnes affichées par défaut ([#54](https://github.com/ColinMaudry/decp.info/issues/54)) - Moins de colonnes affichées par défaut dans Tableau ([#54](https://github.com/ColinMaudry/decp.info/issues/54))
##### 2.1.7 (11 novembre 2025) ##### 2.1.7 (11 novembre 2025)
+2 -1
View File
@@ -16,7 +16,8 @@ dependencies = [
"xlsxwriter", "xlsxwriter",
"plotly[express]", "plotly[express]",
"httpx", "httpx",
"pandas" # utilisé pour la création de certains graphiques "pandas", # utilisé pour la création de certains graphiques
"unidecode"
] ]
[project.optional-dependencies] [project.optional-dependencies]
+30 -4
View File
@@ -148,6 +148,35 @@ td[data-dash-column="objet"],td[data-dash-column="titulaire_nom"],td[data-dash-c
margin-top: 25px; margin-top: 25px;
} }
/* Page de recherche */
#search {
margin: 50px auto 0px auto;
width: 450px;
font-size: 18px;
height: 30px;
display: block;
}
.search_options {
margin: 16px auto;
width: 450px;
}
.search_options input {
margin-right: 12px;
}
.results_acheteur {
grid-column: 1;
grid-row: 1;
}
.results_titulaire {
grid-column: 2;
grid-row: 1;
}
/* Menu de navigation */ /* Menu de navigation */
.navbar { .navbar {
@@ -177,7 +206,7 @@ summary > h3 {
padding-top: 28px; padding-top: 28px;
} }
/* Vue acheteur/titulaire */ /* Vue acheteur/titulaire/recherche */
.wrapper { .wrapper {
display: grid; display: grid;
grid-gap: 10px; grid-gap: 10px;
@@ -185,9 +214,6 @@ summary > h3 {
justify-content: space-between; justify-content: space-between;
} }
.wrapper > div {
}
.org_title { .org_title {
grid-column: 1 / 3; grid-column: 1 / 3;
grid-row: 1; grid-row: 1;
+104
View File
@@ -0,0 +1,104 @@
from dash import Input, Output, callback, dash_table, dcc, html, register_page
from src.utils import (
df_acheteurs,
df_titulaires,
meta_content,
search_org,
setup_table_columns,
)
name = "Recherche"
register_page(
__name__,
path="/",
title=meta_content["title"],
name=name,
description=meta_content["description"],
image_url=meta_content["image_url"],
order=0,
)
layout = html.Div(
className="container",
children=[
dcc.Input(
id="search",
type="text",
placeholder="Nom d'acheteur, d'entreprise, SIREN...",
),
# html.Div(
# className="search_options",
# children=[dcc.RadioItems(options=["Acheteur(s)"])],
# ),
html.Div(id="search_results", className="wrapper"),
],
)
@callback(
Output("search_results", "children"),
Input("search", "value"),
prevent_initial_call=True,
)
def update_search_results(query):
if len(query) >= 1:
content = []
for org_type in ["acheteur", "titulaire"]:
if org_type == "acheteur":
dff = df_acheteurs
elif org_type == "titulaire":
dff = df_titulaires
else:
raise ValueError(f"{org_type} is not supported")
# Search acheteurs and titulaires using the same function
results = search_org(dff, query, org_type=org_type)
count = results.height
# Format output
columns, tooltip = setup_table_columns(results, hideable=False)
org_content = [
html.Div(
className=f"results_{org_type}",
children=[
html.H3(f"{org_type.title()}s : {count}"),
dash_table.DataTable(
columns=columns,
data=results.to_dicts(),
page_size=10,
# style_table={"overflowX": "auto"},
markdown_options={"html": True},
cell_selectable=False,
style_cell_conditional=[
{
"if": {"column_id": "acheteur_nom"},
"maxWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"maxWidth": "250px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
],
),
],
)
if count > 0
else html.P(f"Aucun {org_type} trouvé."),
]
content.extend(org_content)
return content
else:
return html.P("")
+1 -1
View File
@@ -25,7 +25,7 @@ schema = df.collect_schema()
name = "Tableau" name = "Tableau"
register_page( register_page(
__name__, __name__,
path="/", path="/tableau",
title=meta_content["title"], title=meta_content["title"],
name=name, name=name,
description=meta_content["description"], description=meta_content["description"],
+131 -24
View File
@@ -8,6 +8,7 @@ import polars.selectors as cs
from httpx import get from httpx import get
from polars import Schema from polars import Schema
from polars.exceptions import ComputeError from polars.exceptions import ComputeError
from unidecode import unidecode
operators = [ operators = [
["s<", "<"], ["s<", "<"],
@@ -50,30 +51,47 @@ def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
return dff return dff
def add_links(dff: pl.DataFrame): def add_links(dff: pl.DataFrame, target: str = "_blank"):
dff = dff.with_columns( for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
pl.when(pl.col("titulaire_typeIdentifiant") == "SIRET") if col in dff.columns:
.then( if col.startswith("titulaire_"):
'<a href = "/titulaires/' dff = dff.with_columns(
+ pl.col("titulaire_id") pl.when(
+ '">' pl.Expr.or_(
+ pl.col("titulaire_id") pl.col("titulaire_typeIdentifiant").is_null(),
+ "</a>" pl.col("titulaire_typeIdentifiant") == "SIRET",
) )
.otherwise(pl.col("titulaire_id")) )
.alias("titulaire_id") .then(
) '<a href = "/titulaires/'
+ pl.col("titulaire_id")
for column, path in [("acheteur_id", "acheteurs"), ("uid", "marches")]: + f'" target="{target}">'
dff = dff.with_columns( + pl.col(col)
( + "</a>"
f'<a href = "/{path}/' )
+ pl.col(column) .otherwise(pl.col(col))
+ '" target="_blank">' .alias(col)
+ pl.col(column) )
+ "</a>" if col.startswith("acheteur_"):
).alias(column) dff = dff.with_columns(
) (
'<a href = "/acheteurs/'
+ pl.col("acheteur_id")
+ f'" target="{target}">'
+ pl.col(col)
+ "</a>"
).alias(col)
)
if col == "uid":
dff = dff.with_columns(
(
'<a href = "/marches/'
+ pl.col("uid")
+ f'" target="{target}">'
+ pl.col("uid")
+ "</a>"
).alias("uid")
)
return dff return dff
@@ -208,6 +226,18 @@ def get_decp_data() -> pl.DataFrame:
return lff.collect() return lff.collect()
def get_org_data(dff: pl.DataFrame, org_type: str) -> pl.DataFrame:
lff = dff.lazy()
lff = lff.select(
"uid",
cs.starts_with(org_type).exclude(
f"{org_type}_latitude", f"{org_type}_longitude"
),
)
lff = lff.group_by(cs.starts_with(org_type)).len("Marchés")
return lff.collect()
def get_departements() -> dict: def get_departements() -> dict:
with open("data/departements.json", "rb") as f: with open("data/departements.json", "rb") as f:
data = json.load(f) data = json.load(f)
@@ -364,7 +394,84 @@ def get_data_schema() -> dict:
return new_schema return new_schema
def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
"""
Search in either 'acheteur' or 'titulaire' DataFrame.
:param dff: Polars DataFrame with acheteur or titulaire columns
:param query: User search string
:param org_type: 'acheteur' or 'titulaire'
:return: Filtered DataFrame with 'matches' column
"""
if not query.strip():
return dff.select(pl.lit(False).alias("matches"))
sleep(0.2)
# Normalize query
normalized_query = unidecode(query.strip()).upper()
tokens = [" " + t.strip() for t in normalized_query.split() if t.strip()]
# Define columns based on entity type
cols = [
f"{org_type}_id",
f"{org_type}_nom",
f"{org_type}_departement_nom",
f"{org_type}_departement_code",
f"{org_type}_commune_nom",
]
# Concatenate all fields into one string per row
org_str = pl.concat_str(pl.lit(" "), pl.col(cols), separator=" ")
# For each token, create a boolean column: True if token is found
token_matches = []
for token in tokens:
token_match = org_str.str.contains(token).alias(f"token_{token}")
token_matches.append(token_match)
# Count how many tokens match per row
match_score = pl.sum_horizontal(token_matches).alias("match_score")
# For each token, create a boolean column: True if token is found
token_matches = []
for token in tokens:
token_match = org_str.str.contains(token).alias(f"token_{token}")
token_matches.append(token_match)
# Sélection des colonnes
if org_type == "acheteur":
dff = dff.select(cols + ["Marchés"])
if org_type == "titulaire":
dff = dff.select(cols + ["Marchés", "titulaire_typeIdentifiant"])
# Apply and filter
dff = (
dff.with_columns(token_matches + [match_score])
.filter(pl.col("match_score") == len(tokens))
.sort("Marchés", descending=True)
.drop([f"token_{token}" for token in tokens])
)
# Format result
dff = add_links(dff, target="")
dff = dff.with_columns(
pl.concat_str(
pl.col(f"{org_type}_departement_nom"),
pl.lit(" ("),
pl.col(f"{org_type}_departement_code"),
pl.lit(")"),
).alias("Département")
)
dff = dff.select(f"{org_type}_id", f"{org_type}_nom", "Département", "Marchés")
return dff
df: pl.DataFrame = get_decp_data() df: pl.DataFrame = get_decp_data()
df_acheteurs = get_org_data(df, "acheteur")
df_titulaires = get_org_data(df, "titulaire")
departements = get_departements() departements = get_departements()
domain_name = ( domain_name = (
"test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info" "test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"