Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Suppression des données personnelles #1607

Merged
merged 34 commits into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e8a526c
Command skeleton
Guilouf Dec 23, 2024
87104b7
Set inactive testcase
Guilouf Dec 23, 2024
259c1da
Anonymize user data
Guilouf Dec 23, 2024
c9b4ee5
Anonymize user command
Guilouf Dec 23, 2024
f5d37bb
black
Guilouf Dec 23, 2024
daa34af
Test command anonymize_old_users
Guilouf Dec 23, 2024
c86d45e
Remove old c4 user fields
Guilouf Dec 23, 2024
bb99706
Remove old c4 user fields
Guilouf Dec 23, 2024
970c0d2
Unusable password set in sql
Guilouf Dec 23, 2024
ccb242b
Use relativedelta
Guilouf Dec 23, 2024
f36c10b
Set timeouts as global vars
Guilouf Dec 24, 2024
c149b95
Remove image_name and image_url fields from User model
Guilouf Dec 24, 2024
95d8546
Useless boto file ?
Guilouf Dec 24, 2024
3667032
Send email to warn users about to be anonymized
Guilouf Dec 24, 2024
8789574
Email is simply replaced by id
Guilouf Dec 24, 2024
6ce00bd
Dry run for anonymization
Guilouf Dec 24, 2024
3254834
Dry run for email warning
Guilouf Dec 24, 2024
ab96261
type hint
Guilouf Dec 24, 2024
6b66588
Override settings
Guilouf Dec 24, 2024
58965a4
Migrate users last_login date to avoid unwarned delete
Guilouf Dec 24, 2024
37ed2cc
Refactor patch
Guilouf Dec 24, 2024
b351a9f
Refactor stdout
Guilouf Dec 24, 2024
237bf17
precommit clean
Guilouf Dec 26, 2024
f02be51
Add is_anonymized flag
Guilouf Dec 26, 2024
1e68ee7
Unlink anonymized user from siaes
Guilouf Dec 26, 2024
783bbc1
Remove last_login migration
Guilouf Dec 26, 2024
4d2460e
Remove last_login migration
Guilouf Dec 26, 2024
761cf50
Format email field so it's valid, but the domain extension is not exi…
Guilouf Dec 26, 2024
3bdb614
test action
Guilouf Dec 26, 2024
ee54367
test action
Guilouf Dec 26, 2024
daa98f6
Mail log creation on task end
Guilouf Dec 27, 2024
7f68ef0
Mail log creation on task end
Guilouf Dec 27, 2024
95dc81f
Admin filter for anonymized users, defaulting to not anonymized
Guilouf Dec 30, 2024
7fd0b73
Fix circular import
Guilouf Dec 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
madjid-asa marked this conversation as resolved.
Show resolved Hide resolved

@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
Loading