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 : +

+

+ +

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