Skip to content

Commit c9255f5

Browse files
Merge pull request #1877 from betagouv/feat/1823-auto-warn-delete-accounts
#1823 suppression automatique des comptes inactifs et avertissements
2 parents 2a0d32b + 20d8569 commit c9255f5

21 files changed

Lines changed: 813 additions & 72 deletions

recoco/apps/communication/constants.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
TPL_ADVISOR_ACCESS_REQUEST_ACCEPTED = "advisor_access_request_accepted"
1313
TPL_ADVISOR_ACCESS_REQUEST_REFUSED = "advisor_access_request_refused"
1414
TPL_MESSAGES_DIGEST = "messages_digest"
15+
TPL_RGDP_DELETION_FIRST_WARNING = "rgpd_deletion_first_warning"
16+
TPL_RGDP_DELETION_SECOND_WARNING = "rgpd_deletion_second_warning"
1517

1618
TPL_CHOICES = (
1719
(TPL_PROJECT_RECEIVED, "Dossier bien reçu"),
@@ -34,4 +36,12 @@
3436
(TPL_ADVISOR_ACCESS_REQUEST_ACCEPTED, "Demande d'accès conseillers acceptée"),
3537
(TPL_ADVISOR_ACCESS_REQUEST_REFUSED, "Demande d'accès conseillers refusée"),
3638
(TPL_MESSAGES_DIGEST, "Résumé des messages reçus (nouvelle conv')"),
39+
(
40+
TPL_RGDP_DELETION_FIRST_WARNING,
41+
"Premier avertissement avant suppression pour inactivité",
42+
),
43+
(
44+
TPL_RGDP_DELETION_SECOND_WARNING,
45+
"Second et dernier avertissement avant suppression pour inactivité",
46+
),
3747
)

recoco/apps/crm/templates/crm/user_details.html

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,13 @@ <h5 class="d-flex align-items-center fr-mb-0">
116116
</div>
117117
</div>
118118
<div class="d-flex align-items-end flex-column">
119-
<div class="btn-group btn-group-sm">
120-
<a href="{% url 'crm-user-update' crm_user.pk %}"
121-
class="btn btn-outline-secondary"
122-
aria-current="page">Gérer l'utilisateur·rice</a>
123-
</div>
119+
{% if not crm_user.profile.deleted %}
120+
<div class="btn-group btn-group-sm">
121+
<a href="{% url 'crm-user-update' crm_user.pk %}"
122+
class="btn btn-outline-secondary"
123+
aria-current="page">Gérer l'utilisateur·rice</a>
124+
</div>
125+
{% endif %}
124126
<div class="d-flex align-items-center">
125127
<svg class="bi" width="16" height="16" fill="currentColor">
126128
<use xlink:href="{% static 'svg/bootstrap-icons.svg'  %}#bell-fill" />
@@ -166,6 +168,7 @@ <h5 class="d-flex align-items-center fr-mb-0">
166168
</div>
167169
</div>
168170
{% if not crm_user.is_active %}
171+
{% if crm_user.profile.disabled %}
169172
<div class="alert alert-danger fr-mt-3w" role="alert">
170173
<span>Ce compte a été suspendu. Ceci signifie que la personne ne
171174
peut plus se connecter à {{ request.site.name }}.
@@ -177,6 +180,13 @@ <h5 class="d-flex align-items-center fr-mb-0">
177180
</form>
178181
</div>
179182
</div>
183+
{% else %}
184+
<div class="alert alert-danger fr-mt-3w" role="alert">
185+
<span>Ce compte a été supprimé. Ceci signifie que la personne ne
186+
peut plus se connecter à {{ request.site.name }}.
187+
</div>
188+
</div>
189+
{% endif %}
180190
{% endif %}
181191
{% if crm_user.profile.organization %}
182192
<div class="d-flex justify-content-between align-items-start">
@@ -190,16 +200,18 @@ <h5 class="d-flex align-items-center fr-mb-0">
190200
</div>
191201
{% endif %}
192202
</div>
193-
<div class="fr-px-3w fr-pt-3w crm-notes-wrapper relative">
194-
<a class="btn btn-primary fr-mb-3w"
195-
href="{% url 'crm-user-note-create' crm_user.pk %}">créer une note</a>
196-
{% for note in sticky_notes.all %}
197-
{% include "crm/note.html" with pinned=True %}
198-
{% endfor %}
199-
{% for note in notes.all %}
200-
{% include "crm/note.html" %}
201-
{% endfor %}
202-
</div>
203+
{% if not crm_user.profile.deleted %}
204+
<div class="fr-px-3w fr-pt-3w crm-notes-wrapper relative">
205+
<a class="btn btn-primary fr-mb-3w"
206+
href="{% url 'crm-user-note-create' crm_user.pk %}">créer une note</a>
207+
{% for note in sticky_notes.all %}
208+
{% include "crm/note.html" with pinned=True %}
209+
{% endfor %}
210+
{% for note in notes.all %}
211+
{% include "crm/note.html" %}
212+
{% endfor %}
213+
</div>
214+
{% endif %}
203215
<div class="fr-px-3w fr-pt-3w bg-light crm-timeline-min-height">
204216
<h4>Activité</h4>
205217
{% include "crm/timeline.html" %}

recoco/apps/crm/templates/crm/user_update.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ <h3>Gérer le compte « {{ crm_user.username }} »</h3>
2525
<a href="{% url 'crm-user-deactivate' crm_user.pk %}"
2626
class="btn btn-danger">Suspendre ce compte</a>
2727
</div>
28+
{% elif crm_user.profile.deleted %}
29+
<div class="alert alert-danger" role="alert">
30+
Ce compte a été supprimé. Ceci signifie que la personne ne peut plus se connecter à {{ request.site.name }}.
31+
</div>
2832
{% else %}
2933
<div class="alert alert-danger" role="alert">
3034
Ce compte a été suspendu. Ceci signifie que la personne ne peut plus se connecter à {{ request.site.name }}.

recoco/apps/crm/tests/test_user.py

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from datetime import datetime
2+
13
import django.core.mail
24
import pytest
35
from allauth.account.models import EmailAddress
@@ -410,6 +412,51 @@ def test_crm_user_update_profile_information_and_email_address(request, client):
410412
assert "Confirmez votre adresse email" in django.core.mail.outbox[0].subject
411413

412414

415+
@pytest.mark.django_db
416+
def test_crm_user_update_cant_update_disabled_user(request, client):
417+
site = get_current_site(request)
418+
419+
organization = baker.make(addressbook_models.Organization)
420+
421+
end_user = baker.make(auth_models.User, is_active=False)
422+
end_user.profile.sites.add(site)
423+
profile = end_user.profile
424+
profile.deleted = datetime(2022, 12, 12)
425+
profile.save()
426+
427+
url = reverse("crm-user-update", args=[end_user.id])
428+
data = {
429+
"username": "johndoe@example.org",
430+
"first_name": "John",
431+
"last_name": "DOE",
432+
"phone_no": "01 23 45 67 89",
433+
"organization": organization.id,
434+
"organization_position": "staff",
435+
}
436+
437+
with login(client) as user:
438+
assign_perm("use_crm", user, site)
439+
client.post(url, data=data)
440+
441+
# user data is updated
442+
end_user.refresh_from_db()
443+
444+
assert end_user.username != data["username"]
445+
assert end_user.email != data["username"]
446+
assert end_user.first_name != data["first_name"]
447+
assert end_user.last_name != data["last_name"]
448+
449+
# profile is updated
450+
profile.refresh_from_db()
451+
452+
assert profile.phone_no != data["phone_no"]
453+
assert profile.organization != organization
454+
assert profile.organization_position != data["organization_position"]
455+
456+
# the confirmation email has been sent
457+
assert len(django.core.mail.outbox) == 0
458+
459+
413460
@pytest.mark.django_db
414461
def test_crm_user_update_profile_information_with_email_address_exists(request, client):
415462
site = get_current_site(request)
@@ -569,7 +616,8 @@ def test_crm_user_deactivate_processing(request, client):
569616
# user data is updated
570617
end_user.refresh_from_db()
571618
assert end_user.is_active is False
572-
assert end_user.profile.deleted is not None
619+
assert end_user.profile.deleted is None
620+
assert end_user.profile.disabled is not None
573621

574622

575623
########################################################################
@@ -623,7 +671,35 @@ def test_crm_user_reactivate_processing(request, client):
623671
# user data is updated
624672
end_user.refresh_from_db()
625673
assert end_user.is_active is True
626-
assert end_user.profile.deleted is None
674+
assert end_user.profile.disabled is None
675+
676+
677+
@pytest.mark.django_db
678+
def test_crm_user_reactivate_only_active(request, client):
679+
site = get_current_site(request)
680+
681+
disabled = datetime(2025, 9, 12)
682+
deleted = datetime(2026, 1, 16)
683+
684+
end_user = baker.make(auth_models.User, is_active=False)
685+
end_user.profile.sites.add(site)
686+
end_user.profile.disabled = disabled
687+
end_user.profile.deleted = deleted
688+
end_user.profile.save()
689+
690+
url = reverse("crm-user-reactivate", args=[end_user.id])
691+
692+
with login(client) as user:
693+
assign_perm("use_crm", user, site)
694+
response = client.post(url)
695+
696+
assert response.status_code == 400
697+
698+
# user data is updated
699+
end_user.refresh_from_db()
700+
assert end_user.is_active is False
701+
assert end_user.profile.disabled is not None
702+
assert end_user.profile.deleted is not None
627703

628704

629705
########################################################################

recoco/apps/crm/views.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from django.contrib.syndication.views import Feed
3030
from django.core.cache import cache
3131
from django.core.cache.utils import make_template_fragment_key
32+
from django.core.exceptions import BadRequest
3233
from django.db import transaction
3334
from django.db.models import (
3435
Count,
@@ -74,6 +75,7 @@
7475
make_group_name_for_site,
7576
)
7677

78+
from ..home.utils import deactivate_user, reactivate_user
7779
from . import filters, forms, models
7880
from .forms import SiteConfigurationForm
7981

@@ -464,9 +466,9 @@ def user_list(request):
464466
# filtered users
465467
users = filters.UserFilter(
466468
request.GET,
467-
queryset=User.objects.filter(profile__sites=request.site).prefetch_related(
468-
"profile__organization"
469-
),
469+
queryset=User.objects.filter(
470+
profile__sites=request.site, profile__deleted__isnull=True
471+
).prefetch_related("profile__organization"),
470472
)
471473

472474
# required by default on crm
@@ -488,6 +490,8 @@ def user_update(request, user_id=None):
488490
if request.method == "POST":
489491
form = forms.CRMProfileForm(request.POST, instance=profile)
490492
if form.is_valid():
493+
if crm_user.profile.deleted:
494+
form.add_error(None, "Cannot update a deleted user")
491495
with transaction.atomic():
492496
username = form.cleaned_data.get("username")
493497
email_changed = username != crm_user.username
@@ -566,11 +570,7 @@ def user_deactivate(request, user_id=None):
566570
crm_user = get_object_or_404(User, pk=user_id, profile__sites=request.site)
567571

568572
if request.method == "POST":
569-
crm_user.is_active = False
570-
crm_user.save()
571-
profile = crm_user.profile
572-
profile.deleted = timezone.now()
573-
profile.save()
573+
deactivate_user(crm_user)
574574
return redirect(reverse("crm-user-details", args=[crm_user.id]))
575575

576576
# required by default on crm
@@ -586,11 +586,9 @@ def user_reactivate(request, user_id=None):
586586
crm_user = get_object_or_404(User, pk=user_id, profile__sites=request.site)
587587

588588
if request.method == "POST":
589-
crm_user.is_active = True
590-
crm_user.save()
591-
profile = crm_user.profile
592-
profile.deleted = None
593-
profile.save()
589+
if crm_user.profile.deleted:
590+
raise BadRequest("Cannot reactivate a deleted user")
591+
reactivate_user(crm_user)
594592
return redirect(reverse("crm-user-details", args=[crm_user.id]))
595593

596594
# required by default on crm
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from datetime import timedelta
2+
3+
from django.core.management import BaseCommand
4+
from django.utils import timezone
5+
from tqdm import tqdm
6+
7+
from recoco.apps.home.models import UserProfile
8+
from recoco.apps.home.utils import (
9+
DELETION_ABSENT_FOR_DAYS,
10+
FIRST_WARNING_DAYS_BEFORE,
11+
SECOND_WARNING_DAYS_BEFORE,
12+
delete_user,
13+
send_deletion_warning_to_profiles,
14+
)
15+
16+
17+
class Command(BaseCommand):
18+
help = "Warn and delete users that have been absent for too long"
19+
20+
def add_arguments(self, parser):
21+
parser.add_argument(
22+
"-d", "--dry-run", action="store_true", help="Do not actually send stuff"
23+
)
24+
25+
def warn_and_delete(self, dry_run):
26+
now = timezone.now()
27+
so_long = now - timedelta(days=DELETION_ABSENT_FOR_DAYS)
28+
since_second_warning = now - timedelta(days=SECOND_WARNING_DAYS_BEFORE)
29+
since_first_warning = now - timedelta(
30+
days=FIRST_WARNING_DAYS_BEFORE - SECOND_WARNING_DAYS_BEFORE
31+
)
32+
33+
not_deleted_profiles = UserProfile.all.filter(
34+
deleted=None
35+
) # we keep disabled users
36+
37+
# resets warning of users who used the platform lately
38+
not_deleted_profiles.filter(
39+
previous_activity_at__gte=since_second_warning, nb_deletion_warnings__gt=0
40+
).update(nb_deletion_warnings=0, previous_deletion_warning_at=None)
41+
42+
# first warning
43+
to_first_warn_profiles = not_deleted_profiles.filter(
44+
previous_activity_at__lt=so_long, nb_deletion_warnings=0
45+
)
46+
self.stdout.write(
47+
f"Warning {to_first_warn_profiles.count()} users for the first time"
48+
)
49+
if dry_run:
50+
self.stdout.write("dry run: no email sent")
51+
else:
52+
send_deletion_warning_to_profiles(to_first_warn_profiles, 1)
53+
to_first_warn_profiles.update(
54+
nb_deletion_warnings=1, previous_deletion_warning_at=timezone.now()
55+
)
56+
57+
# second warning
58+
to_second_warn_profiles = not_deleted_profiles.filter(
59+
previous_deletion_warning_at__lt=since_first_warning, nb_deletion_warnings=1
60+
)
61+
self.stdout.write(
62+
f"Warning {to_second_warn_profiles.count()} users for the second time"
63+
)
64+
if dry_run:
65+
self.stdout.write("dry run: no email sent")
66+
else:
67+
send_deletion_warning_to_profiles(to_second_warn_profiles, 2)
68+
to_second_warn_profiles.update(
69+
nb_deletion_warnings=2, previous_deletion_warning_at=timezone.now()
70+
)
71+
72+
# actual deletion
73+
to_delete = not_deleted_profiles.filter(
74+
previous_deletion_warning_at__lt=since_second_warning,
75+
nb_deletion_warnings=2,
76+
)
77+
self.stdout.write(f"Deleting {to_delete.count()} users")
78+
if dry_run:
79+
self.stdout.write("dry run: no account deleted")
80+
else:
81+
for profile in tqdm(to_delete):
82+
delete_user(profile.user)
83+
84+
def handle(self, *args, **options):
85+
dry_run = options["dry_run"]
86+
self.warn_and_delete(dry_run)

recoco/apps/home/middlewares.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
from datetime import timedelta
2+
3+
import sentry_sdk
14
from django.core.exceptions import ImproperlyConfigured
25
from django.http import HttpRequest
6+
from django.utils import timezone
37

48
from recoco.apps.home.models import SiteConfiguration
59

@@ -25,3 +29,27 @@ def __call__(self, request: HttpRequest):
2529
)
2630

2731
return self.get_response(request)
32+
33+
34+
class PreviousActivityMiddleware:
35+
def __init__(self, get_response):
36+
self.get_response = get_response
37+
38+
def __call__(self, request: HttpRequest):
39+
now = timezone.now()
40+
if (
41+
request.user.is_authenticated
42+
and not request.user.is_hijacked
43+
and (
44+
request.user.profile.previous_activity_at is None
45+
or request.user.profile.previous_activity_at < now + timedelta(days=1)
46+
)
47+
):
48+
try:
49+
request.user.profile.previous_activity_at = now
50+
request.user.profile.previous_activity_site = request.site
51+
request.user.profile.save()
52+
except Exception as e:
53+
sentry_sdk.capture_exception(e)
54+
55+
return self.get_response(request)

0 commit comments

Comments
 (0)