From b50530afd8dd60d5e5d5f3939468e85a707c1cfa Mon Sep 17 00:00:00 2001 From: SebastienReuiller Date: Thu, 19 Dec 2024 15:41:41 +0100 Subject: [PATCH] =?UTF-8?q?feat(Email=20Notifications):=20Ajout=20du=20for?= =?UTF-8?q?mulaire=20d'=C3=A9dition=20des=20pr=C3=A9f=C3=A9rences=20(#1595?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lemarche/conversations/admin.py | 11 +- lemarche/conversations/factories.py | 11 +- ...il_templatetransactional_group_and_more.py | 126 ++++++++++++++++++ lemarche/conversations/models.py | 31 +++++ lemarche/conversations/tests.py | 2 + .../dashboard/disabled_email_edit.html | 62 +++++++++ .../utils/templatetags/theme_inclusion.py | 6 + lemarche/www/dashboard/forms.py | 34 +++++ lemarche/www/dashboard/tests.py | 39 ++++++ lemarche/www/dashboard/urls.py | 3 +- lemarche/www/dashboard/views.py | 20 ++- lemarche/www/dashboard_siaes/tests.py | 2 + lemarche/www/tenders/tests.py | 2 + 13 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 lemarche/conversations/migrations/0017_emailgroup_disabledemail_templatetransactional_group_and_more.py create mode 100644 lemarche/templates/dashboard/disabled_email_edit.html diff --git a/lemarche/conversations/admin.py b/lemarche/conversations/admin.py index cab8b4f50..f9a88e2cc 100644 --- a/lemarche/conversations/admin.py +++ b/lemarche/conversations/admin.py @@ -3,7 +3,7 @@ from django.urls import reverse from django.utils.html import format_html -from lemarche.conversations.models import Conversation, TemplateTransactional, TemplateTransactionalSendLog +from lemarche.conversations.models import Conversation, EmailGroup, TemplateTransactional, TemplateTransactionalSendLog from lemarche.utils.admin.admin_site import admin_site from lemarche.utils.fields import pretty_print_readonly_jsonfield, pretty_print_readonly_jsonfield_to_table from lemarche.www.conversations.tasks import send_first_email_from_conversation @@ -153,7 +153,7 @@ class TemplateTransactionalAdmin(admin.ModelAdmin): readonly_fields = ["code", "template_transactional_send_log_count_with_link", "created_at", "updated_at"] fieldsets = ( - (None, {"fields": ("name", "code", "description")}), + (None, {"fields": ("name", "code", "description", "group")}), ("Paramètres d'envoi", {"fields": ("mailjet_id", "brevo_id", "source", "is_active")}), ("Stats", {"fields": ("template_transactional_send_log_count_with_link",)}), ("Dates", {"fields": ("created_at", "updated_at")}), @@ -234,3 +234,10 @@ def extra_data_display(self, instance: TemplateTransactionalSendLog = None): return "-" extra_data_display.short_description = TemplateTransactionalSendLog._meta.get_field("extra_data").verbose_name + + +@admin.register(EmailGroup, site=admin_site) +class EmailGroupAdmin(admin.ModelAdmin): + list_display = ["id", "relevant_user_kind", "display_name", "description", "can_be_unsubscribed"] + search_fields = ["id", "display_name"] + readonly_fields = [] diff --git a/lemarche/conversations/factories.py b/lemarche/conversations/factories.py index cc9011699..24014abb4 100644 --- a/lemarche/conversations/factories.py +++ b/lemarche/conversations/factories.py @@ -1,7 +1,7 @@ import factory from factory.django import DjangoModelFactory -from lemarche.conversations.models import Conversation, TemplateTransactional +from lemarche.conversations.models import Conversation, EmailGroup, TemplateTransactional from lemarche.siaes.factories import SiaeFactory @@ -17,9 +17,18 @@ class Meta: initial_body_message = factory.Faker("name", locale="fr_FR") +class EmailGroupFactory(DjangoModelFactory): + class Meta: + model = EmailGroup + + display_name = factory.Faker("name", locale="fr_FR") + description = factory.Faker("name", locale="fr_FR") + + class TemplateTransactionalFactory(DjangoModelFactory): class Meta: model = TemplateTransactional name = factory.Faker("name", locale="fr_FR") code = factory.Faker("name", locale="fr_FR") + group = factory.SubFactory(EmailGroupFactory) diff --git a/lemarche/conversations/migrations/0017_emailgroup_disabledemail_templatetransactional_group_and_more.py b/lemarche/conversations/migrations/0017_emailgroup_disabledemail_templatetransactional_group_and_more.py new file mode 100644 index 000000000..e5ee82205 --- /dev/null +++ b/lemarche/conversations/migrations/0017_emailgroup_disabledemail_templatetransactional_group_and_more.py @@ -0,0 +1,126 @@ +# Generated by Django 4.2.15 on 2024-12-11 17:02 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def create_email_groups(apps, schema_editor): + # Get the model + EmailGroup = apps.get_model("conversations", "EmailGroup") + + # Create email groups + email_groups = [ + { + "id": 1, + "display_name": "Structure(s) intéressée(s)", + "description": "En désactivant cette option, vous ne serez plus averti par email lorsque des fournisseurs s'intéressent à votre besoin, ce qui pourrait vous faire perdre des opportunités de collaboration rapide et efficace.", + "relevant_user_kind": "BUYER", + "can_be_unsubscribed": True, + }, + { + "id": 2, + "display_name": "Communication marketing", + "description": "En désactivant cette option, vous ne recevrez plus par email nos newsletters, enquêtes, invitations à des webinaires et Open Labs, ce qui pourrait vous priver d'informations utiles et de moments d'échange exclusifs.", + "relevant_user_kind": "BUYER", + "can_be_unsubscribed": True, + }, + { + "id": 3, + "display_name": "Opportunités commerciales", + "description": "En désactivant cette option, vous ne recevrez plus par email les demandes de devis et les appels d'offres spécialement adaptés à votre activité, ce qui pourrait vous faire manquer des opportunités importantes pour votre entreprise.", + "relevant_user_kind": "SIAE", + "can_be_unsubscribed": True, + }, + { + "id": 4, + "display_name": "Demandes de mise en relation", + "description": "En désactivant cette option, vous ne recevrez plus par email les demandes de mise en relation de clients intéressés par votre structure, ce qui pourrait vous faire perdre des opportunités précieuses de collaboration et de développement.", + "relevant_user_kind": "SIAE", + "can_be_unsubscribed": True, + }, + { + "id": 5, + "display_name": "Communication marketing", + "description": "En désactivant cette option, vous ne recevrez plus par email nos newsletters, enquêtes, invitations aux webinaires et Open Labs, ce qui pourrait vous faire passer à côté d’informations clés, de ressources utiles et d’événements exclusifs.", + "relevant_user_kind": "SIAE", + "can_be_unsubscribed": True, + }, + ] + + for group in email_groups: + EmailGroup.objects.create(**group) + + +def delete_email_groups(apps, schema_editor): + # Get the model + EmailGroup = apps.get_model("conversations", "EmailGroup") + # Delete all email groups + EmailGroup.objects.all().delete() + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("conversations", "0016_templatetransactionalsendlog"), + ] + + operations = [ + migrations.CreateModel( + name="EmailGroup", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("display_name", models.CharField(blank=True, max_length=255, verbose_name="Nom")), + ("description", models.TextField(blank=True, verbose_name="Description")), + ( + "relevant_user_kind", + models.CharField( + choices=[ + ("SIAE", "Structure"), + ("BUYER", "Acheteur"), + ("PARTNER", "Partenaire"), + ("INDIVIDUAL", "Particulier"), + ], + default="BUYER", + max_length=20, + verbose_name="Type d'utilisateur", + ), + ), + ( + "can_be_unsubscribed", + models.BooleanField(default=False, verbose_name="L'utilisateur peut s'y désinscrire"), + ), + ], + ), + migrations.CreateModel( + name="DisabledEmail", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("disabled_at", models.DateTimeField(auto_now_add=True)), + ( + "group", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="conversations.emailgroup"), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="disabled_emails", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.AddField( + model_name="templatetransactional", + name="group", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.CASCADE, to="conversations.emailgroup" + ), + ), + migrations.AddConstraint( + model_name="disabledemail", + constraint=models.UniqueConstraint(models.F("user"), models.F("group"), name="unique_group_per_user"), + ), + migrations.RunPython(create_email_groups, delete_email_groups), + ] diff --git a/lemarche/conversations/models.py b/lemarche/conversations/models.py index e274393cb..b82b79c10 100644 --- a/lemarche/conversations/models.py +++ b/lemarche/conversations/models.py @@ -13,6 +13,7 @@ from shortuuid import uuid from lemarche.conversations import constants as conversation_constants +from lemarche.users import constants as user_constants from lemarche.utils.apis import api_brevo, api_mailjet from lemarche.utils.data import add_validation_error @@ -200,6 +201,24 @@ def set_validated(self): self.save() +class EmailGroup(models.Model): + display_name = models.CharField(verbose_name="Nom", max_length=255, blank=True) + description = models.TextField(verbose_name="Description", blank=True) + relevant_user_kind = models.CharField( + verbose_name="Type d'utilisateur", + max_length=20, + choices=user_constants.KIND_CHOICES, + default=user_constants.KIND_BUYER, + ) + can_be_unsubscribed = models.BooleanField(verbose_name="L'utilisateur peut s'y désinscrire", default=False) + + def __str__(self): + return f"{self.display_name} ({self.relevant_user_kind if self.relevant_user_kind else 'Tous'})" + + def disabled_for_user(self, user): + return DisabledEmail.objects.filter(user=user, group=self).exists() + + class TemplateTransactionalQuerySet(models.QuerySet): def with_stats(self): return self.annotate( @@ -213,6 +232,7 @@ class TemplateTransactional(models.Model): verbose_name="Nom technique", max_length=255, unique=True, db_index=True, blank=True, null=True ) description = models.TextField(verbose_name="Description", blank=True) + group = models.ForeignKey("EmailGroup", on_delete=models.CASCADE, null=True) # email_subject = models.CharField( # verbose_name="E-mail : objet", @@ -363,3 +383,14 @@ class TemplateTransactionalSendLog(models.Model): class Meta: verbose_name = "Template transactionnel: logs d'envois" verbose_name_plural = "Templates transactionnels: logs d'envois" + + +class DisabledEmail(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="disabled_emails") + group = models.ForeignKey("EmailGroup", on_delete=models.CASCADE) + disabled_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint("user", "group", name="unique_group_per_user"), + ] diff --git a/lemarche/conversations/tests.py b/lemarche/conversations/tests.py index cf9435b1e..70d46ae3d 100644 --- a/lemarche/conversations/tests.py +++ b/lemarche/conversations/tests.py @@ -104,6 +104,8 @@ def test_get_template_id(self): class TemplateTransactionalModelSaveTest(TransactionTestCase): + reset_sequences = True + @classmethod def setUpTestData(cls): pass diff --git a/lemarche/templates/dashboard/disabled_email_edit.html b/lemarche/templates/dashboard/disabled_email_edit.html new file mode 100644 index 000000000..b71a2594e --- /dev/null +++ b/lemarche/templates/dashboard/disabled_email_edit.html @@ -0,0 +1,62 @@ +{% extends "layouts/base.html" %} +{% load static widget_tweaks dsfr_tags process_dict theme_inclusion %} +{% block page_title %} + Notifications{{ block.super }} +{% endblock page_title %} +{% block breadcrumb %} + {% process_dict root_dir=HOME_PAGE_PATH current="Notifications" as breadcrumb_data %} + {% dsfr_breadcrumb breadcrumb_data %} +{% endblock breadcrumb %} +{% block content %} +
+
+
+
+
+

Notifications

+
+
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} +
    + {% for group_item in form.group_items %} + {% get_form_field form group_item.field_name as field %} +
  • +
    + {% with aria_describedby="aria-describedby:"|add:field.auto_id|add:"-hint-text" %} + {{ field|dsfr_input_class_attr|attr:"type:checkbox"|attr:aria_describedby|attr:"class:fr-toggle__input" }} + {% endwith %} + +

    {{ group_item.group.description }}

    +
    +
  • + {% endfor %} +
+
+
+
    +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+{% endblock content %} diff --git a/lemarche/utils/templatetags/theme_inclusion.py b/lemarche/utils/templatetags/theme_inclusion.py index a410c10cf..2246ffe25 100644 --- a/lemarche/utils/templatetags/theme_inclusion.py +++ b/lemarche/utils/templatetags/theme_inclusion.py @@ -3,6 +3,7 @@ Exist also in the base code of C1 : https://github.com/betagouv/itou/blob/master/itou/utils/templatetags/theme_inclusion.py """ + from django import template from django.templatetags.static import static from django.utils.safestring import mark_safe @@ -99,3 +100,8 @@ def import_static_JS_theme_inclusion(): else: scripts_import += ''.format(static_theme(js_dep["src"])) return mark_safe(scripts_import) + + +@register.simple_tag +def get_form_field(form, field_name): + return form[field_name] diff --git a/lemarche/www/dashboard/forms.py b/lemarche/www/dashboard/forms.py index a8d8a5e54..960be939a 100644 --- a/lemarche/www/dashboard/forms.py +++ b/lemarche/www/dashboard/forms.py @@ -1,5 +1,6 @@ from django import forms +from lemarche.conversations.models import DisabledEmail, EmailGroup from lemarche.sectors.models import Sector from lemarche.users.models import User from lemarche.utils.fields import GroupedModelMultipleChoiceField @@ -32,3 +33,36 @@ def __init__(self, *args, **kwargs): # Disabled fields self.fields["email"].disabled = True + + +class DisabledEmailEditForm(forms.Form): + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user", None) + self.group_items = [] + super().__init__(*args, **kwargs) + + disabled_groups = [disable_email.group for disable_email in self.user.disabled_emails.all()] + for email_group in EmailGroup.objects.filter(can_be_unsubscribed=True, relevant_user_kind=self.user.kind): + field_name = f"email_group_{email_group.pk}" + self.fields[field_name] = forms.BooleanField( + required=False, + label=email_group.display_name, + initial=email_group not in disabled_groups, + widget=forms.CheckboxInput(), + ) + self.group_items.append({"group": email_group, "field_name": field_name}) + + def save(self): + disabled_emails = [] + + # add unchecked fields to disabled_emails + for field_name, value in self.cleaned_data.items(): + if field_name.startswith("email_group_"): + if not value: + group = EmailGroup.objects.get(pk=int(field_name.replace("email_group_", ""))) + disabled_email, _ = DisabledEmail.objects.get_or_create(user=self.user, group=group) + disabled_emails.append(disabled_email) + self.user.disabled_emails.set(disabled_emails) + + # remove old disabled_emails + DisabledEmail.objects.exclude(pk__in=[de.pk for de in disabled_emails]).delete() diff --git a/lemarche/www/dashboard/tests.py b/lemarche/www/dashboard/tests.py index 1776dad90..5b4cdb3e7 100644 --- a/lemarche/www/dashboard/tests.py +++ b/lemarche/www/dashboard/tests.py @@ -1,6 +1,7 @@ from django.test import TestCase from django.urls import reverse +from lemarche.conversations.models import EmailGroup from lemarche.users.factories import UserFactory from lemarche.users.models import User @@ -64,3 +65,41 @@ def test_viewing_dashboard_should_update_stats(self): response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertIsNotNone(User.objects.get(id=self.user.id).dashboard_last_seen_date) + + +class DisabledEmailEditViewTest(TestCase): + def setUp(self): + self.user = UserFactory(kind=User.KIND_BUYER) + self.url = reverse("dashboard:notifications_edit") + + def test_login_required(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 302) + self.assertIn("/accounts/login/", response.url) + + def test_get_form_displays_correctly(self): + self.client.force_login(self.user) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "dashboard/disabled_email_edit.html") + + # Check that the groups are displayed and that the 2 checkboxes are checked by default + for group in EmailGroup.objects.filter(relevant_user_kind=self.user.kind): + self.assertContains(response, group.display_name) + self.assertContains(response, " checked>", count=2) + + def test_form_submission_updates_preferences(self): + self.assertEqual(self.user.disabled_emails.count(), 0) + self.client.force_login(self.user) + response = self.client.post( + self.url, + { + "email_group_1": True, + "email_group_2": False, + }, + follow=True, + ) + self.assertContains(response, "Vos préférences de notifications ont été mises à jour.") + self.user.refresh_from_db() + self.assertEqual(self.user.disabled_emails.count(), 1) + self.assertEqual(self.user.disabled_emails.first().group.pk, 2) diff --git a/lemarche/www/dashboard/urls.py b/lemarche/www/dashboard/urls.py index b3239a5c7..c22cccc7d 100644 --- a/lemarche/www/dashboard/urls.py +++ b/lemarche/www/dashboard/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from lemarche.www.dashboard.views import DashboardHomeView, ProfileEditView +from lemarche.www.dashboard.views import DashboardHomeView, DisabledEmailEditView, ProfileEditView app_name = "dashboard" @@ -8,6 +8,7 @@ urlpatterns = [ path("", DashboardHomeView.as_view(), name="home"), path("modifier/", ProfileEditView.as_view(), name="profile_edit"), + path("notifications/", DisabledEmailEditView.as_view(), name="notifications_edit"), # FavoriteList # see dashboard_favorites/urls.py # Network diff --git a/lemarche/www/dashboard/views.py b/lemarche/www/dashboard/views.py index 160bdfc6d..e47451646 100644 --- a/lemarche/www/dashboard/views.py +++ b/lemarche/www/dashboard/views.py @@ -2,14 +2,14 @@ from django.contrib.messages.views import SuccessMessageMixin from django.urls import reverse_lazy from django.utils import timezone -from django.views.generic import DetailView, UpdateView +from django.views.generic import DetailView, FormView, UpdateView from content_manager.models import ContentPage, Tag from lemarche.cms.models import ArticleList from lemarche.siaes.models import Siae from lemarche.tenders.models import Tender from lemarche.users.models import User -from lemarche.www.dashboard.forms import ProfileEditForm +from lemarche.www.dashboard.forms import DisabledEmailEditForm, ProfileEditForm SLUG_RESSOURCES_CAT_SIAES = "solutions" @@ -91,3 +91,19 @@ class ProfileEditView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): def get_object(self): return self.request.user + + +class DisabledEmailEditView(LoginRequiredMixin, SuccessMessageMixin, FormView): + form_class = DisabledEmailEditForm + template_name = "dashboard/disabled_email_edit.html" + success_url = reverse_lazy("dashboard:notifications_edit") + success_message = "Vos préférences de notifications ont été mises à jour." + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs + + def form_valid(self, form): + form.save() + return super().form_valid(form) diff --git a/lemarche/www/dashboard_siaes/tests.py b/lemarche/www/dashboard_siaes/tests.py index 4346c968e..4ab20ee47 100644 --- a/lemarche/www/dashboard_siaes/tests.py +++ b/lemarche/www/dashboard_siaes/tests.py @@ -2,6 +2,7 @@ from django.urls import reverse from lemarche.conversations.factories import TemplateTransactionalFactory +from lemarche.conversations.models import EmailGroup from lemarche.perimeters.factories import PerimeterFactory from lemarche.perimeters.models import Perimeter from lemarche.sectors.factories import SectorFactory, SectorGroupFactory @@ -23,6 +24,7 @@ def setUpTestData(cls): cls.siae_with_user = SiaeFactory() cls.siae_with_user.users.add(cls.user_siae) cls.siae_without_user = SiaeFactory() + EmailGroup.objects.all().delete() # to avoid duplicate key error TemplateTransactionalFactory(code="SIAEUSERREQUEST_ASSIGNEE") def test_anonymous_user_cannot_adopt_siae(self): diff --git a/lemarche/www/tenders/tests.py b/lemarche/www/tenders/tests.py index 4dc73a6cc..023be5728 100644 --- a/lemarche/www/tenders/tests.py +++ b/lemarche/www/tenders/tests.py @@ -11,6 +11,7 @@ from sesame.utils import get_query_string as sesame_get_query_string from lemarche.conversations.factories import TemplateTransactionalFactory +from lemarche.conversations.models import EmailGroup from lemarche.perimeters.factories import PerimeterFactory from lemarche.perimeters.models import Perimeter from lemarche.sectors.factories import SectorFactory @@ -1133,6 +1134,7 @@ def setUpTestData(cls): cls.tender_contact_click_stat_url = reverse( "tenders:detail-contact-click-stat", kwargs={"slug": cls.tender.slug} ) + EmailGroup.objects.all().delete() # to avoid duplicate key error TemplateTransactionalFactory(code="TENDERS_AUTHOR_SIAE_INTERESTED_1") def test_anonymous_user_cannot_notify_interested(self):