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 %}
+ {{ anonymizable_object }}
+ {% endfor %}
+
+
+
+{% 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)