diff --git a/README.md b/README.md index e9183ca..8537ef3 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,9 @@ Ne pas oublier de mettre à jour les fichier .env. #### 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))) -- 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) diff --git a/pyproject.toml b/pyproject.toml index 5f29774..c81d016 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,8 @@ dependencies = [ "xlsxwriter", "plotly[express]", "httpx", - "pandas" # utilisé pour la création de certains graphiques + "pandas", # utilisé pour la création de certains graphiques + "unidecode" ] [project.optional-dependencies] diff --git a/src/assets/style.css b/src/assets/style.css index e29f53d..d5b7cfb 100644 --- a/src/assets/style.css +++ b/src/assets/style.css @@ -148,6 +148,35 @@ td[data-dash-column="objet"],td[data-dash-column="titulaire_nom"],td[data-dash-c 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 */ .navbar { @@ -177,7 +206,7 @@ summary > h3 { padding-top: 28px; } -/* Vue acheteur/titulaire */ +/* Vue acheteur/titulaire/recherche */ .wrapper { display: grid; grid-gap: 10px; @@ -185,9 +214,6 @@ summary > h3 { justify-content: space-between; } -.wrapper > div { -} - .org_title { grid-column: 1 / 3; grid-row: 1; diff --git a/src/pages/recherche.py b/src/pages/recherche.py new file mode 100644 index 0000000..e74a720 --- /dev/null +++ b/src/pages/recherche.py @@ -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("") diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 4b3d380..83bfffe 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -25,7 +25,7 @@ schema = df.collect_schema() name = "Tableau" register_page( __name__, - path="/", + path="/tableau", title=meta_content["title"], name=name, description=meta_content["description"], diff --git a/src/utils.py b/src/utils.py index 23ad4fe..9552df2 100644 --- a/src/utils.py +++ b/src/utils.py @@ -8,6 +8,7 @@ import polars.selectors as cs from httpx import get from polars import Schema from polars.exceptions import ComputeError +from unidecode import unidecode operators = [ ["s<", "<"], @@ -50,30 +51,47 @@ def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame: return dff -def add_links(dff: pl.DataFrame): - dff = dff.with_columns( - pl.when(pl.col("titulaire_typeIdentifiant") == "SIRET") - .then( - '' - + pl.col("titulaire_id") - + "" - ) - .otherwise(pl.col("titulaire_id")) - .alias("titulaire_id") - ) - - for column, path in [("acheteur_id", "acheteurs"), ("uid", "marches")]: - dff = dff.with_columns( - ( - f'' - + pl.col(column) - + "" - ).alias(column) - ) +def add_links(dff: pl.DataFrame, target: str = "_blank"): + for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]: + if col in dff.columns: + if col.startswith("titulaire_"): + dff = dff.with_columns( + pl.when( + pl.Expr.or_( + pl.col("titulaire_typeIdentifiant").is_null(), + pl.col("titulaire_typeIdentifiant") == "SIRET", + ) + ) + .then( + '' + + pl.col(col) + + "" + ) + .otherwise(pl.col(col)) + .alias(col) + ) + if col.startswith("acheteur_"): + dff = dff.with_columns( + ( + '' + + pl.col(col) + + "" + ).alias(col) + ) + if col == "uid": + dff = dff.with_columns( + ( + '' + + pl.col("uid") + + "" + ).alias("uid") + ) return dff @@ -208,6 +226,18 @@ def get_decp_data() -> pl.DataFrame: 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: with open("data/departements.json", "rb") as f: data = json.load(f) @@ -364,7 +394,84 @@ def get_data_schema() -> dict: 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_acheteurs = get_org_data(df, "acheteur") +df_titulaires = get_org_data(df, "titulaire") departements = get_departements() domain_name = ( "test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"