Repository: zestedesavoir/zds-site

Issue:
Notification persistante liée à la publication d'un billet
En version `v28.2b-feve/ec79eed`.

J'ai une notification persistante vers un billet, notification provenant de moi-même (déjà ça c'est un peu étrange mais soit). Le contexte du billet est particulier : juste après sa publication, mais _avant_ de cliquer sur la notification et de visiter le billet, je suis allé sur la version brouillon me retirer des auteurs (car je n'avais rien fait).

Cela dit, [la version en ligne me compte toujours comme auteur (je suis dans la liste publique)](https://zestedesavoir.com/articles/3327/recap-communautaire-21-novembre-2019/).

![Oh ma belle notification](https://user-images.githubusercontent.com/1417570/70189692-42fe1300-16f4-11ea-94e2-a5bf9108aa76.png)

Une possibilité (à vérifier) : je me demande si je ne m'auto-suivais pas à une période (pour voir si ça marchait et c'est peut-être resté), donc potentiellement lié à ça. Donc potentielles étapes pour tenter de reproduire : 

1. On suit une personne.
2. Cette personne publie un contenu.
3. On reçoit une notification pour ça, vu qu'on est abonné⋅e. Mais on ne clique pas dessus.
4. Cette personne se retire des auteurs dans la version brouillon.
5. On clique sur la notification. Peut-être, alors, persiste-t-elle.

Peut-être lié à #4972.


Discussion:
Je l'ai reproduit en bêta avec Amaury en suivant son protocole !
On a testé avec @Situphen et c'est bien ça, on reproduit la persistance avec ces cinq étapes

![image](https://user-images.githubusercontent.com/1417570/70190646-bf91f100-16f6-11ea-8b42-1a36a9905b69.png)


Resolving patch:
diff --git a/zds/notification/managers.py b/zds/notification/managers.py
--- a/zds/notification/managers.py
+++ b/zds/notification/managers.py
@@ -1,3 +1,4 @@
+from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import models
@@ -157,6 +158,22 @@ def deactivate_subscriptions(self, user, _object):
             subscription.save(update_fields=["is_active"])
 
 
+class NewPublicationSubscriptionManager(SubscriptionManager):
+    def get_objects_followed_by(self, user):
+        """
+        Gets objects followed by the given user.
+
+        :param user: concerned user.
+        :type user: django.contrib.auth.models.User
+        :return: All objects followed by given user.
+        """
+        user_list = self.filter(
+            user=user, is_active=True, content_type=ContentType.objects.get_for_model(User)
+        ).values_list("object_id", flat=True)
+
+        return User.objects.filter(id__in=user_list)
+
+
 class NewTopicSubscriptionManager(SubscriptionManager):
     def mark_read_everybody_at(self, topic):
         """
diff --git a/zds/notification/models.py b/zds/notification/models.py
--- a/zds/notification/models.py
+++ b/zds/notification/models.py
@@ -17,6 +17,7 @@
     SubscriptionManager,
     TopicFollowedManager,
     TopicAnswerSubscriptionManager,
+    NewPublicationSubscriptionManager,
     NewTopicSubscriptionManager,
 )
 from zds.utils.misc import convert_camel_to_underscore
@@ -349,7 +350,7 @@ class NewPublicationSubscription(Subscription, MultipleNotificationsMixin):
     """
 
     module = _("Contenu")
-    objects = SubscriptionManager()
+    objects = NewPublicationSubscriptionManager()
 
     def __str__(self):
         return _('<Abonnement du membre "{0}" aux nouvelles publications de l\'utilisateur #{1}>').format(
diff --git a/zds/notification/receivers.py b/zds/notification/receivers.py
--- a/zds/notification/receivers.py
+++ b/zds/notification/receivers.py
@@ -157,13 +157,20 @@ def mark_content_reactions_read(sender, *, instance, user=None, target, **__):
             subscription = ContentReactionAnswerSubscription.objects.get_existing(user, instance, is_active=True)
             if subscription:
                 subscription.mark_notification_read()
-    elif target == PublishableContent:
-        authors = list(instance.authors.all())
-        for author in authors:
-            subscription = NewPublicationSubscription.objects.get_existing(user, author)
-            # a subscription has to be handled only if it is active OR if it was triggered from the publication
-            # event that creates an "autosubscribe" which is immediately deactivated.
-            if subscription and (subscription.is_active or subscription.user in authors):
+    elif target == PublishableContent and user is not None:
+        # We cannot use the list of authors of the content, because the user we
+        # are subscribed to may have left the authorship of the content (see issue #5544).
+        followed_users = list(NewPublicationSubscription.objects.get_objects_followed_by(user))
+        if user not in followed_users:
+            # When a content is published, their authors are subscribed for
+            # notifications of their own publications, but these subscriptions
+            # are not activated (see receiver for signal content_published).
+            # Since followed_users contains only active subscriptions, current
+            # user should not be in it, so we add it manually:
+            followed_users.append(user)
+        for followed_user in followed_users:
+            subscription = NewPublicationSubscription.objects.get_existing(user, followed_user)
+            if subscription:
                 subscription.mark_notification_read(content=instance)
 
 


Test patch:
diff --git a/zds/notification/tests/tests_basics.py b/zds/notification/tests/tests_basics.py
--- a/zds/notification/tests/tests_basics.py
+++ b/zds/notification/tests/tests_basics.py
@@ -35,7 +35,8 @@
     PublishedContentFactory,
 )
 from zds.tutorialv2.models.database import ContentReaction, PublishableContent
-from zds.tutorialv2.publication_utils import publish_content
+from zds.tutorialv2.publication_utils import notify_update, publish_content
+from zds.tutorialv2.tests import override_for_contents, TutorialTestMixin
 from zds.utils import old_slugify
 from zds.utils.tests.factories import SubCategoryFactory, LicenceFactory
 from zds.mp.utils import send_mp, send_message_mp
@@ -535,8 +536,11 @@ def test_remove_subscribed_tag(self):
         self.assertEqual(1, len(Notification.objects.filter(object_id=topic.pk, is_read=False, is_dead=True).all()))
 
 
-class NotificationPublishableContentTest(TestCase):
+@override_for_contents()
+class NotificationPublishableContentTest(TutorialTestMixin, TestCase):
     def setUp(self):
+        self.overridden_zds_app["member"]["bot_account"] = ProfileFactory().user.username
+
         self.user1 = ProfileFactory().user
         self.user2 = ProfileFactory().user
 
@@ -685,6 +689,155 @@ def test_notification_generated_when_a_tuto_is_published(self):
         notifications = Notification.objects.filter(subscription=subscription, is_read=False).all()
         self.assertEqual(0, len(notifications))
 
+    def test_no_persistant_notif_when_follow_and_quit_authorship(self):
+        """
+        Related to #5544. The following scenario is tested:
+        1. user1 follows user2
+        2. user2 publishes a content written with user3
+        3. user1 gets a notification, but don't click on it
+        4. user2 quits authorship of the content (but is still an author of the published version)
+        5. user1 clicks on the notification, the notification should disappear
+        """
+
+        user1 = ProfileFactory().user
+        user2 = ProfileFactory().user
+        user3 = ProfileFactory().user
+
+        # user1 follows user2
+        self.client.logout()
+        self.client.force_login(user1)
+        result = self.client.post(
+            reverse("content:follow", args=[user2.pk]),
+            {"follow": 1},
+            follow=False,
+            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+        )
+        self.assertEqual(result.status_code, 200)
+
+        # user2 publishes a content written with user3
+        tuto = PublishableContentFactory(type="TUTORIAL")
+        tuto.authors.add(user2)
+        tuto.authors.add(user3)
+        UserGalleryFactory(gallery=tuto.gallery, user=user2, mode="W")
+        tuto.licence = LicenceFactory()
+        tuto.subcategory.add(SubCategoryFactory())
+        tuto.save()
+        tuto_draft = tuto.load_version()
+        version = tuto_draft.current_version
+        published = publish_content(tuto, tuto_draft, is_major_update=True)
+        tuto.sha_public = version
+        tuto.sha_draft = version
+        tuto.public_version = published
+        tuto.save()
+        notify_update(tuto, is_update=False, is_major=True)
+
+        # all users get a notification:
+        self.assertEqual(1, Notification.objects.get_unread_notifications_of(user1).count())
+        self.assertEqual(1, Notification.objects.get_unread_notifications_of(user2).count())
+        self.assertEqual(1, Notification.objects.get_unread_notifications_of(user3).count())
+
+        # user2 quits authorship of the content
+        self.client.logout()
+        self.client.force_login(user2)
+        result = self.client.post(
+            reverse("content:remove-author", args=[tuto.pk]), {"username": user2.username}, follow=False
+        )
+        self.assertEqual(result.status_code, 302)
+
+        # user1 still has the notification
+        notifications = Notification.objects.get_unread_notifications_of(user1)
+        self.assertEqual(1, notifications.count())
+
+        # user1 marks the notification as read by visiting the content
+        self.client.logout()
+        self.client.force_login(user1)
+        result = self.client.get(notifications[0].url)
+        self.assertEqual(result.status_code, 200)
+
+        # the notification is read now
+        self.assertEqual(0, Notification.objects.get_unread_notifications_of(user1).count())
+
+    def test_no_persistant_notif_when_follow_and_quit_authorship_and_publish(self):
+        """
+        Related to #5544. Similar to
+        test_no_persistant_notif_when_follow_and_quit_authorship, but this time
+        the content is published. The following scenario is tested:
+        1. user1 follows user2
+        2. user2 publishes a content written with user3
+        3. user1 gets a notification, but don't click on it
+        4. user2 quits authorship of the content
+        5. user3 publishes the content
+        6. user1 clicks on the notification, the notification should disappear
+        """
+
+        user1 = ProfileFactory().user
+        user2 = ProfileFactory().user
+        user3 = ProfileFactory().user
+
+        # user1 follows user2
+        self.client.logout()
+        self.client.force_login(user1)
+        result = self.client.post(
+            reverse("content:follow", args=[user2.pk]),
+            {"follow": 1},
+            follow=False,
+            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+        )
+        self.assertEqual(result.status_code, 200)
+
+        # user2 publishes a content written with user3
+        tuto = PublishableContentFactory(type="TUTORIAL")
+        tuto.authors.add(user2)
+        tuto.authors.add(user3)
+        UserGalleryFactory(gallery=tuto.gallery, user=user2, mode="W")
+        tuto.licence = LicenceFactory()
+        tuto.subcategory.add(SubCategoryFactory())
+        tuto.save()
+        tuto_draft = tuto.load_version()
+        version = tuto_draft.current_version
+        published = publish_content(tuto, tuto_draft, is_major_update=True)
+        tuto.sha_public = version
+        tuto.sha_draft = version
+        tuto.public_version = published
+        tuto.save()
+        notify_update(tuto, is_update=False, is_major=True)
+
+        # all users get a notification:
+        self.assertEqual(1, Notification.objects.get_unread_notifications_of(user1).count())
+        self.assertEqual(1, Notification.objects.get_unread_notifications_of(user2).count())
+        self.assertEqual(1, Notification.objects.get_unread_notifications_of(user3).count())
+
+        # user2 quits authorship of the content
+        self.client.logout()
+        self.client.force_login(user2)
+        result = self.client.post(
+            reverse("content:remove-author", args=[tuto.pk]), {"username": user2.username}, follow=False
+        )
+        self.assertEqual(result.status_code, 302)
+
+        # user3 publishes the content
+        tuto_draft = tuto.load_version()
+        version = tuto_draft.current_version
+        published = publish_content(tuto, tuto_draft, is_major_update=True)
+        tuto.sha_public = version
+        tuto.sha_draft = version
+        tuto.public_version = published
+        tuto.save()
+        notify_update(tuto, is_update=False, is_major=True)
+
+        # user1 still has the notification
+        notifications = Notification.objects.get_unread_notifications_of(user1)
+        self.assertEqual(1, notifications.count())
+
+        # user1 marks the notification as read by visiting the content
+        self.client.logout()
+        self.client.force_login(user1)
+        result = self.client.get(notifications[0].url)
+        self.assertEqual(result.status_code, 200)
+
+        # the notification is read now
+        self.assertEqual(0, Notification.objects.get_unread_notifications_of(user1).count())
+
     def test_no_error_on_multiple_subscription(self):
         subscription = NewPublicationSubscription.objects.toggle_follow(self.user1, self.user2)
 
diff --git a/zds/tutorialv2/tests/tests_opinion_views.py b/zds/tutorialv2/tests/tests_opinion_views.py
--- a/zds/tutorialv2/tests/tests_opinion_views.py
+++ b/zds/tutorialv2/tests/tests_opinion_views.py
@@ -11,6 +11,7 @@
 from zds.forum.tests.factories import TagFactory
 from zds.gallery.tests.factories import UserGalleryFactory
 from zds.member.tests.factories import ProfileFactory, StaffProfileFactory, UserFactory
+from zds.notification.models import Notification
 from zds.tutorialv2.tests.factories import (
     PublishableContentFactory,
     ExtractFactory,
@@ -80,6 +81,11 @@ def test_opinion_publication_author(self, opinions_management):
         self.assertIsNotNone(opinion.public_version)
         self.assertEqual(opinion.public_version.sha_public, opinion_draft.current_version)
 
+        # By visiting the published content, the author marks the publication notification as read:
+        self.assertEqual(Notification.objects.get_unread_notifications_of(self.user_author).count(), 1)
+        self.client.get(result.url)
+        self.assertEqual(Notification.objects.get_unread_notifications_of(self.user_author).count(), 0)
+
     @patch("zds.tutorialv2.signals.opinions_management")
     def test_publish_content_change_title_before_watchdog(self, opinions_management):
         """