diff --git a/lemarche/templates/admin/anonymize_confirmation.html b/lemarche/templates/admin/anonymize_confirmation.html new file mode 100644 index 000000000..15a2f617f --- /dev/null +++ b/lemarche/templates/admin/anonymize_confirmation.html @@ -0,0 +1,33 @@ +{% extends "admin/delete_selected_confirmation.html" %} +{% load admin_urls i18n %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} + +

Utilisateurs à anonymiser

+ {% for anonymizable_object in queryset %} + + {% endfor %} + +
{% csrf_token %} +
+ {% for obj in queryset %} + + {% endfor %} + + + + {% translate "No, take me back" %} +
+ +
+ +{% endblock %} diff --git a/lemarche/users/admin.py b/lemarche/users/admin.py index 4fe1f333c..99c9e0af0 100644 --- a/lemarche/users/admin.py +++ b/lemarche/users/admin.py @@ -5,7 +5,9 @@ from django.contrib.auth.admin import UserAdmin from django.contrib.contenttypes.admin import GenericTabularInline from django.db import models -from django.urls import reverse +from django.http import HttpResponseRedirect +from django.template.response import TemplateResponse +from django.urls import path, reverse from django.utils.html import format_html from fieldsets_with_inlines import FieldsetsInlineMixin @@ -211,6 +213,7 @@ class UserAdmin(FieldsetsInlineMixin, UserAdmin): search_fields = ["id", "email", "first_name", "last_name"] search_help_text = "Cherche sur les champs : ID, E-mail, Prénom, Nom" ordering = ["-created_at"] + actions = ["anonymize_users"] autocomplete_fields = ["company", "partner_network"] readonly_fields = ( @@ -357,6 +360,42 @@ def get_search_results(self, request, queryset, search_term): queryset = queryset.is_admin_bizdev() return queryset, use_distinct + def get_urls(self): + # https://docs.djangoproject.com/en/5.1/ref/contrib/admin/#django.contrib.admin.ModelAdmin.get_urls + urls = super().get_urls() + my_urls = [ + path("anonymise_users/", self.admin_site.admin_view(self.anonymize_users_view), name="anonymize_users"), + *urls, # these patterns last, because they can match a lot of urls + ] + return my_urls + + def anonymize_users_view(self, request): + """Confirmation page after selecting users to anonymize.""" + + if request.method == "GET": + # Display confirmation page + ids = request.GET.getlist("user_id") + queryset = self.model.objects.filter(id__in=ids) + context = { + # Include common variables for rendering the admin template. + **self.admin_site.each_context(request), + "opts": self.opts, + "queryset": queryset, + } + return TemplateResponse(request, "admin/anonymize_confirmation.html", context) + + if request.method == "POST": + # anonymize users + ids = request.POST.getlist("user_id") + queryset = self.model.objects.filter(id__in=ids) + + queryset.exclude(id=request.user.id).anonymize_update() + SiaeUser.objects.filter(user__is_anonymized=True).delete() + + self.message_user(request, "L'anonymisation s'est déroulée avec succès") + + return HttpResponseRedirect(reverse("admin:users_user_changelist")) + def save_formset(self, request, form, formset, change): """ Set Note author on create @@ -402,3 +441,14 @@ def extra_data_display(self, instance: User = None): return "-" extra_data_display.short_description = User._meta.get_field("extra_data").verbose_name + + @admin.action(description="Anonymiser les utilisateurs sélectionnés") + def anonymize_users(self, request, queryset): + """Wipe personal data of all selected users and unlink from SiaeUser + The logged user is excluded to avoid any mistakes""" + # https://docs.djangoproject.com/en/5.1/ref/contrib/admin/actions/#actions-that-provide-intermediate-pages + + selected = queryset.values_list("pk", flat=True) + return HttpResponseRedirect( + f"{reverse('admin:anonymize_users')}?{'&'.join(f'user_id={str(pk)}' for pk in selected)}" + ) diff --git a/lemarche/users/management/commands/anonymize_old_users.py b/lemarche/users/management/commands/anonymize_old_users.py index 90f70bb41..104bfe415 100644 --- a/lemarche/users/management/commands/anonymize_old_users.py +++ b/lemarche/users/management/commands/anonymize_old_users.py @@ -1,11 +1,7 @@ from dateutil.relativedelta import relativedelta from django.conf import settings -from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX -from django.contrib.postgres.functions import RandomUUID from django.core.management.base import BaseCommand from django.db import transaction -from django.db.models import F, Value -from django.db.models.functions import Concat from django.template import defaulttags from django.utils import timezone @@ -62,20 +58,8 @@ def anonymize_old_users(self, expiry_date: timezone.datetime, dry_run: bool): qs = User.objects.filter(last_login__lte=expiry_date, is_anonymized=False) users_to_update_count = qs.count() - qs.update( - is_active=False, # inactive users are allowed to log in standard login views - is_anonymized=True, - email=Concat(F("id"), Value("@domain.invalid")), - first_name="", - last_name="", - phone="", - api_key=None, - api_key_last_updated=None, - # https://docs.djangoproject.com/en/5.1/ref/contrib/auth/#django.contrib.auth.models.User.set_unusable_password - # Imitating the method but in sql. Prevent password reset attempt - # Random string is to avoid chances of impersonation by admins https://code.djangoproject.com/ticket/20079 - password=Concat(Value(UNUSABLE_PASSWORD_PREFIX), RandomUUID()), - ) + qs.anonymize_update() + # remove anonymized users in Siaes SiaeUser.objects.filter(user__is_anonymized=True).delete() diff --git a/lemarche/users/models.py b/lemarche/users/models.py index 0abd372a8..dde3b13d8 100644 --- a/lemarche/users/models.py +++ b/lemarche/users/models.py @@ -1,10 +1,12 @@ from django.conf import settings from django.contrib.auth.base_user import BaseUserManager +from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX from django.contrib.auth.models import AbstractUser from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.postgres.functions import RandomUUID from django.db import models -from django.db.models import Count -from django.db.models.functions import Greatest, Lower +from django.db.models import Count, F, Value +from django.db.models.functions import Concat, Greatest, Lower from django.db.models.signals import post_save, pre_save from django.dispatch import receiver from django.forms.models import model_to_dict @@ -61,6 +63,23 @@ def with_latest_activities(self): ) ) + def anonymize_update(self): + """Wipe or replace personal data stored on User model only""" + return self.update( + is_active=False, # inactive users are allowed to log in standard login views + is_anonymized=True, + email=Concat(F("id"), Value("@domain.invalid")), + first_name="", + last_name="", + phone="", + api_key=None, + api_key_last_updated=None, + # https://docs.djangoproject.com/en/5.1/ref/contrib/auth/#django.contrib.auth.models.User.set_unusable_password + # Imitating the method but in sql. Prevent password reset attempt + # Random string is to avoid chances of impersonation by admins https://code.djangoproject.com/ticket/20079 + password=Concat(Value(UNUSABLE_PASSWORD_PREFIX), RandomUUID()), + ) + class UserManager(BaseUserManager): """ diff --git a/lemarche/users/tests.py b/lemarche/users/tests.py index 321f7ed33..3463b2118 100644 --- a/lemarche/users/tests.py +++ b/lemarche/users/tests.py @@ -3,10 +3,12 @@ from unittest.mock import patch from dateutil.relativedelta import relativedelta +from django.contrib.messages import get_messages from django.core.management import call_command from django.core.validators import validate_email from django.db.models import F from django.test import TestCase, override_settings +from django.urls import reverse from django.utils import timezone from lemarche.companies.factories import CompanyFactory @@ -311,3 +313,36 @@ def test_dryrun_warn_command(self): call_command("anonymize_old_users", dry_run=True, stdout=self.std_out) self.assertFalse(TemplateTransactionalSendLog.objects.all()) + + +class UserAdminTestCase(TestCase): + def setUp(self): + UserFactory(is_staff=False, is_anonymized=False) + super_user = UserFactory(is_staff=True, is_superuser=True) + self.client.force_login(super_user) + + def test_anonymize_action(self): + """Test the anonymize_users action from the admin""" + + users_ids = User.objects.values_list("id", flat=True) + data = { + "action": "anonymize_users", + "_selected_action": users_ids, + } + # https://docs.djangoproject.com/en/5.1/ref/contrib/admin/#reversing-admin-urls + change_url = reverse("admin:users_user_changelist") + response = self.client.post(path=change_url, data=data) + + self.assertEqual(response.status_code, 302) + + data_confirm = {"user_id": users_ids} + + # click on confirm after seeing the confirmation page + response_confirm = self.client.post(response.url, data=data_confirm) + self.assertEqual(response.status_code, 302) + + self.assertTrue(User.objects.filter(is_staff=False).first().is_anonymized) + self.assertFalse(User.objects.filter(is_staff=True).first().is_anonymized) + + messages_strings = [str(message) for message in get_messages(response_confirm.wsgi_request)] + self.assertIn("L'anonymisation s'est déroulée avec succès", messages_strings)