From 89410721163976327125a1585a5077a3c7d21014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Le=CC=81o=20S?= Date: Wed, 1 May 2024 18:27:51 +0200 Subject: [PATCH] feat: add email reminder sent every 3 months to organizations admins having multiple members --- clevercloud/cron.json | 1 + itou/common_apps/organizations/models.py | 5 + itou/communications/dispatch/__init__.py | 1 + itou/communications/dispatch/utils.py | 7 + ...ive_members_email_reminder_last_sent_at.py | 19 ++ ...ive_members_email_reminder_last_sent_at.py | 53 +++++ ...ive_members_email_reminder_last_sent_at.py | 19 ++ ...ive_members_email_reminder_last_sent_at.py | 55 +++++ ...ive_members_email_reminder_last_sent_at.py | 19 ++ ...ive_members_email_reminder_last_sent_at.py | 58 ++++++ .../check_authorized_members_email_body.txt | 17 ++ ...check_authorized_members_email_subject.txt | 4 + .../send_check_authorized_members_email.py | 104 ++++++++++ itou/users/notifications.py | 18 ++ .../test_management_commands.ambr | 42 ++++ tests/users/test_management_commands.py | 191 +++++++++++++++++- 16 files changed, 610 insertions(+), 3 deletions(-) create mode 100644 itou/companies/migrations/0003_company_active_members_email_reminder_last_sent_at.py create mode 100644 itou/companies/migrations/0004_set_companies_active_members_email_reminder_last_sent_at.py create mode 100644 itou/institutions/migrations/0004_institution_active_members_email_reminder_last_sent_at.py create mode 100644 itou/institutions/migrations/0005_set_institutions_active_members_email_reminder_last_sent_at.py create mode 100644 itou/prescribers/migrations/0002_prescriberorganization_active_members_email_reminder_last_sent_at.py create mode 100644 itou/prescribers/migrations/0003_set_prescriber_organizations_active_members_email_reminder_last_sent_at.py create mode 100644 itou/templates/users/emails/check_authorized_members_email_body.txt create mode 100644 itou/templates/users/emails/check_authorized_members_email_subject.txt create mode 100644 itou/users/management/commands/send_check_authorized_members_email.py create mode 100644 itou/users/notifications.py diff --git a/clevercloud/cron.json b/clevercloud/cron.json index 29ae200591b..bbe38347147 100644 --- a/clevercloud/cron.json +++ b/clevercloud/cron.json @@ -19,6 +19,7 @@ "30 1 * * * $ROOT/clevercloud/run_management_command.sh new_users_to_mailjet --wet-run", "0 3 * * * $ROOT/clevercloud/run_management_command.sh clearsessions", "15 5 * * * $ROOT/clevercloud/run_management_command.sh prolongation_requests_chores email_reminder --wet-run", + "0 9 * * * $ROOT/clevercloud/run_management_command.sh send_check_authorized_members_email", "0 12 * * * $ROOT/clevercloud/run_management_command.sh evaluation_campaign_notify", "30 20 * * * $ROOT/clevercloud/crons/populate_metabase_emplois.sh --daily", "5 23 * * * $ROOT/clevercloud/run_management_command.sh archive_employee_records --wet-run", diff --git a/itou/common_apps/organizations/models.py b/itou/common_apps/organizations/models.py index 9995b1b0a74..dc240224314 100644 --- a/itou/common_apps/organizations/models.py +++ b/itou/common_apps/organizations/models.py @@ -40,6 +40,11 @@ class OrganizationAbstract(models.Model): # This enables us to keep our internal primary key opaque and independent from any external logic. uid = models.UUIDField(db_index=True, default=uuid.uuid4, unique=True) + active_members_email_reminder_last_sent_at = models.DateTimeField( + null=True, + verbose_name="date d'envoi du dernier rappel pour vérifier les membres actifs", + ) + # Child class should have a "members" attribute, for example: # members = models.ManyToManyField( # settings.AUTH_USER_MODEL, diff --git a/itou/communications/dispatch/__init__.py b/itou/communications/dispatch/__init__.py index 6aa8356a2ac..0810a631e9a 100644 --- a/itou/communications/dispatch/__init__.py +++ b/itou/communications/dispatch/__init__.py @@ -6,5 +6,6 @@ JobSeekerNotification, PrescriberNotification, PrescriberOrEmployerNotification, + PrescriberOrEmployerOrLaborInspectorNotification, WithStructureMixin, ) diff --git a/itou/communications/dispatch/utils.py b/itou/communications/dispatch/utils.py index 94f98b1031a..b84b851a87a 100644 --- a/itou/communications/dispatch/utils.py +++ b/itou/communications/dispatch/utils.py @@ -18,6 +18,13 @@ def is_manageable_by_user(self): return super().is_manageable_by_user() and (self.user.is_prescriber or self.user.is_employer) +class PrescriberOrEmployerOrLaborInspectorNotification: + def is_manageable_by_user(self): + return super().is_manageable_by_user() and ( + self.user.is_prescriber or self.user.is_employer or self.user.is_labor_inspector + ) + + class WithStructureMixin: def is_manageable_by_user(self): return super().is_manageable_by_user() and self.structure is not None diff --git a/itou/companies/migrations/0003_company_active_members_email_reminder_last_sent_at.py b/itou/companies/migrations/0003_company_active_members_email_reminder_last_sent_at.py new file mode 100644 index 00000000000..f2b927f6f8d --- /dev/null +++ b/itou/companies/migrations/0003_company_active_members_email_reminder_last_sent_at.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.6 on 2024-06-05 09:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("companies", "0002_fix_job_app_contract_type_enum"), + ] + + operations = [ + migrations.AddField( + model_name="company", + name="active_members_email_reminder_last_sent_at", + field=models.DateTimeField( + null=True, verbose_name="date d'envoi du dernier rappel pour vérifier les membres actifs" + ), + ), + ] diff --git a/itou/companies/migrations/0004_set_companies_active_members_email_reminder_last_sent_at.py b/itou/companies/migrations/0004_set_companies_active_members_email_reminder_last_sent_at.py new file mode 100644 index 00000000000..6af8851d281 --- /dev/null +++ b/itou/companies/migrations/0004_set_companies_active_members_email_reminder_last_sent_at.py @@ -0,0 +1,53 @@ +# Generated by Django 5.0.6 on 2024-06-10 15:31 + +import datetime +import math +import time + +from dateutil.relativedelta import relativedelta +from django.db import migrations + + +def set_active_members_email_reminder_last_sent_at(apps, schema_editor): + """ + Set active_members_email_reminder_last_sent_at for companies created more than 3 months ago. + Doing so to avoid processing a single and huge batch of most of the pre-existing companies during the first run of + the send_check_authorized_members_email management command. + """ + START_DATE = datetime.date.today() - relativedelta(months=3) + Company = apps.get_model("companies", "Company") + + companies_qs = ( + Company.objects.filter( + active_members_email_reminder_last_sent_at__isnull=True, + created_at__date__lt=START_DATE, + ) + .only("active_members_email_reminder_last_sent_at", "created_at") + .order_by("created_at") + ) + batch_size = max([math.ceil(companies_qs.count() / 90), 50]) + batch_start_date = START_DATE + + companies_processed = 0 + start = time.perf_counter() + while companies_batch := companies_qs[:batch_size]: + companies = [] + for company in companies_batch: + company.active_members_email_reminder_last_sent_at = datetime.datetime.combine( + batch_start_date, + company.created_at.timetz(), + ) + companies.append(company) + companies_processed += Company.objects.bulk_update(companies, {"active_members_email_reminder_last_sent_at"}) + batch_start_date += datetime.timedelta(days=1) + print(f"{companies_processed} companies migrated in {time.perf_counter() - start:.2f} sec") + + +class Migration(migrations.Migration): + dependencies = [ + ("companies", "0003_company_active_members_email_reminder_last_sent_at"), + ] + + operations = [ + migrations.RunPython(set_active_members_email_reminder_last_sent_at, migrations.RunPython.noop, elidable=True), + ] diff --git a/itou/institutions/migrations/0004_institution_active_members_email_reminder_last_sent_at.py b/itou/institutions/migrations/0004_institution_active_members_email_reminder_last_sent_at.py new file mode 100644 index 00000000000..fabfffb6d97 --- /dev/null +++ b/itou/institutions/migrations/0004_institution_active_members_email_reminder_last_sent_at.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.6 on 2024-06-05 09:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("institutions", "0003_institution_unique_ddets_per_department"), + ] + + operations = [ + migrations.AddField( + model_name="institution", + name="active_members_email_reminder_last_sent_at", + field=models.DateTimeField( + null=True, verbose_name="date d'envoi du dernier rappel pour vérifier les membres actifs" + ), + ), + ] diff --git a/itou/institutions/migrations/0005_set_institutions_active_members_email_reminder_last_sent_at.py b/itou/institutions/migrations/0005_set_institutions_active_members_email_reminder_last_sent_at.py new file mode 100644 index 00000000000..2d83bde8d61 --- /dev/null +++ b/itou/institutions/migrations/0005_set_institutions_active_members_email_reminder_last_sent_at.py @@ -0,0 +1,55 @@ +# Generated by Django 5.0.6 on 2024-06-10 15:31 + +import datetime +import math +import time + +from dateutil.relativedelta import relativedelta +from django.db import migrations + + +def set_active_members_email_reminder_last_sent_at(apps, schema_editor): + """ + Set active_members_email_reminder_last_sent_at for institutions created more than 3 months ago. + Doing so to avoid processing a single and huge batch of most of the pre-existing institutions during the first run + of the send_check_authorized_members_email management command. + """ + START_DATE = datetime.date.today() - relativedelta(months=3) + Institution = apps.get_model("institutions", "Institution") + + institutions_qs = ( + Institution.objects.filter( + active_members_email_reminder_last_sent_at__isnull=True, + created_at__date__lt=START_DATE, + ) + .only("active_members_email_reminder_last_sent_at", "created_at") + .order_by("created_at") + ) + batch_size = max([math.ceil(institutions_qs.count() / 90), 50]) + batch_start_date = START_DATE + + institutions_processed = 0 + start = time.perf_counter() + while institutions_batch := institutions_qs[:batch_size]: + institutions = [] + for institution in institutions_batch: + institution.active_members_email_reminder_last_sent_at = datetime.datetime.combine( + batch_start_date, + institution.created_at.timetz(), + ) + institutions.append(institution) + institutions_processed += Institution.objects.bulk_update( + institutions, {"active_members_email_reminder_last_sent_at"} + ) + batch_start_date += datetime.timedelta(days=1) + print(f"{institutions_processed} institutions migrated in {time.perf_counter() - start:.2f} sec") + + +class Migration(migrations.Migration): + dependencies = [ + ("institutions", "0004_institution_active_members_email_reminder_last_sent_at"), + ] + + operations = [ + migrations.RunPython(set_active_members_email_reminder_last_sent_at, migrations.RunPython.noop, elidable=True), + ] diff --git a/itou/prescribers/migrations/0002_prescriberorganization_active_members_email_reminder_last_sent_at.py b/itou/prescribers/migrations/0002_prescriberorganization_active_members_email_reminder_last_sent_at.py new file mode 100644 index 00000000000..ed7a3a2c7de --- /dev/null +++ b/itou/prescribers/migrations/0002_prescriberorganization_active_members_email_reminder_last_sent_at.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.6 on 2024-06-05 09:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("prescribers", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="prescriberorganization", + name="active_members_email_reminder_last_sent_at", + field=models.DateTimeField( + null=True, verbose_name="date d'envoi du dernier rappel pour vérifier les membres actifs" + ), + ), + ] diff --git a/itou/prescribers/migrations/0003_set_prescriber_organizations_active_members_email_reminder_last_sent_at.py b/itou/prescribers/migrations/0003_set_prescriber_organizations_active_members_email_reminder_last_sent_at.py new file mode 100644 index 00000000000..08ee9726f39 --- /dev/null +++ b/itou/prescribers/migrations/0003_set_prescriber_organizations_active_members_email_reminder_last_sent_at.py @@ -0,0 +1,58 @@ +# Generated by Django 5.0.6 on 2024-06-10 15:31 + +import datetime +import math +import time + +from dateutil.relativedelta import relativedelta +from django.db import migrations + + +def set_active_members_email_reminder_last_sent_at(apps, schema_editor): + """ + Set active_members_email_reminder_last_sent_at for prescriber organizations created more than 3 months ago. + Doing so to avoid processing a single and huge batch of most of the pre-existing prescriber organizations during + the first run of the send_check_authorized_members_email management command. + """ + START_DATE = datetime.date.today() - relativedelta(months=3) + PrescriberOrganization = apps.get_model("prescribers", "PrescriberOrganization") + + prescriber_organizations_qs = ( + PrescriberOrganization.objects.filter( + active_members_email_reminder_last_sent_at__isnull=True, + created_at__date__lt=START_DATE, + ) + .only("active_members_email_reminder_last_sent_at", "created_at") + .order_by("created_at") + ) + batch_size = max([math.ceil(prescriber_organizations_qs.count() / 90), 50]) + batch_start_date = START_DATE + + prescriber_organizations_processed = 0 + start = time.perf_counter() + while prescriber_organizations_batch := prescriber_organizations_qs[:batch_size]: + prescriber_organizations = [] + for prescriber_organization in prescriber_organizations_batch: + prescriber_organization.active_members_email_reminder_last_sent_at = datetime.datetime.combine( + batch_start_date, + prescriber_organization.created_at.timetz(), + ) + prescriber_organizations.append(prescriber_organization) + prescriber_organizations_processed += PrescriberOrganization.objects.bulk_update( + prescriber_organizations, {"active_members_email_reminder_last_sent_at"} + ) + batch_start_date += datetime.timedelta(days=1) + print( + f"{prescriber_organizations_processed} prescriber organizations " + f"migrated in {time.perf_counter() - start:.2f} sec" + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("prescribers", "0002_prescriberorganization_active_members_email_reminder_last_sent_at"), + ] + + operations = [ + migrations.RunPython(set_active_members_email_reminder_last_sent_at, migrations.RunPython.noop, elidable=True), + ] diff --git a/itou/templates/users/emails/check_authorized_members_email_body.txt b/itou/templates/users/emails/check_authorized_members_email_body.txt new file mode 100644 index 00000000000..0528f94f131 --- /dev/null +++ b/itou/templates/users/emails/check_authorized_members_email_body.txt @@ -0,0 +1,17 @@ +{% extends "layout/base_email_text_body.txt" %} +{% load format_filters %} +{% block body %} +Bonjour, + +En tant qu’administrateur de l’organisation {{ structure.name }}, nous vous invitons à vérifier la liste des membres afin de vous assurer que seuls les collaborateurs qui travaillent au sein de cette organisation puissent accéder à votre espace de travail. + +RDV sur votre espace des emplois de l’inclusion à la rubrique “Gérer les collaborateurs” : + +Si un collaborateur a quitté votre organisation, vous devez le retirer des membres en cliquant sur le bouton d’action situé à droite, puis sur l’option “retirer de la structure”. + +{% if active_admins_count == 1 %}Pour des raisons de sécurité et si la configuration de votre organisation vous le permet, nous vous invitons à nommer plusieurs administrateurs. + +{% endif %}Ce rappel automatique vous sera transmis tous les 3 mois, mais il est vivement recommandé d’effectuer cette action dès qu’un collaborateur quitte votre organisation. + +Cordialement, +{% endblock body %} diff --git a/itou/templates/users/emails/check_authorized_members_email_subject.txt b/itou/templates/users/emails/check_authorized_members_email_subject.txt new file mode 100644 index 00000000000..6e97a1a9695 --- /dev/null +++ b/itou/templates/users/emails/check_authorized_members_email_subject.txt @@ -0,0 +1,4 @@ +{% extends "layout/base_email_text_subject.txt" %} +{% block subject %} +Rappel sécurité : vérifiez la liste des membres de l’organisation {{ structure.name }} +{% endblock %} diff --git a/itou/users/management/commands/send_check_authorized_members_email.py b/itou/users/management/commands/send_check_authorized_members_email.py new file mode 100644 index 00000000000..96d66ece9d6 --- /dev/null +++ b/itou/users/management/commands/send_check_authorized_members_email.py @@ -0,0 +1,104 @@ +from dateutil.relativedelta import relativedelta +from django.db import transaction +from django.db.models import Count, Prefetch, Q +from django.db.models.functions import Coalesce +from django.utils import timezone + +from itou.companies.models import Company +from itou.institutions.models import Institution +from itou.prescribers.models import PrescriberOrganization +from itou.users.models import User +from itou.users.notifications import OrganizationActiveMembersReminderNotification +from itou.utils.command import BaseCommand + + +class Command(BaseCommand): + """ + Send an email reminder every 3 months asking admins of companies, organizations and institutions + having more than 1 member to review members access and ensure that only authorized members have + access to the organization data. + """ + + def build_query(self, queryset): + TODAY = timezone.localdate() + membership_attname = queryset.model.members.through._meta.get_field("user").remote_field.name + + return ( + queryset.prefetch_related( + Prefetch( + "members", + queryset=User.objects.order_by("date_joined").filter( + **{ + "is_active": True, + f"{membership_attname}__is_active": True, + f"{membership_attname}__is_admin": True, + }, + ), + to_attr="admin_members", + ) + ) + .annotate( + last_sent_at=Coalesce("active_members_email_reminder_last_sent_at", "created_at"), + active_members_count=Count( + "members", + filter=Q( + **{ + f"{membership_attname}__is_active": True, + f"{membership_attname}__user__is_active": True, + } + ), + ), + ) + .filter( + last_sent_at__date__lte=TODAY - relativedelta(months=3), + active_members_count__gt=1, + ) + .order_by("pk") + ) + + def handle(self, *args, **options): + NOW = timezone.now() + + # Companies + companies = self.build_query(Company.objects.active()) + self.stdout.write(f"Processing {len(companies)} companies") + for company in companies: + with transaction.atomic(): + for member in company.admin_members: + OrganizationActiveMembersReminderNotification( + member, company, active_admins_count=len(company.admin_members) + ).send() + self.stdout.write(f" - Sent reminder notification to user #{member.pk} for company #{company.pk}") + company.active_members_email_reminder_last_sent_at = NOW + company.save(update_fields=["active_members_email_reminder_last_sent_at"]) + + # Prescriber organizations + prescriber_organizations = self.build_query(PrescriberOrganization.objects.all()) + self.stdout.write(f"Processing {len(prescriber_organizations)} prescriber organizations") + for prescriber_organization in prescriber_organizations: + with transaction.atomic(): + for member in prescriber_organization.admin_members: + OrganizationActiveMembersReminderNotification( + member, prescriber_organization, active_admins_count=len(prescriber_organization.admin_members) + ).send() + self.stdout.write( + f" - Sent reminder notification to user #{member.pk} " + f"for prescriber organization #{prescriber_organization.pk}" + ) + prescriber_organization.active_members_email_reminder_last_sent_at = NOW + prescriber_organization.save(update_fields=["active_members_email_reminder_last_sent_at"]) + + # Institutions + institutions = self.build_query(Institution.objects.all()) + self.stdout.write(f"Processing {len(institutions)} institutions") + for institution in institutions: + with transaction.atomic(): + for member in institution.admin_members: + OrganizationActiveMembersReminderNotification( + member, institution, active_admins_count=len(institution.admin_members) + ).send() + self.stdout.write( + f" - Sent reminder notification to user #{member.pk} for institution #{institution.pk}" + ) + institution.active_members_email_reminder_last_sent_at = NOW + institution.save(update_fields=["active_members_email_reminder_last_sent_at"]) diff --git a/itou/users/notifications.py b/itou/users/notifications.py new file mode 100644 index 00000000000..954670613bd --- /dev/null +++ b/itou/users/notifications.py @@ -0,0 +1,18 @@ +from itou.communications import NotificationCategory, registry as notifications_registry +from itou.communications.dispatch import EmailNotification, PrescriberOrEmployerOrLaborInspectorNotification + + +@notifications_registry.register +class OrganizationActiveMembersReminderNotification( + PrescriberOrEmployerOrLaborInspectorNotification, EmailNotification +): + name = "Rappel périodique pour s'assurer que les membres de sa structure sont bien actifs et autorisés" + category = NotificationCategory.MEMBERS_MANAGEMENT + subject_template = "users/emails/check_authorized_members_email_subject.txt" + body_template = "users/emails/check_authorized_members_email_body.txt" + can_be_disabled = False + + def get_context(self): + context = super().get_context() + context["structure"] = self.structure + return context diff --git a/tests/users/__snapshots__/test_management_commands.ambr b/tests/users/__snapshots__/test_management_commands.ambr index ee19f29de55..9a548af34b7 100644 --- a/tests/users/__snapshots__/test_management_commands.ambr +++ b/tests/users/__snapshots__/test_management_commands.ambr @@ -1,4 +1,46 @@ # serializer version: 1 +# name: SendCheckAuthorizedMembersEmailManagementCommandTest.test_check_authorized_members_email_content_one_admin + ''' + Bonjour, + + En tant qu’administrateur de l’organisation Organization 1, nous vous invitons à vérifier la liste des membres afin de vous assurer que seuls les collaborateurs qui travaillent au sein de cette organisation puissent accéder à votre espace de travail. + + RDV sur votre espace des emplois de l’inclusion à la rubrique “Gérer les collaborateurs” : + + Si un collaborateur a quitté votre organisation, vous devez le retirer des membres en cliquant sur le bouton d’action situé à droite, puis sur l’option “retirer de la structure”. + + Pour des raisons de sécurité et si la configuration de votre organisation vous le permet, nous vous invitons à nommer plusieurs administrateurs. + + Ce rappel automatique vous sera transmis tous les 3 mois, mais il est vivement recommandé d’effectuer cette action dès qu’un collaborateur quitte votre organisation. + + Cordialement, + + --- + [DEV] Cet email est envoyé depuis un environnement de démonstration, merci de ne pas en tenir compte [DEV] + Les emplois de l'inclusion + http://localhost:8000 + ''' +# --- +# name: SendCheckAuthorizedMembersEmailManagementCommandTest.test_check_authorized_members_email_content_two_admins + ''' + Bonjour, + + En tant qu’administrateur de l’organisation Organization 1, nous vous invitons à vérifier la liste des membres afin de vous assurer que seuls les collaborateurs qui travaillent au sein de cette organisation puissent accéder à votre espace de travail. + + RDV sur votre espace des emplois de l’inclusion à la rubrique “Gérer les collaborateurs” : + + Si un collaborateur a quitté votre organisation, vous devez le retirer des membres en cliquant sur le bouton d’action situé à droite, puis sur l’option “retirer de la structure”. + + Ce rappel automatique vous sera transmis tous les 3 mois, mais il est vivement recommandé d’effectuer cette action dès qu’un collaborateur quitte votre organisation. + + Cordialement, + + --- + [DEV] Cet email est envoyé depuis un environnement de démonstration, merci de ne pas en tenir compte [DEV] + Les emplois de l'inclusion + http://localhost:8000 + ''' +# --- # name: test_pe_certify_users ''' > about to resolve first_name and last_name for count=1 users. diff --git a/tests/users/test_management_commands.py b/tests/users/test_management_commands.py index ad7f76bf856..633026a70cc 100644 --- a/tests/users/test_management_commands.py +++ b/tests/users/test_management_commands.py @@ -5,10 +5,12 @@ from unittest import mock import httpx +import pytest from allauth.account.models import EmailAddress from dateutil.relativedelta import relativedelta from django.contrib.auth.models import Group from django.contrib.sessions.models import Session +from django.core import mail from django.core.management import call_command from django.utils import timezone from freezegun import freeze_time @@ -18,6 +20,7 @@ from itou.job_applications.models import JobApplication from itou.prescribers.enums import PrescriberOrganizationKind from itou.users.enums import IdentityProvider +from itou.users.management.commands import send_check_authorized_members_email from itou.users.management.commands.new_users_to_mailjet import ( MAILJET_API_URL, NEW_ORIENTEURS_LISTID, @@ -26,12 +29,11 @@ NEW_SIAE_LISTID, ) from itou.users.models import User -from itou.utils.apis.pole_emploi import ( - PoleEmploiAPIBadResponse, -) +from itou.utils.apis.pole_emploi import PoleEmploiAPIBadResponse from itou.utils.mocks.pole_emploi import API_RECHERCHE_ERROR, API_RECHERCHE_RESULT_KNOWN from tests.approvals.factories import ApprovalFactory from tests.companies.factories import CompanyMembershipFactory +from tests.institutions.factories import InstitutionMembershipFactory from tests.job_applications.factories import JobApplicationFactory, JobApplicationSentByJobSeekerFactory from tests.prescribers.factories import ( PrescriberFactory, @@ -1190,3 +1192,186 @@ def recherche_call(user, swap): recherche_call(old_failure, swap=False), recherche_call(old_failure, swap=True), ] + + +@pytest.fixture(name="command") +def command_fixture(request): + request.instance.command = send_check_authorized_members_email.Command(stdout=io.StringIO(), stderr=io.StringIO()) + + +@pytest.mark.usefixtures("unittest_compatibility", "command") +@freeze_time("2024-05-30") +class SendCheckAuthorizedMembersEmailManagementCommandTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.employer_1 = CompanyMembershipFactory(user__email="employer1@test.local", company__name="Company 1") + cls.prescriber_1 = PrescriberMembershipFactory( + organization__name="Organization 1", + organization__created_at=timezone.now() - relativedelta(months=3), + ) + cls.labor_inspector_1 = InstitutionMembershipFactory( + institution__name="Institution 1", + institution__created_at=timezone.now() - relativedelta(months=3, days=-1), + ) + + def test_send_check_authorized_members_email_management_command_not_enough_members(self): + # Nothing to do (only one member per organization) + with self.captureOnCommitCallbacks(execute=True): + self.command.handle() + assert len(mail.outbox) == 0 + assert self.command.stdout.getvalue() == ( + "Processing 0 companies\nProcessing 0 prescriber organizations\nProcessing 0 institutions\n" + ) + + def test_send_check_authorized_members_email_management_command_created_at(self): + employer_2 = CompanyMembershipFactory(company=self.employer_1.company) + prescriber_2 = PrescriberMembershipFactory(organization=self.prescriber_1.organization) + labor_inspector_2 = InstitutionMembershipFactory(institution=self.labor_inspector_1.institution) + + # Should send 2 notifications to the 2 prescribers + # Employer's company has been created today + # Labor inspector's institution has been created less than 3 months ago + with self.captureOnCommitCallbacks(execute=True): + self.command.handle() + expected_output = ( + "Processing 0 companies\n" + "Processing 1 prescriber organizations\n" + f" - Sent reminder notification to user #{self.prescriber_1.user_id} " + f"for prescriber organization #{self.prescriber_1.organization_id}\n" + f" - Sent reminder notification to user #{prescriber_2.user_id} " + f"for prescriber organization #{prescriber_2.organization_id}\n" + "Processing 0 institutions\n" + ) + assert len(mail.outbox) == 2 + assert self.command.stdout.getvalue() == expected_output + + # Subsequent calls should not send other notifications + with self.captureOnCommitCallbacks(execute=True): + self.command.handle() + expected_output += "Processing 0 companies\nProcessing 0 prescriber organizations\nProcessing 0 institutions\n" + assert len(mail.outbox) == 2 + assert self.command.stdout.getvalue() == expected_output + + # Update company and institution creation dates far in the past + self.employer_1.company.created_at -= relativedelta(months=5) + self.employer_1.company.save(update_fields=["created_at"]) + self.labor_inspector_1.institution.created_at -= relativedelta(months=5) + self.labor_inspector_1.institution.save(update_fields=["created_at"]) + with self.captureOnCommitCallbacks(execute=True): + self.command.handle() + expected_output += ( + "Processing 1 companies\n" + f" - Sent reminder notification to user #{self.employer_1.user_id} " + f"for company #{self.employer_1.company_id}\n" + f" - Sent reminder notification to user #{employer_2.user_id} for company #{employer_2.company_id}\n" + "Processing 0 prescriber organizations\n" + "Processing 1 institutions\n" + f" - Sent reminder notification to user #{self.labor_inspector_1.user_id} " + f"for institution #{self.labor_inspector_1.institution_id}\n" + f" - Sent reminder notification to user #{labor_inspector_2.user_id} " + f"for institution #{labor_inspector_2.institution_id}\n" + ) + assert len(mail.outbox) == 6 + assert self.command.stdout.getvalue() == expected_output + + def test_send_check_authorized_members_email_management_command_active_members_email_reminder_last_sent_at(self): + employer_2 = CompanyMembershipFactory(company=self.employer_1.company) + prescriber_2 = PrescriberMembershipFactory(organization=self.prescriber_1.organization) + labor_inspector_2 = InstitutionMembershipFactory(institution=self.labor_inspector_1.institution) + + # Set created_at and active_members_email_reminder_last_sent_at + NOW = timezone.now() + self.employer_1.company.created_at = NOW - relativedelta(months=6) + self.employer_1.company.active_members_email_reminder_last_sent_at = NOW - relativedelta(months=3) + self.employer_1.company.save(update_fields=["created_at", "active_members_email_reminder_last_sent_at"]) + self.prescriber_1.organization.created_at = NOW - relativedelta(months=6, days=-1) + self.prescriber_1.organization.active_members_email_reminder_last_sent_at = NOW - relativedelta( + months=3, days=-1 + ) + self.prescriber_1.organization.save(update_fields=["created_at", "active_members_email_reminder_last_sent_at"]) + self.labor_inspector_1.institution.created_at = NOW - relativedelta(months=6, days=1) + self.labor_inspector_1.institution.active_members_email_reminder_last_sent_at = NOW - relativedelta( + months=3, days=1 + ) + self.labor_inspector_1.institution.save( + update_fields=["created_at", "active_members_email_reminder_last_sent_at"] + ) + + # Should send 4 notifications to the 2 employers and the 2 labor inspectors + with self.captureOnCommitCallbacks(execute=True): + self.command.handle() + expected_output = ( + "Processing 1 companies\n" + f" - Sent reminder notification to user #{self.employer_1.user_id} " + f"for company #{self.employer_1.company_id}\n" + f" - Sent reminder notification to user #{employer_2.user_id} for company #{employer_2.company_id}\n" + "Processing 0 prescriber organizations\n" + "Processing 1 institutions\n" + f" - Sent reminder notification to user #{self.labor_inspector_1.user_id} " + f"for institution #{self.labor_inspector_1.institution_id}\n" + f" - Sent reminder notification to user #{labor_inspector_2.user_id} " + f"for institution #{labor_inspector_2.institution_id}\n" + ) + assert len(mail.outbox) == 4 + assert self.command.stdout.getvalue() == expected_output + + # Subsequent calls should not send other notifications + with self.captureOnCommitCallbacks(execute=True): + self.command.handle() + expected_output += "Processing 0 companies\nProcessing 0 prescriber organizations\nProcessing 0 institutions\n" + assert len(mail.outbox) == 4 + assert self.command.stdout.getvalue() == expected_output + + # Update prescriber organization creation date enough in the past + # Should not send any notification: only active_members_email_reminder_last_sent_at must be considered + self.prescriber_1.organization.created_at -= relativedelta(days=1) + self.prescriber_1.organization.save(update_fields=["created_at"]) + with self.captureOnCommitCallbacks(execute=True): + self.command.handle() + expected_output += "Processing 0 companies\nProcessing 0 prescriber organizations\nProcessing 0 institutions\n" + assert len(mail.outbox) == 4 + assert self.command.stdout.getvalue() == expected_output + + # Update prescriber organization last sent reminder date enough in the past + # Should now send notification to prescribers + self.prescriber_1.organization.active_members_email_reminder_last_sent_at -= relativedelta(days=1) + self.prescriber_1.organization.save(update_fields=["active_members_email_reminder_last_sent_at"]) + with self.captureOnCommitCallbacks(execute=True): + self.command.handle() + expected_output += ( + "Processing 0 companies\n" + "Processing 1 prescriber organizations\n" + f" - Sent reminder notification to user #{self.prescriber_1.user_id} " + f"for prescriber organization #{self.prescriber_1.organization_id}\n" + f" - Sent reminder notification to user #{prescriber_2.user_id} " + f"for prescriber organization #{prescriber_2.organization_id}\n" + "Processing 0 institutions\n" + ) + assert len(mail.outbox) == 6 + assert self.command.stdout.getvalue() == expected_output + + def test_check_authorized_members_email_content_two_admins(self): + PrescriberMembershipFactory(organization=self.prescriber_1.organization) + + # Should send 2 notifications to the 2 prescribers + with self.captureOnCommitCallbacks(execute=True): + self.command.handle() + assert len(mail.outbox) == 2 + assert ( + mail.outbox[0].subject + == "[DEV] Rappel sécurité : vérifiez la liste des membres de l’organisation Organization 1" + ) + assert mail.outbox[0].body == self.snapshot + + def test_check_authorized_members_email_content_one_admin(self): + PrescriberMembershipFactory(organization=self.prescriber_1.organization, is_admin=False) + + # Should send 1 notification to the only one admin prescriber + with self.captureOnCommitCallbacks(execute=True): + self.command.handle() + assert len(mail.outbox) == 1 + assert ( + mail.outbox[0].subject + == "[DEV] Rappel sécurité : vérifiez la liste des membres de l’organisation Organization 1" + ) + assert mail.outbox[0].body == self.snapshot