diff --git a/dags/cluster/config/model.py b/dags/cluster/config/model.py index 2c70a0988..c9aab6fa1 100644 --- a/dags/cluster/config/model.py +++ b/dags/cluster/config/model.py @@ -14,16 +14,27 @@ class ClusterConfig(BaseModel): # et valeurs obligatoires, voir section validation # pour toutes les règles dry_run: bool + + # SELECTION ACTEURS NON-PARENTS include_source_codes: list[str] include_acteur_type_codes: list[str] include_only_if_regex_matches_nom: str | None include_if_all_fields_filled: list[str] exclude_if_any_field_filled: list[str] + + # SELECTION PARENTS EXISTANTS + # Pas de champ source car par définition parents = 0 source + # Pas de champ acteur type = on prend tous les acteur type ci-dessus + include_parents_only_if_regex_matches_nom: str | None + + # NORMALISATION normalize_fields_basic: list[str] normalize_fields_no_words_size1: list[str] normalize_fields_no_words_size2_or_less: list[str] normalize_fields_no_words_size3_or_less: list[str] normalize_fields_order_unique_words: list[str] + + # CLUSTERING cluster_intra_source_is_allowed: bool cluster_fields_exact: list[str] cluster_fields_fuzzy: list[str] @@ -96,12 +107,25 @@ def check_model(cls, values): dropdown_selected=values["include_acteur_type_codes"], ) + """ + Logique supprimée le 2025-01-27 mais conservée pour référence: + - depuis l'ajout des parents indépendants des sources + via PR1265 on ne peut plus savoir si on héritera + uniquement d'une seule source au moment de la config, donc on + laisse passer au niveau de la sélection + TODO: on pourrait scinder la config en plusieurs sous-config: + - SelectionConfig + - NormalizationConfig + - ClusteringConfig + - EnrichmentConfig + Et ainsi avoir des validations plus fines à chaque étape # ACTEUR TYPE vs. INTRA-SOURCE if ( len(values["include_source_ids"]) == 1 and not values["cluster_intra_source_is_allowed"] ): raise ValueError("1 source sélectionnée mais intra-source désactivé") + """ # Par défaut on ne clusterise pas les acteurs d'une même source # sauf si intra-source est activé diff --git a/dags/cluster/dags/cluster_acteurs_suggestions.py b/dags/cluster/dags/cluster_acteurs_suggestions.py index fa3cbff25..19bdcd3b7 100644 --- a/dags/cluster/dags/cluster_acteurs_suggestions.py +++ b/dags/cluster/dags/cluster_acteurs_suggestions.py @@ -11,6 +11,12 @@ from airflow import DAG from airflow.models.baseoperator import chain from airflow.models.param import Param +from cluster.dags.ui import ( + UI_PARAMS_SEPARATOR_CLUSTERING, + UI_PARAMS_SEPARATOR_NORMALIZATION, + UI_PARAMS_SEPARATOR_SELECTION_ACTEURS, + UI_PARAMS_SEPARATOR_SELECTION_PARENTS, +) from cluster.tasks.airflow_logic import ( cluster_acteurs_config_create_task, cluster_acteurs_normalize_task, @@ -51,37 +57,6 @@ dropdown_acteur_columns = django_model_fields_attributes_get(Acteur) -UI_PARAMS_SEPARATOR_SELECTION = r""" - ---- - -# Paramètres de sélection -Les paramètres suivants décident des acteurs à inclure -ou exclure comme candidats au clustering. Ce n'est pas -parce qu'un acteur est selectionné qu'il sera forcément clusterisé. -(ex: si il se retrouve tout seul sachant qu'on supprime -les clusters de taille 1) -""" - -UI_PARAMS_SEPARATOR_NORMALIZATION = r""" - ---- - -# Paramètres de normalisation -Les paramètres suivants définissent comment les valeurs -des champs vont être transformées avant le clustering. -""" - -UI_PARAMS_SEPARATOR_CLUSTERING = r""" - ---- - -# Paramètres de clustering -Les paramètres suivants définissent comment les acteurs -vont être regroupés en clusters. -""" - - with DAG( dag_id="cluster_acteurs_suggestions", dag_display_name="Cluster - Acteurs - Suggestions", @@ -107,9 +82,13 @@ "dry_run": Param( True, type="boolean", - description_md=f"""🚱 Si coché, seules les tâches qui ne modifient pas - la base de données seront exécutées. - {UI_PARAMS_SEPARATOR_SELECTION}""", + description_md=f""" + 🚱 Si coché, aucune tâche d'écriture ne sera effectuée. + Ceci permet de tester le DAG rapidement sans peur de + casser quoi que ce soit (itérer plus vite) + (ex: pas d'écriture des suggestions en DB, + donc pas visible dans Django Admin). + {UI_PARAMS_SEPARATOR_SELECTION_ACTEURS}""", ), # TODO: permettre de ne sélectionner aucune source = toutes les sources "include_source_codes": Param( @@ -142,7 +121,7 @@ champ avant l'application de la regex pour simplifier les expressions 0️⃣ Si aucune valeur spécifiée = cette option n'a PAS d'effet - """, + {UI_PARAMS_SEPARATOR_SELECTION_PARENTS}""", ), "include_if_all_fields_filled": Param( ["code_postal"], @@ -164,9 +143,22 @@ exemple: travailler uniquement sur les acteurs SANS SIRET 0️⃣ Si aucune valeur spécifiée = cette option n'a PAS d'effet - {UI_PARAMS_SEPARATOR_NORMALIZATION} + {UI_PARAMS_SEPARATOR_SELECTION_PARENTS} """, ), + "include_parents_only_if_regex_matches_nom": Param( + "", + type=["null", "string"], + description_md=f"""**➕ INCLUSION PARENTS**: ceux dont le champ 'nom' + correspond à cette expression régulière ([voir recettes](https://www.notion.so/accelerateur-transition-ecologique-ademe/Expressions-r-guli-res-regex-1766523d57d780939a37edd60f367b75)) + + 🧹 Note: la normalisation basique est appliquée à la volée sur ce + champ avant l'application de la regex pour simplifier les expressions + + 0️⃣ Si aucune valeur spécifiée = cette option n'a PAS d'effet + + {UI_PARAMS_SEPARATOR_NORMALIZATION}""", + ), "normalize_fields_basic": Param( [], type=["null", "array"], @@ -210,7 +202,10 @@ """, ), "normalize_fields_no_words_size3_or_less": Param( - ["nom"], + # Feedback métier: supprimer des mots de taille 3 + # commence à devenir radical, donc on laisse ce champ + # vide par défaut + [], type=["null", "array"], examples=dropdown_acteur_columns, description_md=r"""Les champs à normaliser en supprimant les mots diff --git a/dags/cluster/dags/ui.py b/dags/cluster/dags/ui.py new file mode 100644 index 000000000..aae2f0ed6 --- /dev/null +++ b/dags/cluster/dags/ui.py @@ -0,0 +1,48 @@ +""" +Code nécessaire pour le formattage de la UI Airflow, +qu'on déplace dans ce fichier pour ne pas encombrer +le DAG +""" + +UI_PARAMS_SEPARATOR_SELECTION_ACTEURS = r""" + +--- + +# 🔎 Paramètres **sélection acteurs non-parent** +Les paramètres suivants décident des acteurs non-parent à inclure +ou exclure comme candidats au clustering. Ce n'est pas +parce qu'un acteur est selectionné qu'il sera forcément clusterisé +(ex: si il se retrouve tout seul sachant qu'on supprime +les clusters de taille 1) +""" + +UI_PARAMS_SEPARATOR_SELECTION_PARENTS = r""" + +--- + +# 🔎 Paramètres **sélection parents existants** +Les paramètres suivants décident des parents à inclure +ou exclure comme candidats au clustering. + + - 💯 Par défault on prend **TOUS les parents** des **mêmes acteur-types + utilisés pour les acteurs** + - Et on filtre ces parents avec les paramètres suivants: +""" + +UI_PARAMS_SEPARATOR_NORMALIZATION = r""" + +--- + +# 🧹 Paramètres de **normalisation acteurs + parents** +Les paramètres suivants définissent comment les valeurs +des champs vont être transformées avant le clustering. +""" + +UI_PARAMS_SEPARATOR_CLUSTERING = r""" + +--- + +# 📦 Paramètres de **clustering acteurs + parents** +Les paramètres suivants définissent comment les acteurs +vont être regroupés en clusters. +""" diff --git a/dags/cluster/tasks/airflow_logic/cluster_acteurs_selection_from_db.py b/dags/cluster/tasks/airflow_logic/cluster_acteurs_selection_from_db.py index 1fad900da..8111f9804 100644 --- a/dags/cluster/tasks/airflow_logic/cluster_acteurs_selection_from_db.py +++ b/dags/cluster/tasks/airflow_logic/cluster_acteurs_selection_from_db.py @@ -1,10 +1,13 @@ import logging +import numpy as np +import pandas as pd from airflow import DAG from airflow.operators.python import PythonOperator from cluster.config.model import ClusterConfig -from cluster.tasks.business_logic.cluster_acteurs_df_sort import cluster_acteurs_df_sort -from cluster.tasks.business_logic.cluster_acteurs_selection_from_db import ( +from cluster.tasks.business_logic import ( + cluster_acteurs_df_sort, + cluster_acteurs_selection_acteur_type_parents, cluster_acteurs_selection_from_db, ) from utils import logging_utils as log @@ -45,7 +48,14 @@ def cluster_acteurs_selection_from_db_wrapper(**kwargs) -> None: ) log.preview("Config reçue", config) - df, query = cluster_acteurs_selection_from_db( + # -------------------------------- + # 1) Sélection des acteurs + # -------------------------------- + # Quels qu'ils soient (enfants ou parents) sur la + # base de tous les critères d'inclusion/exclusion + # fournis au niveau du DAG + logging.info(log.banner_string("Sélection des acteurs")) + df_acteurs, query = cluster_acteurs_selection_from_db( model_class=DisplayedActeur, include_source_ids=config.include_source_ids, include_acteur_type_ids=config.include_acteur_type_ids, @@ -54,10 +64,46 @@ def cluster_acteurs_selection_from_db_wrapper(**kwargs) -> None: exclude_if_any_field_filled=config.exclude_if_any_field_filled, extra_dataframe_fields=config.fields_used, ) + df_acteurs = cluster_acteurs_df_sort(df_acteurs) log.preview("requête SQL utilisée", query) - log.preview("acteurs sélectionnés", df) - log.preview("# acteurs par source_id", df.groupby("source_id").size()) - log.preview("# acteurs par acteur_type_id", df.groupby("acteur_type_id").size()) + log.preview("# acteurs par source_id", df_acteurs.groupby("source_id").size()) + log.preview( + "# acteurs par acteur_type_id", df_acteurs.groupby("acteur_type_id").size() + ) + log.preview_df_as_markdown("acteurs sélectionnés", df_acteurs) + + # -------------------------------- + # 2) Sélection des parents uniquements + # -------------------------------- + # Aujourd'hui (2025-01-27): la convention data veut qu'un parent + # soit attribué une source NULL. Donc si le métier choisit de + # clusteriser des sources en particulier à 1), on ne peut donc + # jamais récupérer les parents potentiels pour le même type d'acteur + # La logique ci-dessous vient palier à ce problème en sélectionnant + # TOUS les parents des acteurs types sélectionnés, et en ignorant + # les autres paramètres de sélection + logging.info(log.banner_string("Sélection des parents")) + df_parents = cluster_acteurs_selection_acteur_type_parents( + acteur_type_ids=config.include_acteur_type_ids, + fields=config.fields_used, + include_only_if_regex_matches_nom=config.include_parents_only_if_regex_matches_nom, + ) + df_parents = cluster_acteurs_df_sort(df_parents) + log.preview_df_as_markdown("parents sélectionnés", df_parents) + + # -------------------------------- + # 3) Fusion acteurs + parents + # -------------------------------- + logging.info(log.banner_string("Fusion acteurs + parents")) + ids = set() + ids.update(df_acteurs["identifiant_unique"].values) + ids.update(df_parents["identifiant_unique"].values) + log.preview("IDs avant la fusion", ids) + df = pd.concat([df_acteurs, df_parents], ignore_index=True).replace({np.nan: None}) + df = df.drop_duplicates(subset="identifiant_unique", keep="first") + df = cluster_acteurs_df_sort(df) + log.preview("IDs après la fusion", df["identifiant_unique"].tolist()) + log.preview_df_as_markdown("acteurs + parents sélectionnés", df) if df.empty: raise ValueError("Aucun acteur trouvé avec les critères de sélection") diff --git a/dags/cluster/tasks/business_logic/__init__.py b/dags/cluster/tasks/business_logic/__init__.py index 30fc6ba8b..c376c5b8a 100644 --- a/dags/cluster/tasks/business_logic/__init__.py +++ b/dags/cluster/tasks/business_logic/__init__.py @@ -3,6 +3,10 @@ from .cluster_acteurs_parent_calculations import ( # noqa cluster_acteurs_parent_calculations, ) +from .cluster_acteurs_selection_acteur_type_parents import ( # noqa + cluster_acteurs_selection_acteur_type_parents, +) +from .cluster_acteurs_selection_from_db import cluster_acteurs_selection_from_db # noqa from .cluster_acteurs_suggestions import cluster_acteurs_suggestions # noqa from .cluster_acteurs_suggestions_to_db import cluster_acteurs_suggestions_to_db # noqa from .cluster_acteurs_suggestions_validate import ( # noqa diff --git a/dags/cluster/tasks/business_logic/cluster_acteurs_selection_acteur_type_parents.py b/dags/cluster/tasks/business_logic/cluster_acteurs_selection_acteur_type_parents.py new file mode 100644 index 000000000..5ff7532f9 --- /dev/null +++ b/dags/cluster/tasks/business_logic/cluster_acteurs_selection_acteur_type_parents.py @@ -0,0 +1,57 @@ +import pandas as pd +from shared.tasks.business_logic import normalize +from utils.django import django_model_queryset_to_df, django_setup_full + +django_setup_full() +from qfdmo.models import ActeurType, DisplayedActeur # noqa: E402 +from qfdmo.models.acteur import ActeurStatus # noqa: E402 + + +def cluster_acteurs_selection_acteur_type_parents( + acteur_type_ids: list[int], + fields: list[str], + include_only_if_regex_matches_nom: str | None = None, +) -> pd.DataFrame: + """Sélectionne tous les parents des acteurs types donnés, + pour pouvoir notamment permettre de clusteriser avec + ces parents existant indépendemment des critères de sélection + des autres acteurs qu'on cherche à clusteriser (ex: si on cherche + à clusteriser les acteurs commerce de source A MAIS en essayant + de rattacher au maximum avec tous les parents commerce existants)""" + + # Ajout des champs nécessaires au fonctionnement de la fonction + # si manquant + if "nom" not in fields: + fields.append("nom") + + # Petite validation (on ne fait pas confiance à l'appelant) + ids_in_db = list(ActeurType.objects.values_list("id", flat=True)) + ids_invalid = set(acteur_type_ids) - set(ids_in_db) + if ids_invalid: + raise ValueError(f"acteur_type_ids {ids_invalid} pas trouvés en DB") + + # On récupère les parents des acteurs types donnés + # qui sont censés être des acteurs sans source + parents = DisplayedActeur.objects.filter( + acteur_type__id__in=acteur_type_ids, + statut=ActeurStatus.ACTIF, + source__id__isnull=True, + ) + + df = django_model_queryset_to_df(parents, fields) + + # Si une regexp de nom est fournie, on l'applique + # pour filtrer la df, sinon on garde toute la df + if include_only_if_regex_matches_nom: + print(f"{include_only_if_regex_matches_nom=}") + print(df["nom"].map(normalize.string_basic).tolist()) + df = df[ + df["nom"] + # On applique la normalisation de base à la volée + # pour simplifier les regex + .map(normalize.string_basic).str.contains( + include_only_if_regex_matches_nom, na=False, regex=True + ) + ].copy() + + return df diff --git a/dags/cluster/tasks/business_logic/cluster_acteurs_suggestions.py b/dags/cluster/tasks/business_logic/cluster_acteurs_suggestions.py index cfd960159..dc0a79d5c 100644 --- a/dags/cluster/tasks/business_logic/cluster_acteurs_suggestions.py +++ b/dags/cluster/tasks/business_logic/cluster_acteurs_suggestions.py @@ -23,11 +23,6 @@ logger = logging.getLogger(__name__) -COLS_GROUP_EXACT_ALWAYS = [ - # "code_departement", - "code_postal", -] - def cluster_id_from_strings(strings: list[str]) -> str: """ @@ -256,7 +251,7 @@ def cluster_acteurs_suggestions( ) # Vérification des colonnes - for col in COLS_GROUP_EXACT_ALWAYS + cluster_fields_exact: + for col in cluster_fields_exact: if col not in df.columns: raise ValueError(f"Colonne match exacte '{col}' pas dans le DataFrame") for col in cluster_fields_fuzzy: @@ -265,13 +260,10 @@ def cluster_acteurs_suggestions( # On supprime les lignes avec des valeurs nulles pour les colonnes exact df = df.dropna( - subset=COLS_GROUP_EXACT_ALWAYS - + cluster_fields_exact - + cluster_fields_separate - + cluster_fields_fuzzy + subset=cluster_fields_exact + cluster_fields_separate + cluster_fields_fuzzy ) # Ordonne df sur les colonnes exactes - df = df.sort_values(COLS_GROUP_EXACT_ALWAYS + cluster_fields_exact) + df = df.sort_values(cluster_fields_exact) # On ne garde que les colonnes utiles cols_ids_codes = [ @@ -279,8 +271,7 @@ def cluster_acteurs_suggestions( ] cols_to_keep = list( set( - COLS_GROUP_EXACT_ALWAYS - + cols_ids_codes + cols_ids_codes + cluster_fields_exact + cluster_fields_separate + cluster_fields_fuzzy @@ -294,9 +285,7 @@ def cluster_acteurs_suggestions( # On groupe par les colonnes exactes clusters_size1 = [] clusters = [] - for exact_keys, exact_rows in df.groupby( - COLS_GROUP_EXACT_ALWAYS + cluster_fields_exact - ): + for exact_keys, exact_rows in df.groupby(cluster_fields_exact): # On ne considère que les clusters de taille 2+ if len(exact_rows) < 2: logger.info(f"🔴 Ignoré: cluster de taille <2: {list(exact_keys)}") diff --git a/dags_unit_tests/cluster/config/test_model.py b/dags_unit_tests/cluster/config/test_model.py index d319af740..b418eccb0 100644 --- a/dags_unit_tests/cluster/config/test_model.py +++ b/dags_unit_tests/cluster/config/test_model.py @@ -15,6 +15,7 @@ def params_working(self) -> dict: "include_only_if_regex_matches_nom": "mon nom", "include_if_all_fields_filled": ["f1_incl", "f2_incl"], "exclude_if_any_field_filled": ["f3_excl", "f4_excl"], + "include_parents_only_if_regex_matches_nom": None, "normalize_fields_basic": ["basic1", "basic2"], "normalize_fields_no_words_size1": ["size1"], "normalize_fields_no_words_size2_or_less": ["size2"], @@ -134,6 +135,11 @@ def test_default_dry_run_is_true(self, params_working): with pytest.raises(ValueError, match="dry_run à fournir"): ClusterConfig(**params_working) + """ + Test supprimé le 2025-01-27 mais conservé pour référence: + - depuis l'ajout des parents indépendant des sources + via PR1265 on ne peut plus savoir si on héritera + uniquement d'une seule source au moment de la config def test_error_one_source_no_intra(self, params_working): # Si on ne founit qu'une source alors il faut autoriser # le clustering intra-source @@ -142,6 +148,7 @@ def test_error_one_source_no_intra(self, params_working): msg = "1 source sélectionnée mais intra-source désactivé" with pytest.raises(ValueError, match=msg): ClusterConfig(**params_working) + """ def test_error_must_provide_acteur_type(self, params_working): # Si aucun type d'acteur fourni alors on lève une erreur diff --git a/dags_unit_tests/cluster/tasks/business_logic/test_cluster_acteurs_selection_acteur_type_parents.py b/dags_unit_tests/cluster/tasks/business_logic/test_cluster_acteurs_selection_acteur_type_parents.py new file mode 100644 index 000000000..c620f2032 --- /dev/null +++ b/dags_unit_tests/cluster/tasks/business_logic/test_cluster_acteurs_selection_acteur_type_parents.py @@ -0,0 +1,152 @@ +""" +Fichier de test pour la fonction cluster_acteurs_selection_acteur_type_parents +""" + +import pandas as pd +import pytest +from cluster.tasks.business_logic import cluster_acteurs_selection_acteur_type_parents + +from qfdmo.models import DisplayedActeur +from unit_tests.qfdmo.acteur_factory import ActeurTypeFactory, SourceFactory + + +@pytest.mark.django_db() +class TestClusterActeursSelectionActeurTypeParents: + + @pytest.fixture + def db_testdata_write(self) -> dict: + print("db_testdata_write") + """Création des donnéees de test en DB""" + data = {} + data["at1"] = ActeurTypeFactory(code="at1") + data["at2"] = ActeurTypeFactory(code="at2") + data["at3"] = ActeurTypeFactory(code="at3") + data["at4"] = ActeurTypeFactory(code="at4") + data["s1"] = SourceFactory(code="s1") + data["id_at1_parent"] = "10000000-0000-0000-0000-000000000000" + # On fait exprès d'utiliser des UUIDs partout, y compris + # pour les non-parents, pour démontrer que la requête ne + # se base pas sur l'anatomie des IDs + data["id_at2_pas_parent"] = "20000000-0000-0000-0000-000000000000" + data["id_at2_parent_a"] = "20000000-0000-0000-0000-00000000000a" + data["id_at2_parent_b"] = "20000000-0000-0000-0000-00000000000b" + data["id_at3_pas_parent"] = "30000000-0000-0000-0000-000000000000" + data["id_at4_parent"] = "40000000-0000-0000-0000-000000000000" + data["id_at4_parent_inactif"] = "40000000-0000-0000-0000-00000inactif" + + # at1 + # Parent MAIS d'un acteur type non sélectionné (at1) + DisplayedActeur.objects.create( + acteur_type=data["at1"], + identifiant_unique=data["id_at1_parent"], + ) + # at2 + # On test le cas où il y a plusieurs parents + # Pas parent car avec une source + DisplayedActeur.objects.create( + acteur_type=data["at2"], + identifiant_unique=data["id_at2_pas_parent"], + source=data["s1"], + ) + # Parents car sans source + DisplayedActeur.objects.create( + nom="Mon parent at2 a", + acteur_type=data["at2"], + identifiant_unique=data["id_at2_parent_a"], + ) + DisplayedActeur.objects.create( + nom="Mon PÂRËNT at2 b", + acteur_type=data["at2"], + identifiant_unique=data["id_at2_parent_b"], + ) + # at3 + # Pour at3 on test le cas où il n'y a pas de parent + DisplayedActeur.objects.create( + acteur_type=data["at3"], + identifiant_unique=data["id_at3_pas_parent"], + source=data["s1"], + ) + # at4 + # On test le cas où il y a 1 parent + DisplayedActeur.objects.create( + nom="Mon parent at4", + acteur_type=data["at4"], + identifiant_unique=data["id_at4_parent"], + ) + # On test le cas où il y a 1 parent mais inactif + DisplayedActeur.objects.create( + acteur_type=data["at4"], + identifiant_unique=data["id_at4_parent_inactif"], + statut="INACTIF", + ) + + return data + + @pytest.fixture + def df_working(self, db_testdata_write) -> pd.DataFrame: + """On génère et retourne la df pour les tests""" + data = db_testdata_write + acteur_type_ids = [data["at2"].id, data["at3"].id, data["at4"].id] + fields = ["identifiant_unique", "statut", "latitude"] + return cluster_acteurs_selection_acteur_type_parents( + acteur_type_ids=acteur_type_ids, + fields=fields, + ) + + def test_df_shape(self, df_working): + # 3 parents (2 parents pour at2 + 0 pour at3 + 1 pour at4) + # 4 champs (+nom) + assert df_working.shape == (3, 4) + + def test_df_columns(self, df_working): + # Seules les colonnes demandées sont retournées + assert sorted(df_working.columns.tolist()) == sorted( + [ + "identifiant_unique", + "statut", + "latitude", + "nom", + ] + ) + + def test_parents_are_valid(self, df_working, db_testdata_write): + # Seuls les parents des acteurs types demandés + # sont retournés + data = db_testdata_write + assert sorted(df_working["identifiant_unique"].tolist()) == sorted( + [ + data["id_at2_parent_a"], + data["id_at2_parent_b"], + data["id_at4_parent"], + ] + ) + + def test_parents_not_actif_excluded(self, df_working, db_testdata_write): + # Les parents inactifs ne sont pas retournés + data = db_testdata_write + assert ( + data["id_at4_parent_inactif"] + not in df_working["identifiant_unique"].tolist() + ) + + def test_with_regex(self, db_testdata_write): + """On génère et retourne la df avec la même config + MAIS cette fois si on ajoute l'expression régulière sur le nom""" + data = db_testdata_write + acteur_type_ids = [data["at2"].id, data["at3"].id, data["at4"].id] + fields = ["identifiant_unique", "statut", "latitude"] + df = cluster_acteurs_selection_acteur_type_parents( + acteur_type_ids=acteur_type_ids, + fields=fields, + # On démontre que les regex sont appliquées sur + # les versions normalisées à la volée des noms + include_only_if_regex_matches_nom=r"parent at(?:1|2) (?:a|b)", + ) + assert sorted(df["nom"].tolist()) == sorted( + [ + # La donnée n'est pas modifiée, uniquement normalisée + # à la volée pour l'application des regex + "Mon parent at2 a", + "Mon PÂRËNT at2 b", + ] + ) diff --git a/dags_unit_tests/cluster/tasks/business_logic/test_cluster_acteurs_suggestions.py b/dags_unit_tests/cluster/tasks/business_logic/test_cluster_acteurs_suggestions.py index 2fb9e551b..6e9e05aca 100644 --- a/dags_unit_tests/cluster/tasks/business_logic/test_cluster_acteurs_suggestions.py +++ b/dags_unit_tests/cluster/tasks/business_logic/test_cluster_acteurs_suggestions.py @@ -86,9 +86,9 @@ def test_cols_group_exact(self, df_basic): assert df_clusters["cluster_id"].nunique() == 3 clusters = df_clusters_to_dict(df_clusters) assert clusters == { - "75000_paris": ["id1", "id2", "id3"], - "75000_paris-typo": ["id4-a", "id4-b"], - "53000_laval": ["id5-a", "id5-b"], + "paris": ["id1", "id2", "id3"], + "paris-typo": ["id4-a", "id4-b"], + "laval": ["id5-a", "id5-b"], } def test_validation_cols_group_exact(self, df_basic): @@ -138,7 +138,7 @@ def test_clusters_of_one_are_removed(self, df_some_clusters_of_one): assert len(df_clusters) == 2 clusters = df_clusters_to_dict(df_clusters) assert clusters == { - "13000_marseille": ["id1", "id4"], + "marseille": ["id1", "id4"], } # ----------------------------------------------- @@ -168,7 +168,7 @@ def test_cols_group_fuzzy_single(self, df_cols_group_fuzzy): df_clusters = cluster_acteurs_suggestions( df_cols_group_fuzzy, # code_postal est en dur dans la fonction de clustering - cluster_fields_exact=[], + cluster_fields_exact=["code_postal"], cluster_fields_fuzzy=["nom"], cluster_fuzzy_threshold=0.7, )