Skip to content

Commit

Permalink
feat: Action admin pour l'anonymisation des utilisateurs (#1624)
Browse files Browse the repository at this point in the history
  • Loading branch information
Guilouf authored Jan 10, 2025
1 parent 393f227 commit 0dc2970
Show file tree
Hide file tree
Showing 5 changed files with 142 additions and 21 deletions.
33 changes: 33 additions & 0 deletions lemarche/templates/admin/anonymize_confirmation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{% extends "admin/delete_selected_confirmation.html" %}
{% load admin_urls i18n %}

{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; Anonymiser plusieurs utilisateurs
</div>
{% endblock %}

{% block content %}

<h2>Utilisateurs à anonymiser</h2>
{% for anonymizable_object in queryset %}
<ul>{{ anonymizable_object }}</ul>
{% endfor %}

<form method="post">{% csrf_token %}
<div>
{% for obj in queryset %}
<input type="hidden" name="user_id" value="{{ obj.pk }}">
{% endfor %}
<input type="hidden" name="action" value="delete_selected">
<input type="hidden" name="post" value="yes">
<input type="submit" value="{% translate 'Yes, I’m sure' %}">
<a href="#" class="button cancel-link">{% translate "No, take me back" %}</a>
</div>

</form>

{% endblock %}
52 changes: 51 additions & 1 deletion lemarche/users/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)}"
)
20 changes: 2 additions & 18 deletions lemarche/users/management/commands/anonymize_old_users.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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()

Expand Down
23 changes: 21 additions & 2 deletions lemarche/users/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down
35 changes: 35 additions & 0 deletions lemarche/users/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

0 comments on commit 0dc2970

Please sign in to comment.