← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~gary/launchpad/muteteamsub-email into lp:launchpad/db-devel

 

Gary Poster has proposed merging lp:~gary/launchpad/muteteamsub-email into lp:launchpad/db-devel with lp:~gmb/launchpad/team-subscription-opt-out as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~gary/launchpad/muteteamsub-email/+merge/56154

This branch teaches the email sending code to honor the per-person structural subscription mutes.

It does so via a single additional SQL query per batch of notifications (not per-source or per recipient).

As the interface change describes in lib/lp/bugs/interfaces/bugnotification.py, IBugNotificationSet.getRecipientFilterData now may omit recipients that it receives. This is already honored in lib/lp/bugs/scripts/bugnotification.py, construct_email_notifications, because we use the output of the method to determine what emails to send.

    recipients = getUtility(IBugNotificationSet).getRecipientFilterData(
        recipients, filtered_notifications)

The actual code change is in lib/lp/bugs/model/bugnotification.py. I'll leave it to try and speak for itself.

I added an __init__ to BugSubscriptionFilterMutes just for test convenience in lib/lp/bugs/model/bugsubscriptionfilter.py.

I added several new tests, most of them for the method in isolation but a couple of it as used by construct_email_notifications. I refactored some existing tests to make a couple of helper methods that they and the new tests could use.

That's pretty much it.

Thanks

Gary
-- 
https://code.launchpad.net/~gary/launchpad/muteteamsub-email/+merge/56154
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~gary/launchpad/muteteamsub-email into lp:launchpad/db-devel.
=== modified file 'lib/lp/bugs/interfaces/bugnotification.py'
--- lib/lp/bugs/interfaces/bugnotification.py	2011-04-04 06:11:16 +0000
+++ lib/lp/bugs/interfaces/bugnotification.py	2011-04-04 13:05:18 +0000
@@ -88,6 +88,10 @@
             (BugNotificationRecipients) that represent the subscriptions that
             caused the notifications to be sent.
         :param notifications: the notifications that are being communicated.
+    
+        The dict of recipients may have fewer recipients than were
+        provided if those users muted all of the subscription filters
+        that caused them to be sent.
         """
 
 

=== modified file 'lib/lp/bugs/model/bugnotification.py'
--- lib/lp/bugs/model/bugnotification.py	2011-04-04 06:11:16 +0000
+++ lib/lp/bugs/model/bugnotification.py	2011-04-04 13:05:18 +0000
@@ -54,7 +54,10 @@
     IBugNotificationSet,
     )
 from lp.bugs.model.bugactivity import BugActivity
-from lp.bugs.model.bugsubscriptionfilter import BugSubscriptionFilter
+from lp.bugs.model.bugsubscriptionfilter import (
+    BugSubscriptionFilter,
+    BugSubscriptionFilterMute,
+    )
 from lp.bugs.model.structuralsubscription import StructuralSubscription
 from lp.registry.interfaces.person import IPersonSet
 from lp.services.database.stormbase import StormBase
@@ -191,9 +194,10 @@
         if not notifications or not recipient_to_sources:
             # This is a shortcut that will remove some error conditions.
             return {}
-        # This makes one call to the database to get all the information
-        # we need. We get the filter ids and descriptions for each
-        # source, and then we divide up the information per recipient.
+        # This makes two calls to the database to get all the
+        # information we need. The first call gets the filter ids and
+        # descriptions for each recipient, and then we divide up the
+        # information per recipient.
         # First we get some intermediate data structures set up.
         source_person_id_map = {}
         recipient_id_map = {}
@@ -241,22 +245,36 @@
             source_person_id_map[source_person_id]['filters'][filter_id] = (
                 filter_description)
             filter_ids.append(filter_id)
+        no_filter_marker = -1 # This is only necessary while production and
+        # sample data have structural subscriptions without filters.
         # Assign the filters to each recipient.
         for recipient_data in recipient_id_map.values():
             for source_person_id in recipient_data['source person ids']:
                 recipient_data['filters'].update(
-                    source_person_id_map[source_person_id]['filters'])
+                    source_person_id_map[source_person_id]['filters']
+                    or {no_filter_marker: None})
+        if filter_ids:
+            # Now we get the information about subscriptions that might be
+            # filtered and take that into account.
+            mute_data = store.find(
+                (BugSubscriptionFilterMute.person_id,
+                 BugSubscriptionFilterMute.filter_id),
+                In(BugSubscriptionFilterMute.person_id, recipient_id_map.keys()),
+                In(BugSubscriptionFilterMute.filter_id, filter_ids))
+            for person_id, filter_id in mute_data:
+                del recipient_id_map[person_id]['filters'][filter_id]
         # Now recipient_id_map has all the information we need.  Let's
         # build the final result and return it.
         result = {}
         for recipient_data in recipient_id_map.values():
-            filter_descriptions = [
-                description for description
-                in recipient_data['filters'].values() if description]
-            filter_descriptions.sort() # This is good for tests.
-            result[recipient_data['principal']] = {
-                'sources': recipient_data['sources'],
-                'filter descriptions': filter_descriptions}
+            if recipient_data['filters']:
+                filter_descriptions = [
+                    description for description
+                    in recipient_data['filters'].values() if description]
+                filter_descriptions.sort() # This is good for tests.
+                result[recipient_data['principal']] = {
+                    'sources': recipient_data['sources'],
+                    'filter descriptions': filter_descriptions}
         return result
 
 

=== modified file 'lib/lp/bugs/model/bugsubscriptionfilter.py'
--- lib/lp/bugs/model/bugsubscriptionfilter.py	2011-04-04 13:05:15 +0000
+++ lib/lp/bugs/model/bugsubscriptionfilter.py	2011-04-04 13:05:18 +0000
@@ -257,6 +257,12 @@
 
     __storm_table__ = "BugSubscriptionFilterMute"
 
+    def __init__(self, person=None, filter=None):
+        if person is not None:
+            self.person = person
+        if filter is not None:
+            self.filter = filter.id
+
     person_id = Int("person", allow_none=False, validator=validate_person)
     person = Reference(person_id, "Person.id")
 

=== modified file 'lib/lp/bugs/scripts/tests/test_bugnotification.py'
--- lib/lp/bugs/scripts/tests/test_bugnotification.py	2011-04-04 06:11:16 +0000
+++ lib/lp/bugs/scripts/tests/test_bugnotification.py	2011-04-04 13:05:18 +0000
@@ -49,6 +49,7 @@
     BugNotificationFilter,
 #    BugNotificationRecipient,
     )
+from lp.bugs.model.bugsubscriptionfilter import BugSubscriptionFilterMute
 from lp.bugs.model.bugtask import BugTask
 from lp.bugs.scripts.bugnotification import (
     construct_email_notifications,
@@ -904,7 +905,7 @@
 
 
 class TestEmailNotificationsWithFilters(TestCaseWithFactory):
-    """Ensure outgoing mails have corresponding mail headers.
+    """Ensure outgoing mails have corresponding headers, accounting for mutes.
 
     Every filter that could have potentially caused a notification to
     go off has a `BugNotificationFilter` record linking a `BugNotification`
@@ -912,6 +913,11 @@
 
     From those records, we include all BugSubscriptionFilter.description
     in X-Subscription-Filter-Description headers in each email.
+
+    Every team filter that caused notifications might be muted for a
+    given recipient.  These can cause headers to be omitted, and if all
+    filters that caused the notifications are omitted then the
+    notification itself will not be sent.
     """
 
     layer = LaunchpadZopelessLayer
@@ -1070,3 +1076,24 @@
         self.assertContentEqual(
             [u"Matching subscriptions: First filter, Second filter"],
             self.getSubscriptionEmailBody())
+
+    def test_muted(self):
+        bug_filter = self.addFilter(u"Test filter")
+        BugSubscriptionFilterMute(
+            person=self.subscription.subscriber,
+            filter=self.notification.bug_filters.one())
+        filtered, omitted, messages = construct_email_notifications(
+            [self.notification])
+        self.assertEqual(list(messages), [])
+
+    def test_header_multiple_one_muted(self):
+        # Multiple filters with a description make all emails
+        # include all filter descriptions in the header.
+        bug_filter = self.addFilter(u"First filter")
+        bug_filter = self.addFilter(u"Second filter")
+        BugSubscriptionFilterMute(
+            person=self.subscription.subscriber,
+            filter=self.notification.bug_filters[0])
+
+        self.assertContentEqual([u"Second filter"],
+                                self.getSubscriptionEmailHeaders())

=== modified file 'lib/lp/bugs/tests/test_bugnotification.py'
--- lib/lp/bugs/tests/test_bugnotification.py	2011-04-04 06:11:16 +0000
+++ lib/lp/bugs/tests/test_bugnotification.py	2011-04-04 13:05:18 +0000
@@ -33,6 +33,7 @@
     BugNotificationFilter,
     BugNotificationSet,
     )
+from lp.bugs.model.bugsubscriptionfilter import BugSubscriptionFilterMute
 from lp.testing import TestCaseWithFactory
 from lp.testing.factory import LaunchpadObjectFactory
 from lp.testing.mail_helpers import pop_notifications
@@ -175,6 +176,32 @@
         self.addNotificationRecipient(notification, person)
         return notification
 
+    def includeFilterInNotification(self, description=None, subscription=None,
+                                    notification=None,
+                                    create_new_filter=False):
+        if subscription is None:
+            subscription = self.subscription
+        if notification is None:
+            notification = self.notification
+        if create_new_filter:
+            bug_filter = subscription.newBugFilter()
+        else:
+            bug_filter = subscription.bug_filters.one()
+        if description is not None:
+            bug_filter.description = description
+        BugNotificationFilter(
+            bug_notification=notification,
+            bug_subscription_filter=bug_filter)
+
+    def prepareTwoNotificationsWithFilters(self):
+        # Set up first notification and filter.
+        self.includeFilterInNotification(description=u'Special Filter!')
+        # Set up second notification and filter.
+        self.notification2 = self.addNotification(self.subscriber)
+        self.includeFilterInNotification(description=u'Another Filter!',
+                                         create_new_filter=True,
+                                         notification=self.notification2)
+
     def test_bug_filters_empty(self):
         # When there are no linked bug filters, it returns a ResultSet
         # with no entries.
@@ -182,11 +209,8 @@
 
     def test_bug_filters_single(self):
         # With a linked BugSubscriptionFilter, it is returned.
-        bug_filter = self.subscription.bug_filters.one()
-        BugNotificationFilter(
-            bug_notification=self.notification,
-            bug_subscription_filter=bug_filter)
-        self.assertContentEqual([bug_filter],
+        self.includeFilterInNotification()
+        self.assertContentEqual([self.subscription.bug_filters.one()],
                                 self.notification.bug_filters)
 
     def test_bug_filters_multiple(self):
@@ -217,32 +241,29 @@
     def test_getRecipientFilterData_other_persons(self):
         # When there is no named bug filter for the recipient,
         # it returns the recipient but with no filter descriptions.
-        BugNotificationFilter(
-            bug_notification=self.notification,
-            bug_subscription_filter=self.subscription.bug_filters.one())
+        self.includeFilterInNotification()
         subscriber2 = self.factory.makePerson()
         subscription2 = self.bug.default_bugtask.target.addSubscription(
             subscriber2, subscriber2)
-        bug_filter = subscription2.bug_filters.one()
-        bug_filter.description = u'Special Filter!'
-        BugNotificationFilter(
-            bug_notification=self.notification,
-            bug_subscription_filter=bug_filter)
+        notification2 = self.addNotification(subscriber2)
+        self.includeFilterInNotification(subscription=subscription2,
+                                         description=u'Special Filter!',
+                                         notification=notification2)
         sources = list(self.notification.recipients)
+        sources2 = list(notification2.recipients)
         self.assertEqual(
             {self.subscriber: {'sources': sources,
-                               'filter descriptions': []}},
+                               'filter descriptions': []},
+             subscriber2: {'sources': sources2, 
+                           'filter descriptions': [u'Special Filter!']}},
             BugNotificationSet().getRecipientFilterData(
-                {self.subscriber: sources}, [self.notification]))
+                {self.subscriber: sources, subscriber2: sources2},
+                [self.notification, notification2]))
 
     def test_getRecipientFilterData_match(self):
         # When there are bug filters for the recipient,
         # only those filters are returned.
-        bug_filter = self.subscription.bug_filters.one()
-        bug_filter.description = u'Special Filter!'
-        BugNotificationFilter(
-            bug_notification=self.notification,
-            bug_subscription_filter=bug_filter)
+        self.includeFilterInNotification(description=u'Special Filter!')
         sources = list(self.notification.recipients)
         self.assertEqual(
             {self.subscriber: {'sources': sources,
@@ -253,32 +274,89 @@
     def test_getRecipientFilterData_multiple_notifications_match(self):
         # When there are bug filters for the recipient for multiple
         # notifications, return filters for all the notifications.
-        # Set up first notification and filter.
-        bug_filter = self.subscription.bug_filters.one()
-        bug_filter.description = u'Special Filter!'
-        BugNotificationFilter(
-            bug_notification=self.notification,
-            bug_subscription_filter=bug_filter)
-        # Set up second notification and filter.
-        notification2 = self.addNotification(self.subscriber)
-        bug_filter2 = self.subscription.newBugFilter()
-        bug_filter2.description = u'Another Filter!'
-        BugNotificationFilter(
-            bug_notification=notification2,
-            bug_subscription_filter=bug_filter2)
+        self.prepareTwoNotificationsWithFilters()
+        # Perform the test.
         sources = list(self.notification.recipients)
-        sources.extend(notification2.recipients)
-        # This first check is really just for the internal consistency of the
-        # test.
-        self.assertEqual(len(sources), 2)
-        # Perform the test.
+        sources.extend(self.notification2.recipients)
+        assert(len(sources)==2)
         self.assertEqual(
             {self.subscriber: {'sources': sources,
              'filter descriptions': ['Another Filter!', 'Special Filter!']}},
             BugNotificationSet().getRecipientFilterData(
                 {self.subscriber: sources},
+                [self.notification, self.notification2]))
+
+    def test_getRecipientFilterData_mute(self):
+        # When there are bug filters for the recipient,
+        # only those filters are returned.
+        self.includeFilterInNotification(description=u'Special Filter!')
+        # Mute the first filter.
+        BugSubscriptionFilterMute(
+            person=self.subscriber,
+            filter=self.notification.bug_filters.one())
+        sources = list(self.notification.recipients)
+        self.assertEqual(
+            {},
+            BugNotificationSet().getRecipientFilterData(
+                {self.subscriber: sources}, [self.notification]))
+
+    def test_getRecipientFilterData_mute_one_person_of_two(self):
+        self.includeFilterInNotification()
+        # Mute the first filter.
+        BugSubscriptionFilterMute(
+            person=self.subscriber,
+            filter=self.notification.bug_filters.one())
+        subscriber2 = self.factory.makePerson()
+        subscription2 = self.bug.default_bugtask.target.addSubscription(
+            subscriber2, subscriber2)
+        notification2 = self.addNotification(subscriber2)
+        self.includeFilterInNotification(subscription=subscription2,
+                                         description=u'Special Filter!',
+                                         notification=notification2)
+        sources = list(self.notification.recipients)
+        sources2 = list(notification2.recipients)
+        self.assertEqual(
+            {subscriber2: {'sources': sources2, 
+                           'filter descriptions': [u'Special Filter!']}},
+            BugNotificationSet().getRecipientFilterData(
+                {self.subscriber: sources, subscriber2: sources2},
                 [self.notification, notification2]))
 
+    def test_getRecipientFilterData_mute_one_filter_of_two(self):
+        self.prepareTwoNotificationsWithFilters()
+        # Mute the first filter.
+        BugSubscriptionFilterMute(
+            person=self.subscriber,
+            filter=self.notification.bug_filters.one())
+        sources = list(self.notification.recipients)
+        sources.extend(self.notification2.recipients)
+        # Perform the test.
+        self.assertEqual(
+            {self.subscriber: {'sources': sources,
+             'filter descriptions': ['Another Filter!']}},
+            BugNotificationSet().getRecipientFilterData(
+                {self.subscriber: sources},
+                [self.notification, self.notification2]))
+
+    def test_getRecipientFilterData_mute_both_filters_mutes(self):
+        self.prepareTwoNotificationsWithFilters()
+        # Mute the first filter.
+        BugSubscriptionFilterMute(
+            person=self.subscriber,
+            filter=self.notification.bug_filters.one())
+        # Mute the second filter.
+        BugSubscriptionFilterMute(
+            person=self.subscriber,
+            filter=self.notification2.bug_filters.one())
+        sources = list(self.notification.recipients)
+        sources.extend(self.notification2.recipients)
+        # Perform the test.
+        self.assertEqual(
+            {},
+            BugNotificationSet().getRecipientFilterData(
+                {self.subscriber: sources},
+                [self.notification, self.notification2]))
+
 
 class TestNotificationProcessingWithoutRecipients(TestCaseWithFactory):
     """Adding notificatons without any recipients does not cause any harm.