Skip to content

Commit

Permalink
feat(notification): informer les abonnés des nouvelles questions dans…
Browse files Browse the repository at this point in the history
… un forum (#835)

## Description

🎸 Informer les utilisateurs abonnés à un forum lorsqu'une nouvelle
question est posée
🎸 La notification est incluse dans le digest quotidien
🎸 Le `EmailSentTrackKind` du digest quotidien est désormais
`BULK_NOTIFS`

## Type de changement

🎢 Nouvelle fonctionnalité (changement non cassant qui ajoute une
fonctionnalité).

### Points d'attention

🦺 refactor des tests impactés en pytest
🦺 ajout de `Trait` à `NotificationFactory`, hydratation dynamique de
`recipient`
🦺 dynamisation du contenu généré par la méthode
`get_serialized_messages`
🦺 prise en compte de la création du premier `post` d'un `topic` par le
signal `create_post_notifications`
🦺 ajustement de la méthode `get_grouped_notifications` lors de l'appel à
`send_email`
  • Loading branch information
vincentporte authored Dec 3, 2024
1 parent e929c19 commit 432fec2
Show file tree
Hide file tree
Showing 12 changed files with 291 additions and 191 deletions.
2 changes: 1 addition & 1 deletion lacommunaute/forum/tests/tests_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ def test_queries(self):

TopicFactory.create_batch(20, with_post=True)
self.client.force_login(self.user)
with self.assertNumQueries(23):
with self.assertNumQueries(22):
self.client.get(self.url)

def test_certified_post_display(self):
Expand Down
15 changes: 13 additions & 2 deletions lacommunaute/forum_conversation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,13 @@ def get_absolute_url(self, with_fqdn=False):

return absolute_url

def mails_to_notify(self):
def mails_to_notify_topic_head(self):
forum_upvoters_qs = User.objects.filter(id__in=self.forum.upvotes.all().values("voter_id")).values_list(
"email", flat=True
)
return [email for email in forum_upvoters_qs]

def mails_to_notify_replies(self):
# we want to notify stakeholders of the topic, except the last poster.
# stakeholders are:
# - authenticated users who upvoted one of the posts of the topic
Expand Down Expand Up @@ -109,6 +115,11 @@ def mails_to_notify(self):

return [email for email in stakeholders_qs if email != last_poster_email]

def mails_to_notify(self):
if self.last_post.is_topic_head:
return self.mails_to_notify_topic_head()
return self.mails_to_notify_replies()

@property
def poster_email(self):
return self.first_post.username or self.first_post.poster.email
Expand Down Expand Up @@ -143,7 +154,7 @@ def is_first_reply(self):
def save(self, *args, **kwargs):
created = not self.pk
super().save(*args, **kwargs)
if created and (self.is_topic_tail and not self.is_topic_head):
if created:
post_create.send(sender=self.__class__, instance=self)


Expand Down
47 changes: 26 additions & 21 deletions lacommunaute/forum_conversation/tests/tests_models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from datetime import datetime, timezone

import pytest
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import IntegrityError
Expand All @@ -10,13 +11,13 @@
from lacommunaute.forum.factories import ForumFactory
from lacommunaute.forum_conversation.factories import (
AnonymousPostFactory,
AnonymousTopicFactory,
CertifiedPostFactory,
PostFactory,
TopicFactory,
)
from lacommunaute.forum_conversation.models import Post, Topic
from lacommunaute.forum_member.shortcuts import get_forum_member_display_name
from lacommunaute.forum_upvote.models import UpVote
from lacommunaute.users.factories import UserFactory


Expand Down Expand Up @@ -139,36 +140,40 @@ def test_topic_types(self):
self.assertEqual(1, Topic.TOPIC_STICKY)
self.assertEqual(2, Topic.TOPIC_ANNOUNCE)

def test_mails_to_notify_sorted_authenticated_posters(self):
topic = TopicFactory(with_post=True)
self.assertEqual(topic.mails_to_notify(), [])

post = PostFactory(topic=topic)
self.assertEqual(topic.mails_to_notify(), [topic.poster.email])
@pytest.fixture(name="upvoters")
def upvoters_fixture():
return UserFactory.create_batch(3)

PostFactory(topic=topic)
self.assertEqual(topic.mails_to_notify(), sorted([topic.poster.email, post.poster.email]))

def test_mails_to_notify_authenticated_upvoters(self):
upvoter = UserFactory()
topic = TopicFactory(with_post=True)
UpVote.objects.create(content_object=topic.first_post, voter=upvoter)
class TestModelMethods:
def test_forum_upvoters_are_notified_for_new_topic(self, db, upvoters):
topic = TopicFactory(forum=ForumFactory(upvoted_by=upvoters), with_post=True)
assert topic.mails_to_notify() == [upvoter.email for upvoter in upvoters]

self.assertEqual(topic.mails_to_notify(), [upvoter.email])
def test_forum_upvoters_are_not_notified_on_replies(self, db, upvoters):
topic = TopicFactory(forum=ForumFactory(upvoted_by=upvoters), with_post=True, answered=True)
assert not set([upvoter.email for upvoter in upvoters]).intersection(set(topic.mails_to_notify()))

def test_mails_to_notify_anonymous_poster(self):
def test_posts_upvoters_are_notified_on_replies(self, db, upvoters):
topic = TopicFactory(with_post=True)
PostFactory(topic=topic, upvoted_by=upvoters)
assert set([upvoter.email for upvoter in upvoters]).issubset(set(topic.mails_to_notify()))

def test_authenticated_posters_are_notified_on_replies_except_last_one(self, db):
topic = TopicFactory(with_post=True)
anonymous_post = AnonymousPostFactory(topic=topic)
PostFactory(topic=topic)
assert topic.mails_to_notify() == [topic.first_post.poster.email]

self.assertEqual(topic.mails_to_notify(), sorted([topic.poster.email, anonymous_post.username]))
def test_anonymous_posters_are_notified_on_replies_except_last_one(self, db):
topic = AnonymousTopicFactory(with_post=True)
AnonymousPostFactory(topic=topic)
assert topic.mails_to_notify() == [topic.first_post.username]

def test_mails_to_notify_deduplication(self):
def test_notify_replies_deduplication(self, db):
topic = TopicFactory(with_post=True)
UpVote.objects.create(content_object=topic.first_post, voter=topic.poster)

PostFactory(topic=topic)
self.assertEqual(topic.mails_to_notify(), [topic.poster.email])
PostFactory(topic=topic, upvoted_by=[topic.first_post.poster])
assert topic.mails_to_notify() == [topic.first_post.poster.email]


class CertifiedPostModelTest(TestCase):
Expand Down
8 changes: 8 additions & 0 deletions lacommunaute/notification/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,16 @@ class EmailSentTrackKind(models.TextChoices):
ONBOARDING = "onboarding", "Onboarding d'un nouvel utilisateur"
PENDING_TOPIC = "pending_topic", "Question sans réponse"
MAGIC_LINK = "magic_link", "Lien de connexion magique"
BULK_NOTIFS = "bulk_notifs", "Notifications groupées"


class NotificationDelay(models.TextChoices):
ASAP = "asap", _("As soon as possible")
DAY = "day", _("The following day")


delay_of_notifications = {
EmailSentTrackKind.PENDING_TOPIC: NotificationDelay.DAY,
EmailSentTrackKind.FIRST_REPLY: NotificationDelay.ASAP,
EmailSentTrackKind.FOLLOWING_REPLIES: NotificationDelay.DAY,
}
12 changes: 9 additions & 3 deletions lacommunaute/notification/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from django.utils import timezone
from faker import Faker

from lacommunaute.notification.enums import EmailSentTrackKind
from lacommunaute.forum_conversation.factories import AnonymousPostFactory, TopicFactory
from lacommunaute.notification.enums import EmailSentTrackKind, NotificationDelay
from lacommunaute.notification.models import EmailSentTrack, Notification


Expand All @@ -24,12 +25,17 @@ class Meta:


class NotificationFactory(factory.django.DjangoModelFactory):
recipient = faker.email()
recipient = factory.Faker("email")
kind = EmailSentTrackKind.FIRST_REPLY
post = None
delay = NotificationDelay.DAY

class Meta:
model = Notification
skip_postgeneration_save = True

class Params:
is_sent = factory.Trait(sent_at=(timezone.now() - timedelta(days=random.randint(0, 90))))
set_post = factory.Trait(post=factory.LazyAttribute(lambda o: TopicFactory(with_post=True).first_post))
set_anonymous_post = factory.Trait(
post=factory.LazyAttribute(lambda o: AnonymousPostFactory(topic=TopicFactory()))
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Generated by Django 5.0.9 on 2024-11-27 13:57

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("notification", "0010_alter_emailsenttrack_kind_alter_notification_kind"),
]

operations = [
migrations.AlterField(
model_name="emailsenttrack",
name="kind",
field=models.CharField(
choices=[
("first_reply", "Première réponse à un sujet"),
("following_replies", "Réponses suivantes"),
("onboarding", "Onboarding d'un nouvel utilisateur"),
("pending_topic", "Question sans réponse"),
("magic_link", "Lien de connexion magique"),
("bulk_notifs", "Notifications groupées"),
],
max_length=20,
verbose_name="type",
),
),
migrations.AlterField(
model_name="notification",
name="kind",
field=models.CharField(
choices=[
("first_reply", "Première réponse à un sujet"),
("following_replies", "Réponses suivantes"),
("onboarding", "Onboarding d'un nouvel utilisateur"),
("pending_topic", "Question sans réponse"),
("magic_link", "Lien de connexion magique"),
("bulk_notifs", "Notifications groupées"),
],
max_length=20,
verbose_name="type",
),
),
]
12 changes: 9 additions & 3 deletions lacommunaute/notification/signals.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from lacommunaute.notification.enums import EmailSentTrackKind, NotificationDelay
from lacommunaute.notification.enums import EmailSentTrackKind, delay_of_notifications
from lacommunaute.notification.models import Notification


Expand All @@ -11,8 +11,14 @@ def create_post_notifications(sender, instance, **kwargs):
if not instance.approved:
return

delay = NotificationDelay.ASAP if instance.is_first_reply else NotificationDelay.DAY
kind = EmailSentTrackKind.FIRST_REPLY if instance.is_first_reply else EmailSentTrackKind.FOLLOWING_REPLIES
if instance.is_topic_head:
kind = EmailSentTrackKind.PENDING_TOPIC
elif instance.is_first_reply:
kind = EmailSentTrackKind.FIRST_REPLY
else:
kind = EmailSentTrackKind.FOLLOWING_REPLIES

delay = delay_of_notifications[kind]

notifications = [
Notification(recipient=email_address, post=instance, kind=kind, delay=delay)
Expand Down
9 changes: 4 additions & 5 deletions lacommunaute/notification/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from django.urls import reverse
from django.utils import timezone

from config.settings.base import DEFAULT_FROM_EMAIL, NEW_MESSAGES_EMAIL_MAX_PREVIEW, SIB_NEW_MESSAGES_TEMPLATE
from config.settings.base import NEW_MESSAGES_EMAIL_MAX_PREVIEW, SIB_NEW_MESSAGES_TEMPLATE
from lacommunaute.forum_conversation.models import Topic
from lacommunaute.forum_member.shortcuts import get_forum_member_display_name
from lacommunaute.notification.emails import bulk_send_user_to_list, send_email
Expand All @@ -17,7 +17,7 @@ def send_messages_notifications(delay: NotificationDelay):
"""Notifications are scheduled in the application and then processed later by this task"""

notifications = Notification.objects.filter(delay=delay, sent_at__isnull=True, post__isnull=False).select_related(
"post", "post__topic", "post__poster"
"post", "post__topic", "post__poster", "post__topic__forum", "post__topic__first_post"
)

def get_grouped_notifications():
Expand All @@ -34,10 +34,9 @@ def get_grouped_notifications():
"messages": get_serialized_messages(recipient_notifications[:NEW_MESSAGES_EMAIL_MAX_PREVIEW]),
}
send_email(
to=[{"email": DEFAULT_FROM_EMAIL}],
to=[{"email": recipient}],
params=params,
bcc=[{"email": recipient}],
kind=EmailSentTrackKind.FOLLOWING_REPLIES,
kind=EmailSentTrackKind.BULK_NOTIFS,
template_id=SIB_NEW_MESSAGES_TEMPLATE,
)

Expand Down
Loading

0 comments on commit 432fec2

Please sign in to comment.