Skip to content

Commit

Permalink
feat: Suppression des données personnelles (#1607)
Browse files Browse the repository at this point in the history
  • Loading branch information
Guilouf authored Jan 2, 2025
1 parent 4d65073 commit ec4fbbf
Show file tree
Hide file tree
Showing 13 changed files with 478 additions and 576 deletions.
6 changes: 6 additions & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,12 @@
# ------------------------------------------------------------------------------
INACTIVE_CONVERSATION_TIMEOUT_IN_MONTHS = env.int("INACTIVE_CONVERSATION_TIMEOUT_IN_MONTHS", 6)

# Privacy timeouts
# ------------------------------------------------------------------------------
INACTIVE_USER_TIMEOUT_IN_MONTHS = env.int("INACTIVE_USER_TIMEOUT_IN_MONTHS", 3 * 12)
INACTIVE_USER_WARNING_DELAY_IN_DAYS = env.int("INACTIVE_USER_WARNING_DELAY_IN_DAYS", 7)


# Wagtail
# ------------------------------------------------------------------------------

Expand Down
12 changes: 3 additions & 9 deletions lemarche/conversations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,6 @@ def get_template_id(self):
return self.brevo_id
return None

def create_send_log(self, **kwargs):
TemplateTransactionalSendLog.objects.create(template_transactional=self, **kwargs)

def send_transactional_email(
self,
recipient_email,
Expand All @@ -325,6 +322,9 @@ def send_transactional_email(
):
if self.is_active:
args = {
"template_transactional": self,
"recipient_content_object": recipient_content_object,
"parent_content_object": parent_content_object,
"template_id": self.get_template_id,
"recipient_email": recipient_email,
"recipient_name": recipient_name,
Expand All @@ -337,12 +337,6 @@ def send_transactional_email(
api_mailjet.send_transactional_email_with_template(**args)
elif self.source == conversation_constants.SOURCE_BREVO:
api_brevo.send_transactional_email_with_template(**args)
# create log
self.create_send_log(
recipient_content_object=recipient_content_object,
parent_content_object=parent_content_object,
extra_data={"source": self.source, "args": args}, # "response": result()
)


class TemplateTransactionalSendLog(models.Model):
Expand Down
60 changes: 27 additions & 33 deletions lemarche/users/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from django.contrib.contenttypes.admin import GenericTabularInline
from django.db import models
from django.urls import reverse
from django.utils.html import format_html, mark_safe
from django.utils.html import format_html
from fieldsets_with_inlines import FieldsetsInlineMixin

from lemarche.conversations.models import TemplateTransactionalSendLog
Expand Down Expand Up @@ -126,6 +126,31 @@ def queryset(self, request, queryset):
return queryset


class IsAnonymizedFilter(admin.SimpleListFilter):
"""Custom admin filter to target users who are anonymized"""

title = "Est anonymisé"
parameter_name = "is_anonymized"

def lookups(self, request, model_admin):
return ("Yes", "Oui"), (None, "Non")

def queryset(self, request, queryset):
value = self.value()
if value == "Yes":
return queryset.filter(is_anonymized=True)
return queryset.filter(is_anonymized=False)

def choices(self, changelist):
"""Removed the first yield from the base method to only have 2 choices, defaulting too No"""
for lookup, title in self.lookup_choices:
yield {
"selected": self.value() == lookup,
"query_string": changelist.get_query_string({self.parameter_name: lookup}),
"display": title,
}


class UserNoteInline(GenericTabularInline):
model = Note
fields = ["text", "author", "created_at", "updated_at"]
Expand Down Expand Up @@ -181,6 +206,7 @@ class UserAdmin(FieldsetsInlineMixin, UserAdmin):
HasApiKeyFilter,
"is_staff",
"is_superuser",
IsAnonymizedFilter,
]
search_fields = ["id", "email", "first_name", "last_name"]
search_help_text = "Cherche sur les champs : ID, E-mail, Prénom, Nom"
Expand All @@ -194,8 +220,6 @@ class UserAdmin(FieldsetsInlineMixin, UserAdmin):
"siae_count_annotated_with_link",
"tender_count_annotated_with_link",
"favorite_list_count_with_link",
"image_url",
"image_url_display",
"recipient_transactional_send_logs_count_with_link",
"brevo_contact_id",
"extra_data_display",
Expand Down Expand Up @@ -263,25 +287,6 @@ class UserAdmin(FieldsetsInlineMixin, UserAdmin):
"Permissions",
{"classes": ["collapse"], "fields": ("is_active", "is_staff", "is_superuser", "groups")},
),
(
"Données C4 Cocorico",
{
"classes": ["collapse"],
"fields": (
"c4_id",
"c4_website",
"c4_siret",
"c4_naf",
"c4_phone_prefix",
"c4_time_zone",
"c4_phone_verified",
"c4_email_verified",
"c4_id_card_verified",
"image_url",
"image_url_display",
),
},
),
(
"Stats",
{
Expand Down Expand Up @@ -383,17 +388,6 @@ def favorite_list_count_with_link(self, user):
favorite_list_count_with_link.short_description = "Nombre de listes de favoris"
favorite_list_count_with_link.admin_order_field = "favorite_list_count"

def image_url_display(self, user):
if user.image_url:
return mark_safe(
f'<a href="{user.image_url}" target="_blank">'
f'<img src="{user.image_url}" title="{user.image_url}" style="max-height:300px" />'
f"</a>"
)
return mark_safe("<div>-</div>")

image_url_display.short_description = "Image"

def recipient_transactional_send_logs_count_with_link(self, obj):
url = reverse("admin:conversations_templatetransactionalsendlog_changelist") + f"?user__id__exact={obj.id}"
return format_html(f'<a href="{url}">{obj.recipient_transactional_send_logs.count()}</a>')
Expand Down
114 changes: 114 additions & 0 deletions lemarche/users/management/commands/anonymize_old_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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

from lemarche.conversations.models import TemplateTransactional
from lemarche.siaes.models import SiaeUser
from lemarche.users.models import User


class DryRunException(Exception):
"""To be raised in a dry run mode to abort current transaction"""

pass


class Command(BaseCommand):
"""Update and anonymize inactive users past a defined inactivity period"""

def add_arguments(self, parser):
parser.add_argument(
"--month_timeout",
type=int,
default=settings.INACTIVE_USER_TIMEOUT_IN_MONTHS,
help="Délai en mois à partir duquel les utilisateurs sont considérés inactifs",
)
parser.add_argument(
"--warning_delay",
type=int,
default=settings.INACTIVE_USER_WARNING_DELAY_IN_DAYS,
help="Délai en jours avant la date de suppression pour prevenir les utilisateurs",
)
parser.add_argument(
"--dry_run",
type=bool,
default=False,
help="La commande est exécutée sans que les modifications soient transmises à la base",
)

def handle(self, *args, **options):
expiry_date = timezone.now() - relativedelta(months=options["month_timeout"])
warning_date = expiry_date + relativedelta(days=options["warning_delay"])

try:
self.anonymize_old_users(expiry_date=expiry_date, dry_run=options["dry_run"])
except DryRunException:
self.stdout.write("Fin du dry_run d'anonymisation")

self.warn_users_by_email(expiry_date=expiry_date, warning_date=warning_date, dry_run=options["dry_run"])

@transaction.atomic
def anonymize_old_users(self, expiry_date: timezone.datetime, dry_run: bool):
"""Update inactive users since x months and strip them from their personal data.
email is unique and not nullable, therefore it's replaced with the object id."""

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()),
)
# remove anonymized users in Siaes
SiaeUser.objects.filter(user__is_anonymized=True).delete()

self.stdout.write(f"Utilisateurs anonymisés avec succès ({users_to_update_count} traités)")

if dry_run: # cancel transaction
raise DryRunException

@transaction.atomic
def warn_users_by_email(self, warning_date: timezone.datetime, expiry_date: timezone.datetime, dry_run: bool):
email_template = TemplateTransactional.objects.get(code="USER_ANONYMIZATION_WARNING")

# Users that have already received the mail are excluded
users_to_warn = User.objects.filter(last_login__lte=warning_date, is_active=True, is_anonymized=False).exclude(
recipient_transactional_send_logs__template_transactional__code=email_template.code,
recipient_transactional_send_logs__extra_data__contains={"sent": True},
)

if dry_run:
self.stdout.write(
f"Fin du dry_run d'avertissement des utilisateurs, {users_to_warn.count()} auraient été avertis"
)
return # exit before sending emails

for user in users_to_warn:
email_template.send_transactional_email(
recipient_email=user.email,
recipient_name=user.full_name,
variables={
"user_full_name": user.full_name,
"anonymization_date": defaulttags.date(expiry_date), # natural date
},
recipient_content_object=user,
)

self.stdout.write(f"Un email d'avertissement a été envoyé à {users_to_warn.count()} utilisateurs")
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 4.2.15 on 2024-12-23 17:02

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("users", "0039_remove_user_user_email_ci_uniqueness_and_more"),
]

operations = [
migrations.RemoveField(
model_name="user",
name="c4_email_verified",
),
migrations.RemoveField(
model_name="user",
name="c4_id",
),
migrations.RemoveField(
model_name="user",
name="c4_id_card_verified",
),
migrations.RemoveField(
model_name="user",
name="c4_naf",
),
migrations.RemoveField(
model_name="user",
name="c4_phone_prefix",
),
migrations.RemoveField(
model_name="user",
name="c4_phone_verified",
),
migrations.RemoveField(
model_name="user",
name="c4_siret",
),
migrations.RemoveField(
model_name="user",
name="c4_time_zone",
),
migrations.RemoveField(
model_name="user",
name="c4_website",
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 4.2.15 on 2024-12-24 09:04

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("users", "0040_remove_user_c4_email_verified_remove_user_c4_id_and_more"),
]

operations = [
migrations.RemoveField(
model_name="user",
name="image_name",
),
migrations.RemoveField(
model_name="user",
name="image_url",
),
]
30 changes: 30 additions & 0 deletions lemarche/users/migrations/0042_email_template_anonymise_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from django.db import migrations


def create_template(apps, schema_editor):
TemplateTransactional = apps.get_model("conversations", "TemplateTransactional")
TemplateTransactional.objects.create(
name="Avertissement d'anonymisation de compte utilisateur",
code="USER_ANONYMIZATION_WARNING",
description="""
Bonjour {{ params.user_full_name }}, votre compte va être supprimé le {{ params.anonymization_date }}
si vous ne vous connectez pas avant.
Bonne journée,
L'équipe du marché de l'inclusion
""",
)


def delete_template(apps, schema_editor):
TemplateTransactional = apps.get_model("conversations", "TemplateTransactional")
TemplateTransactional.objects.get(code="USER_ANONYMIZATION_WARNING").delete()


class Migration(migrations.Migration):
dependencies = [
("users", "0041_remove_user_image_name_remove_user_image_url"),
]

operations = [
migrations.RunPython(create_template, reverse_code=delete_template),
]
17 changes: 17 additions & 0 deletions lemarche/users/migrations/0043_user_is_anonymized.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.15 on 2024-12-26 09:23

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("users", "0042_email_template_anonymise_user"),
]

operations = [
migrations.AddField(
model_name="user",
name="is_anonymized",
field=models.BooleanField(default=False, verbose_name="L'utilisateur à été anonymisé"),
),
]
Loading

0 comments on commit ec4fbbf

Please sign in to comment.