Repository: WeblateOrg/weblate

Issue:
Duplicate notifications
### Describe the issue

When a change is made to an already translated string, two notifications are sent, one for the user's "Translated string" notification setting and one for the "Changed string" setting. Both (virtually identical) emails read: "Changed translation" (subject) and "Translation change" (body). (There is only one "Translation changed" entry in the history, but there are two notification emails).

Shouldn't "Translated string" only take effect if the string is newly translated, i.e. was previously untranslated? If not, how to distinguish between a new (i.e. first-time) and a changed translation (notification-wise)?

### I already tried

- [X] I've read and searched [the documentation](https://docs.weblate.org/).
- [X] I've searched for similar issues in this repository.

### Steps to reproduce the behavior

1. Go to user's Settings > Notification.
2. Enable notification for "Changed string" and "Translated string".
3. Wait until someone changes a translation.

### Expected behavior

When a change is made to an already translated string, only one "Changed string" notification should be sent.

### Screenshots

_No response_

### Exception traceback

_No response_

### How do you run Weblate?

weblate.org service

### Weblate versions

[Weblate 4.18.1](https://hosted.weblate.org/)

### Weblate deploy checks

_No response_

### Additional context

_No response_
Duplicate notifications
### Describe the issue

When a change is made to an already translated string, two notifications are sent, one for the user's "Translated string" notification setting and one for the "Changed string" setting. Both (virtually identical) emails read: "Changed translation" (subject) and "Translation change" (body). (There is only one "Translation changed" entry in the history, but there are two notification emails).

Shouldn't "Translated string" only take effect if the string is newly translated, i.e. was previously untranslated? If not, how to distinguish between a new (i.e. first-time) and a changed translation (notification-wise)?

### I already tried

- [X] I've read and searched [the documentation](https://docs.weblate.org/).
- [X] I've searched for similar issues in this repository.

### Steps to reproduce the behavior

1. Go to user's Settings > Notification.
2. Enable notification for "Changed string" and "Translated string".
3. Wait until someone changes a translation.

### Expected behavior

When a change is made to an already translated string, only one "Changed string" notification should be sent.

### Screenshots

_No response_

### Exception traceback

_No response_

### How do you run Weblate?

weblate.org service

### Weblate versions

[Weblate 4.18.1](https://hosted.weblate.org/)

### Weblate deploy checks

_No response_

### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/weblate/accounts/migrations/0026_alter_subscription_notification_and_more.py b/weblate/accounts/migrations/0026_alter_subscription_notification_and_more.py
new file mode 100644
--- /dev/null
+++ b/weblate/accounts/migrations/0026_alter_subscription_notification_and_more.py
@@ -0,0 +1,74 @@
+# Copyright © Michal Čihař <michal@weblate.org>
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Generated by Django 4.2.1 on 2023-06-29 18:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("accounts", "0025_profile_theme"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="subscription",
+            name="notification",
+            field=models.CharField(
+                choices=[
+                    ("RepositoryNotification", "Repository operation"),
+                    ("LockNotification", "Component locking"),
+                    ("LicenseNotification", "Changed license"),
+                    ("ParseErrorNotification", "Parse error"),
+                    ("NewStringNotificaton", "New string"),
+                    ("NewContributorNotificaton", "New contributor"),
+                    ("NewSuggestionNotificaton", "New suggestion"),
+                    ("NewCommentNotificaton", "New comment"),
+                    ("MentionCommentNotificaton", "Mentioned in comment"),
+                    ("LastAuthorCommentNotificaton", "Comment on own translation"),
+                    ("TranslatedStringNotificaton", "Translated string"),
+                    ("ChangedStringNotificaton", "Changed string"),
+                    ("ApprovedStringNotificaton", "Approved string"),
+                    ("NewTranslationNotificaton", "New language"),
+                    ("NewComponentNotificaton", "New translation component"),
+                    ("NewAnnouncementNotificaton", "New announcement"),
+                    ("NewAlertNotificaton", "New alert"),
+                    ("MergeFailureNotification", "Repository failure"),
+                    ("PendingSuggestionsNotification", "Pending suggestions"),
+                    ("ToDoStringsNotification", "Unfinished strings"),
+                ],
+                max_length=100,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="subscription",
+            name="notification",
+            field=models.CharField(
+                choices=[
+                    ("RepositoryNotification", "Repository operation"),
+                    ("LockNotification", "Component locking"),
+                    ("LicenseNotification", "Changed license"),
+                    ("ParseErrorNotification", "Parse error"),
+                    ("NewStringNotificaton", "New string"),
+                    ("NewContributorNotificaton", "New contributor"),
+                    ("NewSuggestionNotificaton", "New suggestion"),
+                    ("NewCommentNotificaton", "New comment"),
+                    ("MentionCommentNotificaton", "Mentioned in comment"),
+                    ("LastAuthorCommentNotificaton", "Comment on own translation"),
+                    ("TranslatedStringNotificaton", "Translated string"),
+                    ("ApprovedStringNotificaton", "Approved string"),
+                    ("ChangedStringNotificaton", "Changed string"),
+                    ("NewTranslationNotificaton", "New language"),
+                    ("NewComponentNotificaton", "New translation component"),
+                    ("NewAnnouncementNotificaton", "New announcement"),
+                    ("NewAlertNotificaton", "New alert"),
+                    ("MergeFailureNotification", "Repository failure"),
+                    ("PendingSuggestionsNotification", "Pending suggestions"),
+                    ("ToDoStringsNotification", "Unfinished strings"),
+                ],
+                max_length=100,
+            ),
+        ),
+    ]
diff --git a/weblate/accounts/notifications.py b/weblate/accounts/notifications.py
--- a/weblate/accounts/notifications.py
+++ b/weblate/accounts/notifications.py
@@ -5,7 +5,7 @@
 from collections import defaultdict
 from copy import copy
 from email.utils import formataddr
-from typing import Iterable, Optional
+from typing import Any, Iterable, List, Optional
 
 from dateutil.relativedelta import relativedelta
 from django.conf import settings
@@ -84,10 +84,12 @@ class Notification:
     ignore_watched: bool = False
     any_watched: bool = False
     required_attr: Optional[str] = None
+    skip_when_notify: List[Any] = []
 
     def __init__(self, outgoing, perm_cache=None):
         self.outgoing = outgoing
         self.subscription_cache = {}
+        self.child_notify = None
         if perm_cache is not None:
             self.perm_cache = perm_cache
         else:
@@ -339,8 +341,24 @@ def send_immediate(
                 self.get_headers(context),
             )
 
+    def _convert_change_skip(self, change):
+        return change
+
     def should_skip(self, user, change):
-        return False
+        if not self.skip_when_notify:
+            return False
+        if self.child_notify is None:
+            self.child_notify = [
+                notify_class(None, self.perm_cache)
+                for notify_class in self.skip_when_notify
+            ]
+        converted_change = self._convert_change_skip(change)
+        return any(
+            list(
+                child_notify.get_users(FREQ_INSTANT, converted_change, users=[user.pk])
+            )
+            for child_notify in self.child_notify
+        )
 
     def notify_immediate(self, change):
         for user in self.get_users(FREQ_INSTANT, change):
@@ -414,32 +432,6 @@ def notify_monthly(self):
         self.notify_digest(FREQ_MONTHLY, self.filter_changes(months=1))
 
 
-@register_notification
-class MergeFailureNotification(Notification):
-    actions = (
-        Change.ACTION_FAILED_MERGE,
-        Change.ACTION_FAILED_REBASE,
-        Change.ACTION_FAILED_PUSH,
-    )
-    # Translators: Notification name
-    verbose = gettext_lazy("Repository failure")
-    template_name = "repository_error"
-
-    def __init__(self, outgoing, perm_cache=None):
-        super().__init__(outgoing, perm_cache)
-        self.fake_notify = None
-
-    def should_skip(self, user, change):
-        fake = copy(change)
-        fake.action = Change.ACTION_ALERT
-        fake.alert = Alert(name="MergeFailure", details={"error": ""})
-        if self.fake_notify is None:
-            self.fake_notify = NewAlertNotificaton(None, self.perm_cache)
-        return bool(
-            list(self.fake_notify.get_users(FREQ_INSTANT, fake, users=[user.pk]))
-        )
-
-
 @register_notification
 class RepositoryNotification(Notification):
     actions = (
@@ -520,39 +512,26 @@ class NewSuggestionNotificaton(Notification):
 
 
 @register_notification
-class LastAuthorCommentNotificaton(Notification):
+class NewCommentNotificaton(Notification):
     actions = (Change.ACTION_COMMENT,)
     # Translators: Notification name
-    verbose = gettext_lazy("Comment on own translation")
+    verbose = gettext_lazy("New comment")
     template_name = "new_comment"
-    ignore_watched = True
+    filter_languages = True
     required_attr = "comment"
 
-    def __init__(self, outgoing, perm_cache=None):
-        super().__init__(outgoing, perm_cache)
-        self.fake_notify = None
+    def get_language_filter(self, change, translation):
+        if not change.comment.unit.is_source:
+            return translation.language
+        return None
 
-    def should_skip(self, user, change):
-        if self.fake_notify is None:
-            self.fake_notify = MentionCommentNotificaton(None, self.perm_cache)
-        return bool(
-            list(self.fake_notify.get_users(FREQ_INSTANT, change, users=[user.pk]))
-        )
+    def notify_immediate(self, change):
+        super().notify_immediate(change)
 
-    def get_users(
-        self,
-        frequency,
-        change=None,
-        project=None,
-        component=None,
-        translation=None,
-        users=None,
-    ):
-        last_author = change.unit.get_last_content_change()[0]
-        users = [] if last_author.is_anonymous else [last_author.pk]
-        return super().get_users(
-            frequency, change, project, component, translation, users
-        )
+        # Notify upstream
+        report_source_bugs = change.component.report_source_bugs
+        if change.comment and change.comment.unit.is_source and report_source_bugs:
+            self.send_immediate("en", report_source_bugs, change)
 
 
 @register_notification
@@ -563,17 +542,7 @@ class MentionCommentNotificaton(Notification):
     template_name = "new_comment"
     ignore_watched = True
     required_attr = "comment"
-
-    def __init__(self, outgoing, perm_cache=None):
-        super().__init__(outgoing, perm_cache)
-        self.fake_notify = None
-
-    def should_skip(self, user, change):
-        if self.fake_notify is None:
-            self.fake_notify = NewCommentNotificaton(None, self.perm_cache)
-        return bool(
-            list(self.fake_notify.get_users(FREQ_INSTANT, change, users=[user.pk]))
-        )
+    skip_when_notify = [NewCommentNotificaton]
 
     def get_users(
         self,
@@ -599,35 +568,29 @@ def get_users(
 
 
 @register_notification
-class NewCommentNotificaton(Notification):
+class LastAuthorCommentNotificaton(Notification):
     actions = (Change.ACTION_COMMENT,)
     # Translators: Notification name
-    verbose = gettext_lazy("New comment")
+    verbose = gettext_lazy("Comment on own translation")
     template_name = "new_comment"
-    filter_languages = True
+    ignore_watched = True
     required_attr = "comment"
+    skip_when_notify = [MentionCommentNotificaton]
 
-    def get_language_filter(self, change, translation):
-        if not change.comment.unit.is_source:
-            return translation.language
-        return None
-
-    def notify_immediate(self, change):
-        super().notify_immediate(change)
-
-        # Notify upstream
-        report_source_bugs = change.component.report_source_bugs
-        if change.comment and change.comment.unit.is_source and report_source_bugs:
-            self.send_immediate("en", report_source_bugs, change)
-
-
-@register_notification
-class ChangedStringNotificaton(Notification):
-    actions = Change.ACTIONS_CONTENT
-    # Translators: Notification name
-    verbose = gettext_lazy("Changed string")
-    template_name = "changed_translation"
-    filter_languages = True
+    def get_users(
+        self,
+        frequency,
+        change=None,
+        project=None,
+        component=None,
+        translation=None,
+        users=None,
+    ):
+        last_author = change.unit.get_last_content_change()[0]
+        users = [] if last_author.is_anonymous else [last_author.pk]
+        return super().get_users(
+            frequency, change, project, component, translation, users
+        )
 
 
 @register_notification
@@ -648,6 +611,16 @@ class ApprovedStringNotificaton(Notification):
     filter_languages = True
 
 
+@register_notification
+class ChangedStringNotificaton(Notification):
+    actions = Change.ACTIONS_CONTENT
+    # Translators: Notification name
+    verbose = gettext_lazy("Changed string")
+    template_name = "changed_translation"
+    filter_languages = True
+    skip_when_notify = [TranslatedStringNotificaton, ApprovedStringNotificaton]
+
+
 @register_notification
 class NewTranslationNotificaton(Notification):
     actions = (Change.ACTION_ADDED_LANGUAGE, Change.ACTION_REQUESTED_LANGUAGE)
@@ -724,6 +697,25 @@ def should_skip(self, user, change):
         return False
 
 
+@register_notification
+class MergeFailureNotification(Notification):
+    actions = (
+        Change.ACTION_FAILED_MERGE,
+        Change.ACTION_FAILED_REBASE,
+        Change.ACTION_FAILED_PUSH,
+    )
+    # Translators: Notification name
+    verbose = gettext_lazy("Repository failure")
+    template_name = "repository_error"
+    skip_when_notify = [NewAlertNotificaton]
+
+    def _convert_change_skip(self, change):
+        fake = copy(change)
+        fake.action = Change.ACTION_ALERT
+        fake.alert = Alert(name="MergeFailure", details={"error": ""})
+        return fake
+
+
 class SummaryNotification(Notification):
     filter_languages = True
 


Test patch:
diff --git a/weblate/accounts/tests/test_notifications.py b/weblate/accounts/tests/test_notifications.py
--- a/weblate/accounts/tests/test_notifications.py
+++ b/weblate/accounts/tests/test_notifications.py
@@ -223,8 +223,8 @@ def test_notify_new_translation(self):
             action=Change.ACTION_CHANGE,
         )
 
-        # Check mail - ChangedStringNotificaton and TranslatedStringNotificaton
-        self.validate_notifications(2, "[Weblate] New translation in Test/Test — Czech")
+        # Check mail - TranslatedStringNotificaton
+        self.validate_notifications(1, "[Weblate] New translation in Test/Test — Czech")
 
     def test_notify_approved_translation(self):
         Change.objects.create(
@@ -234,11 +234,10 @@ def test_notify_approved_translation(self):
             action=Change.ACTION_APPROVE,
         )
 
-        # Check mail - ChangedStringNotificaton and ApprovedStringNotificaton
+        # Check mail - ApprovedStringNotificaton
         self.validate_notifications(
-            2,
+            1,
             subjects=[
-                "[Weblate] New translation in Test/Test — Czech",
                 "[Weblate] Approved translation in Test/Test — Czech",
             ],
         )
@@ -328,9 +327,8 @@ def test_notify_new_comment_author(self):
         change = self.get_unit().recent_content_changes[0]
         change.user = self.anotheruser
         change.save()
-        # Notification for other user edit
-        # ChangedStringNotificaton and TranslatedStringNotificaton
-        self.assertEqual(len(mail.outbox), 2)
+        # Notification for other user edit via  TranslatedStringNotificaton
+        self.assertEqual(len(mail.outbox), 1)
         mail.outbox = []
 
     def test_notify_new_component(self):