From a911d8fffd880b33073c5ad08b4a0c5dcd108ca9 Mon Sep 17 00:00:00 2001
From: ikarius
Date: Thu, 29 Feb 2024 15:39:44 +0100
Subject: [PATCH] Notifications : utilisateurs actifs sans structure (CAT 4)
(#239)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
https://trello.com/c/izUxi3lU/1173-notifications-personnes-inscrites-mais-non-rattach%C3%A9es-%C3%A0-une-structure-cat-4
Envoi de notifications pour les utilisateurs non rattachés à une
structure et sans rattachement en attente.
4 notifications, puis suppression du compte.
---
dora/core/templates/email-base.mjml | 4 +-
dora/notifications/enums.py | 3 +
dora/notifications/tasks/__init__.py | 3 +
dora/notifications/tasks/tests/tests_users.py | 117 ++++++++++++++++++
dora/notifications/tasks/users.py | 82 ++++++++++++
dora/users/emails.py | 32 +++++
.../notification_user_without_structure.mjml | 34 +++++
...ation_user_without_structure_deletion.mjml | 28 +++++
dora/users/tests/test_emails.py | 27 +++-
9 files changed, 327 insertions(+), 3 deletions(-)
create mode 100644 dora/notifications/tasks/tests/tests_users.py
create mode 100644 dora/notifications/tasks/users.py
create mode 100644 dora/users/templates/notification_user_without_structure.mjml
create mode 100644 dora/users/templates/notification_user_without_structure_deletion.mjml
diff --git a/dora/core/templates/email-base.mjml b/dora/core/templates/email-base.mjml
index 95f1f32e..ca9402a4 100644
--- a/dora/core/templates/email-base.mjml
+++ b/dora/core/templates/email-base.mjml
@@ -78,8 +78,8 @@
{% endblock %}
- {% block post_cta %}
- {% endblock %}
+ {% block post_cta %}
+ {% endblock %}
{% block signature %}
diff --git a/dora/notifications/enums.py b/dora/notifications/enums.py
index 5cfd7a7c..89b884d2 100644
--- a/dora/notifications/enums.py
+++ b/dora/notifications/enums.py
@@ -12,6 +12,9 @@ class TaskType(models.TextChoices):
SERVICE_ACTIVATION = "service_activation"
INVITED_USERS = "invited_users"
SELF_INVITED_USERS = "self_invited_users"
+ USERS_WITHOUT_STRUCTURE = "users_without_structure"
+
+ ...
# catch-all: pour des cas de tests, ou "one-shot"
GENERIC_TASK = "generic_task"
diff --git a/dora/notifications/tasks/__init__.py b/dora/notifications/tasks/__init__.py
index 802d1f29..f906090c 100644
--- a/dora/notifications/tasks/__init__.py
+++ b/dora/notifications/tasks/__init__.py
@@ -1,2 +1,5 @@
from . import invitations # noqa
from . import structures # noqa
+from . import users # noqa
+
+# imports utilisés pour l'activation des tâches de notification (effet de bord)
diff --git a/dora/notifications/tasks/tests/tests_users.py b/dora/notifications/tasks/tests/tests_users.py
new file mode 100644
index 00000000..3ca82813
--- /dev/null
+++ b/dora/notifications/tasks/tests/tests_users.py
@@ -0,0 +1,117 @@
+import uuid
+
+import pytest
+from dateutil.relativedelta import relativedelta
+from django.core import mail
+from django.utils import timezone
+from freezegun import freeze_time
+
+from dora.core.test_utils import make_structure, make_user
+from dora.notifications.models import Notification
+from dora.users.models import User
+
+from ..core import Task
+from ..users import UsersWithoutStructureTask
+
+
+@pytest.fixture
+def user_without_structure_task():
+ return UsersWithoutStructureTask()
+
+
+def test_user_without_structure_task_is_registered():
+ assert UsersWithoutStructureTask in Task.registered_tasks()
+
+
+def test_user_without_structure_task_candidates(user_without_structure_task):
+ # pas d'id IC
+ nok_user = make_user()
+ assert nok_user not in user_without_structure_task.candidates()
+
+ # adresse e-mail non validée
+ nok_user = make_user(is_valid=False)
+ assert nok_user not in user_without_structure_task.candidates()
+
+ # utilisateurs incrits depuis plus de 4 mois exclus
+ nok_user = make_user(
+ ic_id=uuid.uuid4(), date_joined=timezone.now() - relativedelta(months=4, days=1)
+ )
+ assert nok_user not in user_without_structure_task.candidates()
+
+ # membres de structure exclus
+ nok_user = make_user(structure=make_structure(), ic_id=uuid.uuid4())
+ assert nok_user not in user_without_structure_task.candidates()
+
+ # utilisateurs invités exclus
+ nok_user = make_user(ic_id=uuid.uuid4())
+ make_structure(putative_member=nok_user)
+ assert nok_user not in user_without_structure_task.candidates()
+
+ # candidat potentiel
+ ok_user = make_user(ic_id=uuid.uuid4())
+ assert ok_user in user_without_structure_task.candidates()
+
+
+def test_user_without_structure_task_should_trigger(user_without_structure_task):
+ user = make_user(ic_id=uuid.uuid4())
+
+ # première notification à +1j
+ ok, _, _ = user_without_structure_task.run()
+ assert not ok
+
+ notification = Notification.objects.first()
+ assert notification.is_pending
+
+ now = timezone.now()
+
+ for cnt, day in enumerate((1, 5, 10, 15), 1):
+ with freeze_time(now + relativedelta(days=day)):
+ ok, _, _ = user_without_structure_task.run()
+ assert ok
+
+ notification.refresh_from_db()
+ assert notification.counter == cnt
+ assert notification.is_pending
+
+ # on vérifie qu'un e-mail est bien envoyé
+ # testé plus en détails dans la partie e-mail
+ assert len(mail.outbox) == cnt
+ assert mail.outbox[cnt - 1].to == [user.email]
+
+ # le contenu de l'e-mail est différent pour la dernière notification
+ match cnt:
+ case 4:
+ assert (
+ mail.outbox[cnt - 1].subject
+ == "Dernier rappel avant suppression"
+ )
+ case _:
+ assert (
+ mail.outbox[cnt - 1].subject
+ == "Rappel : Identifiez votre structure sur DORA"
+ )
+
+ with freeze_time(now + relativedelta(days=day + 1)):
+ ok, _, _ = user_without_structure_task.run()
+ assert not ok
+
+ notification.refresh_from_db()
+ assert notification.counter == cnt
+ assert notification.is_pending
+
+ notification.refresh_from_db()
+ assert notification.counter == 4
+ assert notification.is_pending
+
+ # on teste la dernière iteration de la notification (+4 mois)
+ with freeze_time(now + relativedelta(months=4)):
+ ok, _, _ = user_without_structure_task.run()
+ assert ok
+
+ # la notification ne doit plus exister (l'utilisateur propriétaire a été supprimé)
+ with pytest.raises(Notification.DoesNotExist):
+ notification.refresh_from_db()
+
+ # le compte utilisateur doit avoir été supprimé
+ with pytest.raises(User.DoesNotExist):
+ user.refresh_from_db()
diff --git a/dora/notifications/tasks/users.py b/dora/notifications/tasks/users.py
new file mode 100644
index 00000000..33af8cab
--- /dev/null
+++ b/dora/notifications/tasks/users.py
@@ -0,0 +1,82 @@
+from dateutil.relativedelta import relativedelta
+from django.utils import timezone
+
+from dora.notifications.enums import TaskType
+from dora.notifications.models import Notification
+from dora.users.emails import send_user_without_structure_notification
+from dora.users.models import User
+
+from .core import Task, TaskError
+
+"""
+Users :
+ Notifications ayant pour candidats des utilisateurs "en direct",
+ c.a.d. sans passer par l'intermédiaire d'un objet tiers,
+ comme une invitation ou une structure.
+"""
+
+
+class UsersWithoutStructureTask(Task):
+ @classmethod
+ def task_type(cls):
+ return TaskType.USERS_WITHOUT_STRUCTURE
+
+ @classmethod
+ def candidates(cls):
+ return User.objects.exclude(ic_id=None).filter(
+ is_valid=True,
+ membership=None,
+ putative_membership=None,
+ date_joined__gt=timezone.now() - relativedelta(months=4, days=1),
+ )
+
+ @classmethod
+ def should_trigger(cls, notification: Notification) -> bool:
+ now = timezone.now()
+ match notification.counter:
+ case 0:
+ return notification.created_at + relativedelta(days=1) <= now
+ case 1:
+ return notification.created_at + relativedelta(days=5) <= now
+ case 2:
+ return notification.created_at + relativedelta(days=10) <= now
+ case 3:
+ return notification.created_at + relativedelta(days=15) <= now
+ case 4:
+ return notification.created_at + relativedelta(months=4) <= now
+ case _:
+ return False
+
+ @classmethod
+ def process(cls, notification: Notification):
+ match notification.counter:
+ case 0 | 1 | 2 | 3:
+ try:
+ send_user_without_structure_notification(
+ notification.owner_user, deletion=notification.counter == 3
+ )
+ except Exception as ex:
+ raise TaskError(
+ f"Erreur d'envoi du mail pour un utilisateur sans structure ({notification}) : {ex}"
+ ) from ex
+ case 4:
+ notification.complete()
+ # action : voir post_process
+ case _:
+ raise TaskError(f"État du compteur incohérent ({notification})")
+
+ @classmethod
+ def post_process(cls, notification: Notification):
+ print("PP")
+ if notification.is_complete:
+ print("PP:deleting")
+ user = notification.owner_user
+ # suppression du compte utilisateur associé si :
+ # - aucune autre invitation
+ # - non membre d'une structure
+ if not user.putative_membership.count() and not user.membership.count():
+ user.delete()
+ # à ce point, la notification doit aussi être détruite (CASCADE)...
+
+
+Task.register(UsersWithoutStructureTask)
diff --git a/dora/users/emails.py b/dora/users/emails.py
index a3f67870..6b8d372f 100644
--- a/dora/users/emails.py
+++ b/dora/users/emails.py
@@ -23,3 +23,35 @@ def send_invitation_reminder(user, structure):
user.email,
mjml2html(render_to_string("invitation_reminder.mjml", context)),
)
+
+
+def send_user_without_structure_notification(user, deletion=False):
+ # même notification et contexte mais template différent si dernier rappel
+ cta_link = furl(settings.FRONTEND_URL) / "auth" / "rattachement"
+ cta_link.add(
+ {
+ "login_hint": iri_to_uri(user.email),
+ "mtm_campaign": "MailsTransactionnels",
+ "mtm_keyword": "InscritSansStructure",
+ }
+ )
+ context = {
+ "user": user,
+ "cta_link": cta_link.url,
+ "with_legal_info": True,
+ }
+
+ send_mail(
+ "Dernier rappel avant suppression"
+ if deletion
+ else "Rappel : Identifiez votre structure sur DORA",
+ user.email,
+ mjml2html(
+ render_to_string(
+ "notification_user_without_structure_deletion.mjml"
+ if deletion
+ else "notification_user_without_structure.mjml",
+ context,
+ )
+ ),
+ )
diff --git a/dora/users/templates/notification_user_without_structure.mjml b/dora/users/templates/notification_user_without_structure.mjml
new file mode 100644
index 00000000..e5bbb773
--- /dev/null
+++ b/dora/users/templates/notification_user_without_structure.mjml
@@ -0,0 +1,34 @@
+{% extends "email-base.mjml" %}
+
+{% block preview %}N'oubliez pas d'identifier et de rejoindre votre structure{% endblock %}
+
+{% block content %}
+
+ Bonjour {{ user.last_name }},
+
+
+ Pour tirer le meilleur parti de notre plateforme, il est temps d’identifier et rejoindre votre structure dès maintenant, pour bénéficier de toutes les fonctionnalités de DORA :
+{% endblock %}
+
+
+{% block post_cta %}
+
+
+ N'oubliez pas que l'identification de votre structure est essentielle pour :
+
+ - mettre en avant les informations concernant votre structure
+ - inviter vos collaborateurs
+ - référencer son offre de services
+ - accéder aux coordonner de tous vos partenaires
+ - envoyer vos demandes d’orientation via DORA à vos partenaires
+
+
+
+ Ne manquez pas cette opportunité de bénéficier pleinement de DORA !
+
+{% endblock %}
+
+{% block cta %}
+ Identifiez et rejoignez votre structure
+
+{% endblock %}
diff --git a/dora/users/templates/notification_user_without_structure_deletion.mjml b/dora/users/templates/notification_user_without_structure_deletion.mjml
new file mode 100644
index 00000000..ad290b4a
--- /dev/null
+++ b/dora/users/templates/notification_user_without_structure_deletion.mjml
@@ -0,0 +1,28 @@
+{% extends "email-base.mjml" %}
+
+{% block preview %}Activez votre compte DORA avant qu'il ne soit supprimé{% endblock %}
+
+{% block content %}
+
+ Bonjour {{ user.last_name }},
+
+
+ Nous avons remarqué que vous n'avez toujours pas identifié et rejoint votre structure sur DORA.
+
+ Veuillez noter que sans action de votre part, votre compte sera supprimé dans 4 mois.
+
+ Pour éviter cela, activez votre compte dès maintenant :
+{% endblock %}
+
+{% block post_cta %}
+
+
+ Nous vous remercions pour votre intérêt pour DORA et espérons vous voir bientôt parmi nos utilisateurs actifs.
+
+
+{% endblock %}
+
+{% block cta %}
+ Identifiez et rejoignez votre structure
+
+{% endblock %}
diff --git a/dora/users/tests/test_emails.py b/dora/users/tests/test_emails.py
index 54f4a18f..24a7e82d 100644
--- a/dora/users/tests/test_emails.py
+++ b/dora/users/tests/test_emails.py
@@ -1,7 +1,11 @@
+import pytest
from django.core import mail
from dora.core.test_utils import make_structure, make_user
-from dora.users.emails import send_invitation_reminder
+from dora.users.emails import (
+ send_invitation_reminder,
+ send_user_without_structure_notification,
+)
def test_send_invitation_reminder():
@@ -18,3 +22,24 @@ def test_send_invitation_reminder():
)
assert structure.name in mail.outbox[0].body
assert "/auth/invitation" in mail.outbox[0].body
+
+
+@pytest.mark.parametrize(
+ "deletion,subject",
+ (
+ (False, "Rappel : Identifiez votre structure sur DORA"),
+ (True, "Dernier rappel avant suppression"),
+ ),
+)
+def test_send_user_without_structure_notification(deletion, subject):
+ user = make_user()
+
+ send_user_without_structure_notification(user, deletion=deletion)
+
+ assert len(mail.outbox) == 1
+ assert mail.outbox[0].to == [user.email]
+ assert mail.outbox[0].subject == subject
+ assert user.last_name in mail.outbox[0].body
+ assert "MailsTransactionnels" in mail.outbox[0].body
+ assert "InscritSansStructure" in mail.outbox[0].body
+ assert "Nous avons accès à vos données à caractère personnel" in mail.outbox[0].body