Merge branch 'feature/58_search' into dev
This commit is contained in:
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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("")
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user