diff --git a/core/views.py b/core/views.py new file mode 100644 index 000000000..4439d12e9 --- /dev/null +++ b/core/views.py @@ -0,0 +1,8 @@ +from django.contrib.auth.mixins import LoginRequiredMixin + + +class IsStaffMixin(LoginRequiredMixin): + def dispatch(self, request, *args, **kwargs): + if not request.user.is_staff: + return self.handle_no_permission() + return super().dispatch(request, *args, **kwargs) diff --git a/dags/sources/tasks/airflow_logic/operators.py b/dags/sources/tasks/airflow_logic/operators.py index ad4cddfb6..efa5fba9f 100755 --- a/dags/sources/tasks/airflow_logic/operators.py +++ b/dags/sources/tasks/airflow_logic/operators.py @@ -7,7 +7,7 @@ from sources.tasks.airflow_logic.db_read_propositions_max_id_task import ( db_read_propositions_max_id_task, ) -from sources.tasks.airflow_logic.db_write_suggestion_task import ( +from sources.tasks.airflow_logic.db_write_type_action_suggestions_task import ( db_write_type_action_suggestions_task, ) from sources.tasks.airflow_logic.propose_acteur_changes_task import ( diff --git a/dags/sources/tasks/business_logic/source_data_normalize.py b/dags/sources/tasks/business_logic/source_data_normalize.py index 8830fc8cb..40026ec3d 100755 --- a/dags/sources/tasks/business_logic/source_data_normalize.py +++ b/dags/sources/tasks/business_logic/source_data_normalize.py @@ -52,7 +52,7 @@ def _transform_columns(df: pd.DataFrame, dag_config: DAGConfig) -> pd.DataFrame: for column_to_transform in columns_to_transform: function_name = column_to_transform.transformation normalisation_function = get_transformation_function(function_name, dag_config) - # logger.warning(f"Transformation {function_name}") + logger.warning(f"Transformation {function_name}") df[column_to_transform.destination] = df[column_to_transform.origin].apply( normalisation_function ) @@ -70,7 +70,7 @@ def _transform_df(df: pd.DataFrame, dag_config: DAGConfig) -> pd.DataFrame: for column_to_transform_df in columns_to_transform_df: function_name = column_to_transform_df.transformation normalisation_function = get_transformation_function(function_name, dag_config) - # logger.warning(f"Transformation {function_name}") + logger.warning(f"Transformation {function_name}") df[column_to_transform_df.destination] = df[ column_to_transform_df.origin ].apply(normalisation_function, axis=1) @@ -141,6 +141,22 @@ def _remove_undesired_lines(df: pd.DataFrame, dag_config: DAGConfig) -> pd.DataF return df +def _display_warning_about_missing_location(df: pd.DataFrame) -> None: + # TODO: A voir ce qu'on doit faire de ces acteurs non digitaux mais sans + # localisation (proposition : les afficher en erreur directement ?) + if "location" in df.columns and "acteur_type_code" in df.columns: + df_acteur_sans_loc = df[ + (df["location"].isnull()) & (df["acteur_type_code"] != "acteur_digital") + ] + if not df_acteur_sans_loc.empty: + nb_acteurs = len(df) + logger.warning( + f"Nombre d'acteur sans localisation: {len(df_acteur_sans_loc)} / " + f"{nb_acteurs}" + ) + log.preview("Acteurs sans localisation", df_acteur_sans_loc) + + def source_data_normalize( df_acteur_from_source: pd.DataFrame, dag_config: DAGConfig, @@ -191,19 +207,8 @@ def source_data_normalize( # Merge et suppression des lignes indésirables df = _remove_undesired_lines(df, dag_config) - # TODO: A voir ce qu'on doit faire de ces acteurs non digitaux mais sans - # localisation (proposition : les afficher en erreur directement ?) - if "location" in df.columns and "acteur_type_code" in df.columns: - df_acteur_sans_loc = df[ - (df["location"].isnull()) & (df["acteur_type_code"] != "acteur_digital") - ] - if not df_acteur_sans_loc.empty: - nb_acteurs = len(df) - logger.warning( - f"Nombre d'acteur sans localisation: {len(df_acteur_sans_loc)} / " - f"{nb_acteurs}" - ) - log.preview("Acteurs sans localisation", df_acteur_sans_loc) + # Log si des localisations sont manquantes parmis les acteurs non digitaux + _display_warning_about_missing_location(df) log.preview("df après normalisation", df) if df.empty: diff --git a/dags/sources/tasks/transform/transform_df.py b/dags/sources/tasks/transform/transform_df.py index df1516781..507d144d5 100644 --- a/dags/sources/tasks/transform/transform_df.py +++ b/dags/sources/tasks/transform/transform_df.py @@ -227,7 +227,6 @@ def compute_location(row: pd.Series, _): lng_column = row.keys()[1] row[lat_column] = parse_float(row[lat_column]) row[lng_column] = parse_float(row[lng_column]) - print(row[lat_column], row[lng_column]) row["location"] = transform_location(row[lng_column], row[lat_column]) return row[["location"]] diff --git a/dags/utils/base_utils.py b/dags/utils/base_utils.py index 98548e25a..85d01a098 100755 --- a/dags/utils/base_utils.py +++ b/dags/utils/base_utils.py @@ -115,7 +115,6 @@ def extract_details(row, col="adresse_format_ban"): def transform_location(longitude, latitude): if not longitude or not latitude or math.isnan(longitude) or math.isnan(latitude): - print("Longitude or latitude is missing.") return None return wkb.dumps(Point(longitude, latitude)).hex() diff --git a/data/admin.py b/data/admin.py index 11b18b4dd..b15dcb50f 100644 --- a/data/admin.py +++ b/data/admin.py @@ -1,15 +1,15 @@ from django.contrib.gis import admin -from data.models import SuggestionCohorte, SuggestionUnitaire +from data.models import Suggestion, SuggestionCohorte class SuggestionCohorteAdmin(admin.ModelAdmin): pass -class SuggestionUnitaireAdmin(admin.ModelAdmin): +class SuggestionAdmin(admin.ModelAdmin): pass admin.site.register(SuggestionCohorte, SuggestionCohorteAdmin) -admin.site.register(SuggestionUnitaire, SuggestionUnitaireAdmin) +admin.site.register(Suggestion, SuggestionAdmin) diff --git a/data/apps.py b/data/apps.py index b882be950..29617343f 100644 --- a/data/apps.py +++ b/data/apps.py @@ -4,3 +4,5 @@ class DataConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "data" + label = "data" + verbose_name = "Gestion des interactions avec la plateforme de données" diff --git a/data/migrations/0001_bancache.py b/data/migrations/0001_bancache.py index 887041a9a..01ef4502d 100644 --- a/data/migrations/0001_bancache.py +++ b/data/migrations/0001_bancache.py @@ -26,12 +26,12 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("adresse", models.CharField(blank=True, max_length=255, null=True)), + ("adresse", models.CharField(blank=True, null=True)), ( "code_postal", - models.CharField(blank=True, max_length=255, null=True), + models.CharField(blank=True, null=True), ), - ("ville", models.CharField(blank=True, max_length=255, null=True)), + ("ville", models.CharField(blank=True, null=True)), ( "location", django.contrib.gis.db.models.fields.PointField( diff --git a/data/migrations/0002_tables_suggestion.py b/data/migrations/0002_tables_suggestion.py index 781e61179..9008c58aa 100644 --- a/data/migrations/0002_tables_suggestion.py +++ b/data/migrations/0002_tables_suggestion.py @@ -23,15 +23,15 @@ class Migration(migrations.Migration): ( "identifiant_action", models.CharField( - help_text="Identifiant de l'action (ex : dag_id pour Airflow)", - max_length=250, + verbose_name="Identifiant de l'action", + help_text="(ex : dag_id pour Airflow)", ), ), ( "identifiant_execution", models.CharField( - help_text="Identifiant de l'execution (ex : run_id pour Airflow)", - max_length=250, + verbose_name="Identifiant de l'execution", + help_text="(ex : run_id pour Airflow)", ), ), ( @@ -50,7 +50,7 @@ class Migration(migrations.Migration): ), ("SOURCE_SUPRESSION", "ingestion de source de données"), ], - max_length=250, + max_length=50, ), ), ( @@ -94,7 +94,7 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name="SuggestionUnitaire", + name="Suggestion", fields=[ ("id", models.AutoField(primary_key=True, serialize=False)), ( @@ -117,14 +117,14 @@ class Migration(migrations.Migration): "context", models.JSONField( blank=True, - help_text="Contexte de la suggestion : données initiales", + verbose_name="Contexte de la suggestion : données initiales", null=True, ), ), ( "suggestion", models.JSONField( - blank=True, help_text="Suggestion de modification" + blank=True, verbose_name="Suggestion de modification" ), ), ( diff --git a/data/models.py b/data/models.py index 730ec4247..eb0fc140c 100644 --- a/data/models.py +++ b/data/models.py @@ -45,15 +45,15 @@ class SuggestionCohorte(models.Model): # On utilise identifiant car le champ n'est pas utilisé pour résoudre une relation # en base de données identifiant_action = models.CharField( - max_length=250, help_text="Identifiant de l'action (ex : dag_id pour Airflow)" + verbose_name="Identifiant de l'action", help_text="(ex : dag_id pour Airflow)" ) identifiant_execution = models.CharField( - max_length=250, - help_text="Identifiant de l'execution (ex : run_id pour Airflow)", + verbose_name="Identifiant de l'execution", + help_text="(ex : run_id pour Airflow)", ) type_action = models.CharField( choices=SuggestionAction.choices, - max_length=250, + max_length=50, blank=True, ) statut = models.CharField( @@ -62,7 +62,9 @@ class SuggestionCohorte(models.Model): default=SuggestionStatut.AVALIDER, ) metadata = models.JSONField( - null=True, blank=True, help_text="Metadata de la cohorte, données statistiques" + null=True, + blank=True, + verbose_name="Metadata de la cohorte, données statistiques", ) cree_le = models.DateTimeField(auto_now_add=True, db_default=Now()) modifie_le = models.DateTimeField(auto_now=True, db_default=Now()) @@ -86,7 +88,7 @@ def __str__(self) -> str: return f"{self.identifiant_action} - {self.identifiant_execution}" -class SuggestionUnitaire(models.Model): +class Suggestion(models.Model): id = models.AutoField(primary_key=True) suggestion_cohorte = models.ForeignKey( SuggestionCohorte, on_delete=models.CASCADE, related_name="suggestion_unitaires" @@ -97,9 +99,11 @@ class SuggestionUnitaire(models.Model): default=SuggestionStatut.AVALIDER, ) context = models.JSONField( - null=True, blank=True, help_text="Contexte de la suggestion : données initiales" + null=True, + blank=True, + verbose_name="Contexte de la suggestion : données initiales", ) - suggestion = models.JSONField(blank=True, help_text="Suggestion de modification") + suggestion = models.JSONField(blank=True, verbose_name="Suggestion de modification") cree_le = models.DateTimeField(auto_now_add=True, db_default=Now()) modifie_le = models.DateTimeField(auto_now=True, db_default=Now()) @@ -152,11 +156,11 @@ def display_proposition_service(self): class BANCache(models.Model): class Meta: verbose_name = "Cache BAN" - verbose_name_plural = "Cache BAN" + verbose_name_plural = "Caches BAN" - adresse = models.CharField(max_length=255, blank=True, null=True) - code_postal = models.CharField(max_length=255, blank=True, null=True) - ville = models.CharField(max_length=255, blank=True, null=True) + adresse = models.CharField(blank=True, null=True) + code_postal = models.CharField(blank=True, null=True) + ville = models.CharField(blank=True, null=True) location = models.PointField(blank=True, null=True) ban_returned = models.JSONField(blank=True, null=True) modifie_le = models.DateTimeField(auto_now=True, db_default=Now()) diff --git a/data/urls.py b/data/urls.py index 3419263eb..b4026f355 100644 --- a/data/urls.py +++ b/data/urls.py @@ -1,11 +1,11 @@ from django.urls import path -from data.views import SuggestionManagment +from data.views import SuggestionManagement urlpatterns = [ path( - "suggestions", - SuggestionManagment.as_view(), + "suggestions/", + SuggestionManagement.as_view(), name="suggestions", ), ] diff --git a/data/views.py b/data/views.py index 7e9b0a7fe..b6ab0e7fb 100644 --- a/data/views.py +++ b/data/views.py @@ -1,19 +1,16 @@ +""" +DEPRECATED: cette vue sera bentôt caduque, on utilisera l'administration django +""" + from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin from django.shortcuts import render +from django.urls import reverse from django.views.generic.edit import FormView +from core.views import IsStaffMixin from data.forms import SuggestionCohorteForm from data.models import SuggestionAction, SuggestionStatut - -class IsStaffMixin(LoginRequiredMixin): - def dispatch(self, request, *args, **kwargs): - if not request.user.is_staff: - return self.handle_no_permission() - return super().dispatch(request, *args, **kwargs) - - ACTION_TO_VERB = { SuggestionAction.SOURCE_AJOUT: "ajoutera", SuggestionAction.SOURCE_SUPPRESSION: "supprimera", @@ -21,10 +18,13 @@ def dispatch(self, request, *args, **kwargs): } -class SuggestionManagment(IsStaffMixin, FormView): +class SuggestionManagement(IsStaffMixin, FormView): form_class = SuggestionCohorteForm template_name = "data/dags_validations.html" - success_url = "/data/suggestions" + # success_url = "/data/suggestions" + + def get_success_url(self) -> str: + return reverse("data:suggestions") def form_valid(self, form): # MANAGE search and display suggestion_cohorte details @@ -69,107 +69,3 @@ def form_valid(self, form): def form_invalid(self, form): messages.error(self.request, "Il y a des erreurs dans le formulaire.") return super().form_invalid(form) - - -# class DagsValidationDeprecated(IsStaffMixin, FormView): -# form_class = SuggestionCohorteForm -# template_name = "qfdmo/dags_validations.html" -# success_url = "/dags/validations" - -# def get_initial(self): -# initial = super().get_initial() -# initial["suggestion_cohorte"] = self.request.GET.get("suggestion_cohorte") -# return initial - -# def post(self, request, *args, **kwargs): - -# dag_valid = request.POST.get("dag_valid") -# if dag_valid in ["1", "0"]: -# return self.form_valid(self.get_form()) -# else: -# suggestion_cohorte_obj = SuggestionCohorte.objects.get( -# pk=request.POST.get("suggestion_cohorte") -# ) -# id = request.POST.get("id") -# suggestion_unitaire = suggestion_cohorte_obj.suggestion_unitaires.filter( -# id=id -# ).first() -# identifiant_unique = request.POST.get("identifiant_unique") -# index = request.POST.get("index") -# action = request.POST.get("action") - -# if action == "validate": -# suggestion_unitaire.update_row_update_candidate( -# SuggestionStatut.ATRAITER.value, index -# ) -# elif action == "reject": -# suggestion_unitaire.update_row_update_candidate( -# SuggestionStatut.REJETER.value, index -# ) - -# updated_candidat = suggestion_unitaire.get_candidat(index) - -# return render( -# request, -# "qfdmo/partials/candidat_row.html", -# { -# "identifiant_unique": identifiant_unique, -# "candidat": updated_candidat, -# "index": index, -# "request": request, -# "suggestion_cohorte": request.POST.get("suggestion_cohorte"), -# "suggestion_unitaire": suggestion_unitaire, -# }, -# ) - -# def get_context_data(self, **kwargs): -# context = super().get_context_data(**kwargs) -# if self.request.GET.get("suggestion_cohorte"): -# suggestion_cohorte = SuggestionCohorte.objects.get( -# pk=self.request.GET.get("suggestion_cohorte") -# ) -# context["suggestion_cohorte_instance"] = suggestion_cohorte -# suggestion_unitaires = ( -# suggestion_cohorte.suggestion_unitaires.all().order_by("?")[:100] -# ) -# context["suggestion_unitaires"] = suggestion_unitaires - -# if ( -# suggestion_unitaires -# and suggestion_unitaires[0].change_type == "UPDATE_ACTOR" -# ): -# # Pagination -# suggestion_unitaires = ( -# suggestion_cohorte.suggestion_unitaires.all().order_by("id") -# ) -# paginator = Paginator(suggestion_unitaires, 100) -# page_number = self.request.GET.get("page") -# page_obj = paginator.get_page(page_number) -# context["suggestion_unitaires"] = page_obj - -# return context - -# def form_valid(self, form): -# if not form.is_valid(): -# raise ValueError("Form is not valid") -# suggestion_cohorte_id = form.cleaned_data["suggestion_cohorte"].id -# suggestion_cohorte_obj = ( -# SuggestionCohorte.objects.get(pk=suggestion_cohorte_id) -# ) -# new_status = ( -# SuggestionStatut.ATRAITER.value -# if self.request.POST.get("dag_valid") == "1" -# else SuggestionStatut.REJETER.value -# ) - -# # FIXME: I am not sure we need the filter here -# suggestion_cohorte_obj.suggestion_unitaires.filter( -# status=SuggestionStatut.AVALIDER.value -# ).update(status=new_status) - -# logging.info(f"{suggestion_cohorte_id} - {self.request.user}") - -# suggestion_cohorte_obj.statut = new_status -# suggestion_cohorte_obj.save() - -# return super().form_valid(form) diff --git a/docs/reference/303-systeme-de-suggestions.md b/docs/reference/303-systeme-de-suggestions.md index f42cee410..dc2f7e062 100644 --- a/docs/reference/303-systeme-de-suggestions.md +++ b/docs/reference/303-systeme-de-suggestions.md @@ -4,62 +4,18 @@ Cette proposition de modification de l'architecture pour faire évoluer le système de suggestion est un travail itératif. Il est donc nessaire de garder en tête la cibe et le moyen d'y aller. -## Existant et problématique - -il existe les tables `dagrun` et `dagrunchange`: - -- `dagrun` représente un ensemble de suggestions produit par l'execution d'un DAG airflow -- `dagrinchange` représente la suggestion de modification pour une ligne donnée - -On a quelques problème de lisibilité des ces tables: - -- les types des évenements sont imprécis et utilisé pour plusieurs propos, par exemple, `UPDATE_ACTOR` est utilisé pour des propositions de siretisation et de suppression de acteurs lors de l'ingestion de la source -- les types des évenements sont définis au niveau de chaque ligne, pour connaitre le type de -- si une ligne est problématique, aucune ligne n'est mise à jour -- on n'à pas de vu sur les DAG qui on réussi ou se sont terminés en erreur - -## Proposition d'amélioration - -### Base de données - -- Renommage des tables : `dagrun` -> `suggestion_cohorte` , `dagrunchange` -> `suggestion_unitaire` -- Écrire les champs en français comme le reste des tables de l'application -- Revu des statuts de `suggestion_cohorte` : à traiter, en cours de traitement, fini avec succès, fini avec succès partiel, fini en erreur -- Ajout d'un type d'évenement à `suggestion_cohorte` : source, enrichissement -- Ajout d'un sous-type d'évenement à `suggestion_cohorte` : source - ajout acteur, source - suppression acteur, source - modification acteur, enrichissement - déménagement… -- Ajout de champ pour stocker le message de sortie (au moins en cas d'erreur) -- Paramettre de tolérance d'erreur -- 2 champs JSON, 1 context initial, 1 suggestion - -### Interface - -Si possible, utiliser l'interface d'administration de Django pour gérer les suggestions (cela devrait bien fonctionner au mons pour la partie `ingestion des sources`). - -- Division des interfaces de validation : - - `ingestion des sources` : nouvelles sources ou nouvelle version d'une source existante - - `enrichissements` : fermetures, démenagements, enrichissement avec annuaire-entrprise, l'API BAN ou d'autres API -- Ajout de filtre sur le statut (à traiter est sélectionné par défaut) -- Ajout de la pagination -- permettre de cocher les suggestions et d'executer une action our l'ensemble - -### Pipeline - -- Le DAG de validation des cohortes doit intégrer la même architecture que les autres DAGs - -# Cible - ## Systeme de Suggestion -Les suggestions sont crées par l'exécution d'un pipeline ou d'un script. Les suggestions sont faites par paquet qu'on appelle **Cohorte**, les Cohortes comprennent un ensemble de suggestion de mofification +Les suggestions sont créées par l'exécution d'un pipeline ou d'un script. Les suggestions sont faites par paquet qu'on appelle **Cohorte**, les Cohortes comprennent un ensemble de suggestions de modification Les cohortes ont un type d'événement : `clustering`, `enrichissement`, `source` selon le type de l'action lancée à l'origine de la suggestion de modification Les cohortes et les suggestions ont un statut de traitement qui représente leur cycle de vie : `à valider`, `rejeter`, `à traiter`, `en cours de traitement`, `fini avec succès`, `fini avec succès partiel` (uniquement pour les cohortes), `fini en erreur` -### Representation dans Django +### Représentation dans Django -- SuggestionCohorte représente les cohortes -- SuggestionUnitaire représente les propositions de modification +- SuggestionCohorte représente les cohortes, c'est à dire un ensemble de suggestions de la même nature +- Suggestion représente les propositions de modification ### Cycle de vie d'une suggestion diff --git a/qfdmo/views/dags.py b/qfdmo/views/dags.py index 4db1618a5..582b2a5e6 100644 --- a/qfdmo/views/dags.py +++ b/qfdmo/views/dags.py @@ -4,22 +4,15 @@ import logging -from django.contrib.auth.mixins import LoginRequiredMixin from django.core.paginator import Paginator from django.shortcuts import render from django.views.generic.edit import FormView +from core.views import IsStaffMixin from qfdmo.forms import DagsForm from qfdmo.models.data import DagRun, DagRunStatus -class IsStaffMixin(LoginRequiredMixin): - def dispatch(self, request, *args, **kwargs): - if not request.user.is_staff: - return self.handle_no_permission() - return super().dispatch(request, *args, **kwargs) - - class DagsValidation(IsStaffMixin, FormView): form_class = DagsForm template_name = "qfdmo/dags_validations.html"