← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/bug-contact-auto-unsubscribe-672596 into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/bug-contact-auto-unsubscribe-672596 into lp:launchpad with lp:~wallyworld/launchpad/private-bug-subscriptions-854405 as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #672596 in Launchpad itself: "When marking a security bug non-security, the security contact should be unsubscribed"
  https://bugs.launchpad.net/launchpad/+bug/672596

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/bug-contact-auto-unsubscribe-672596/+merge/76553

== Implementation ==

This branch unsubscribes bug supervisors when a bug is made public and security contacts when it is made non-security. Emails are sent to the affected people letting them know they have been unsubscribed.

The BugNotificationRecipients class was extended to add 2 new recipient types:
  addBugSupervisor
  addSecurityContact

The UnsubscribedFromBug bug change class was extended to optionally trigger sending of email notifications - normally unsubscribing from a bug doesn't send an email. But we need to be able to tell it to when we auto unsubscribe as a result of bug status change.

== Tests ==

Add 2 new tests to TestBugPrivateAndSecurityRelatedUpdatesMixin:
  - test_bugSupervisorUnsubscribedIfBugMadePublic
  - test_securityContactUnsubscribedIfBugNotSecurityRelated

Also tweak some existing doc tests.

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/bugs/adapters/bugchange.py
  lib/lp/bugs/doc/bugsubscription.txt
  lib/lp/bugs/mail/bugnotificationrecipients.py
  lib/lp/bugs/model/bug.py
  lib/lp/bugs/model/tests/test_bug.py
  lib/lp/bugs/scripts/bugnotification.py

-- 
https://code.launchpad.net/~wallyworld/launchpad/bug-contact-auto-unsubscribe-672596/+merge/76553
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/bug-contact-auto-unsubscribe-672596 into lp:launchpad.
=== modified file 'lib/lp/bugs/adapters/bugchange.py'
--- lib/lp/bugs/adapters/bugchange.py	2011-08-16 19:14:51 +0000
+++ lib/lp/bugs/adapters/bugchange.py	2011-09-22 11:14:28 +0000
@@ -179,9 +179,11 @@
 class UnsubscribedFromBug(BugChangeBase):
     """A user got unsubscribed from a bug."""
 
-    def __init__(self, when, person, unsubscribed_user):
+    def __init__(self, when, person, unsubscribed_user, **kwargs):
         super(UnsubscribedFromBug, self).__init__(when, person)
         self.unsubscribed_user = unsubscribed_user
+        self.send_notification = kwargs.get('send_notification', False)
+        self.notification_text = kwargs.get('notification_text')
 
     def getBugActivity(self):
         """See `IBugChange`."""
@@ -191,7 +193,10 @@
 
     def getBugNotification(self):
         """See `IBugChange`."""
-        return None
+        if self.send_notification and self.notification_text:
+            return {'text': '** %s' % self.notification_text}
+        else:
+            return None
 
 
 class BugConvertedToQuestion(BugChangeBase):

=== modified file 'lib/lp/bugs/doc/bugsubscription.txt'
--- lib/lp/bugs/doc/bugsubscription.txt	2011-09-22 11:14:27 +0000
+++ lib/lp/bugs/doc/bugsubscription.txt	2011-09-22 11:14:28 +0000
@@ -430,20 +430,22 @@
     >>> print_displayname(linux_source_bug.getDirectSubscribers())
     Foo Bar
     Mark Shuttleworth
-    Robert Collins
-    Ubuntu Team
 
     >>> print_displayname(linux_source_bug.getIndirectSubscribers())
     Martin Pitt
     No Privileges Person
+    Robert Collins
     Sample Person
     Scott James Remnant
+    Ubuntu Team
 
     >>> print_displayname(linux_source_bug.getAlsoNotifiedSubscribers())
     Martin Pitt
     No Privileges Person
+    Robert Collins
     Sample Person
     Scott James Remnant
+    Ubuntu Team
 
 To find out which email addresses should receive a notification email on
 a bug, and why, IBug.getBugNotificationRecipients() assembles an
@@ -458,8 +460,8 @@
     [('foo.bar@xxxxxxxxxxxxx', 'Subscriber'),
      ('mark@xxxxxxxxxxx', 'Subscriber'),
      ('no-priv@xxxxxxxxxxxxx', u'Subscriber (linux-source-2.6.15 in Ubuntu)'),
-     ('robertc@xxxxxxxxxxxxxxxxx', 'Subscriber'),
-     ('support@xxxxxxxxxx', u'Subscriber @ubuntu-team'),
+     ('robertc@xxxxxxxxxxxxxxxxx', u'Subscriber (Mozilla Firefox)'),
+     ('support@xxxxxxxxxx', u'Subscriber (Ubuntu) @ubuntu-team'),
      ('test@xxxxxxxxxxxxx', 'Assignee')]
 
 If IBug.getBugNotificationRecipients() is passed a  BugNotificationLevel
@@ -472,8 +474,8 @@
     >>> [(address, recipients.getReason(address)[1]) for address in addresses]
     [('foo.bar@xxxxxxxxxxxxx', 'Subscriber'),
      ('mark@xxxxxxxxxxx', 'Subscriber'),
-     ('robertc@xxxxxxxxxxxxxxxxx', 'Subscriber'),
-     ('support@xxxxxxxxxx', u'Subscriber @ubuntu-team'),
+     ('robertc@xxxxxxxxxxxxxxxxx', u'Subscriber (Mozilla Firefox)'),
+     ('support@xxxxxxxxxx', u'Subscriber (Ubuntu) @ubuntu-team'),
      ('test@xxxxxxxxxxxxx', 'Assignee')]
 
 When Sample Person is unsubscribed from linux_source_bug, he is no
@@ -487,8 +489,8 @@
     >>> [(address, recipients.getReason(address)[1]) for address in addresses]
     [('foo.bar@xxxxxxxxxxxxx', 'Subscriber'),
      ('mark@xxxxxxxxxxx', 'Subscriber'),
-     ('robertc@xxxxxxxxxxxxxxxxx', 'Subscriber'),
-     ('support@xxxxxxxxxx', u'Subscriber @ubuntu-team'),
+     ('robertc@xxxxxxxxxxxxxxxxx', u'Subscriber (Mozilla Firefox)'),
+     ('support@xxxxxxxxxx', u'Subscriber (Ubuntu) @ubuntu-team'),
      ('test@xxxxxxxxxxxxx', 'Assignee')]
 
 ...but remains included for the level LIFECYCLE.
@@ -501,8 +503,8 @@
     [('foo.bar@xxxxxxxxxxxxx', 'Subscriber'),
      ('mark@xxxxxxxxxxx', 'Subscriber'),
      ('no-priv@xxxxxxxxxxxxx', u'Subscriber (linux-source-2.6.15 in Ubuntu)'),
-     ('robertc@xxxxxxxxxxxxxxxxx', 'Subscriber'),
-     ('support@xxxxxxxxxx', u'Subscriber @ubuntu-team'),
+     ('robertc@xxxxxxxxxxxxxxxxx', u'Subscriber (Mozilla Firefox)'),
+     ('support@xxxxxxxxxx', u'Subscriber (Ubuntu) @ubuntu-team'),
      ('test@xxxxxxxxxxxxx', 'Assignee')]
 
 To find out if someone is already directly subscribed to a bug, call

=== modified file 'lib/lp/bugs/mail/bugnotificationrecipients.py'
--- lib/lp/bugs/mail/bugnotificationrecipients.py	2011-05-31 19:45:53 +0000
+++ lib/lp/bugs/mail/bugnotificationrecipients.py	2011-09-22 11:14:28 +0000
@@ -107,6 +107,28 @@
             text = "are a bug assignee"
         self._addReason(person, text, reason)
 
+    def addBugSupervisor(self, person):
+        """Registers a bug supervisor of a bugtask's pillar of this bug."""
+        reason = "Bug Supervisor"
+        if person.isTeam():
+            text = ("are a member of %s, which is a bug supervisor"
+                    % person.displayname)
+            reason += " @%s" % person.name
+        else:
+            text = "are a bug supervisor"
+        self._addReason(person, text, reason)
+
+    def addSecurityContact(self, person):
+        """Registers a security contact of a bugtask's pillar of this bug."""
+        reason = "Security Contact"
+        if person.isTeam():
+            text = ("are a member of %s, which is a security contact"
+                    % person.displayname)
+            reason += " @%s" % person.name
+        else:
+            text = "are a security contact"
+        self._addReason(person, text, reason)
+
     def addStructuralSubscriber(self, person, target):
         """Registers a structural subscriber to this bug's target."""
         reason = "Subscriber (%s)" % target.displayname

=== modified file 'lib/lp/bugs/model/bug.py'
--- lib/lp/bugs/model/bug.py	2011-09-22 11:14:27 +0000
+++ lib/lp/bugs/model/bug.py	2011-09-22 11:14:28 +0000
@@ -822,6 +822,7 @@
             person = unsubscribed_by
 
         ignore_permissions = kwargs.get('ignore_permissions', False)
+        recipients = kwargs.get('recipients')
         for sub in self.subscriptions:
             if sub.person.id == person.id:
                 if (not ignore_permissions
@@ -832,8 +833,9 @@
                             person.displayname))
 
                 self.addChange(UnsubscribedFromBug(
-                    when=UTC_NOW, person=unsubscribed_by,
-                    unsubscribed_user=person))
+                        when=UTC_NOW, person=unsubscribed_by,
+                        unsubscribed_user=person, **kwargs),
+                    recipients=recipients)
                 store = Store.of(sub)
                 store.remove(sub)
                 # Make sure that the subscription removal has been
@@ -1740,6 +1742,29 @@
                 result.add(who)
         return result
 
+    def getAutoRemovedSubscribers(self, for_private, for_security_related):
+        """Return the to be removed subscribers for bug with given attributes.
+
+        When a bug's privacy or security related attributes change, some
+        existing subscribers may need to be automatically removed.
+        The rules are:
+            security=false ->
+                auto removed subscribers = (bugtask security contacts)
+            privacy=false ->
+                auto removed subscribers = (bugtask bug supervisors)
+
+        """
+        bug_supervisors = []
+        security_contacts = []
+        for pillar in self.affected_pillars:
+            if (self.security_related and not for_security_related
+                and pillar.security_contact):
+                    security_contacts.append(pillar.security_contact)
+            if (self.private and not for_private
+                and pillar.bug_supervisor):
+                    bug_supervisors.append(pillar.bug_supervisor)
+        return bug_supervisors, security_contacts
+
     def reconcileSubscribers(self, for_private, for_security_related, who):
         """ Ensure only appropriate people are subscribed to private bugs.
 
@@ -1761,6 +1786,28 @@
             self.getSubscriptionInfo().direct_subscribers)
         required_subscribers = self.getRequiredSubscribers(
             for_private, for_security_related, who)
+        removed_bug_supervisors, removed_security_contacts = (
+            self.getAutoRemovedSubscribers(for_private, for_security_related))
+        for subscriber in removed_bug_supervisors:
+            recipients = BugNotificationRecipients()
+            recipients.addBugSupervisor(subscriber)
+            notification_text = ("This bug is no longer private so you will "
+                "no longer be notified of changes.")
+            self.unsubscribe(
+                subscriber, who, ignore_permissions=True,
+                send_notification=True,
+                notification_text=notification_text,
+                recipients=recipients)
+        for subscriber in removed_security_contacts:
+            recipients = BugNotificationRecipients()
+            recipients.addSecurityContact(subscriber)
+            notification_text = ("This bug is no longer security related so "
+                "you will no longer be notified of changes.")
+            self.unsubscribe(
+                subscriber, who, ignore_permissions=True,
+                send_notification=True,
+                notification_text=notification_text,
+                recipients=recipients)
 
         # If this bug is for a project that is marked as having private bugs
         # by default, and the bug is private or security related, we will

=== modified file 'lib/lp/bugs/model/tests/test_bug.py'
--- lib/lp/bugs/model/tests/test_bug.py	2011-09-22 11:14:27 +0000
+++ lib/lp/bugs/model/tests/test_bug.py	2011-09-22 11:14:28 +0000
@@ -1,7 +1,5 @@
 # Copyright 2010-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
-from canonical.launchpad.interfaces.lpstorm import IStore
-from lp.bugs.model.bugnotification import BugNotificationRecipient
 
 __metaclass__ = type
 
@@ -10,11 +8,14 @@
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
+from canonical.launchpad.interfaces.lpstorm import IStore
 from canonical.testing.layers import DatabaseFunctionalLayer
 from lp.bugs.enum import (
     BugNotificationLevel,
     BugNotificationStatus,
     )
+from lp.bugs.model.bugnotification import BugNotificationRecipient
+from lp.bugs.scripts.bugnotification import get_email_notifications
 from lp.bugs.interfaces.bugnotification import IBugNotificationSet
 from lp.bugs.interfaces.bugtask import BugTaskStatus
 from lp.bugs.model.bug import (
@@ -496,20 +497,22 @@
         # Used with the various setPrivateAndSecurityRelated tests to create
         # a bug and add some initial subscribers.
         bug_owner = self.factory.makePerson(name='bugowner')
-        bug_supervisor = self.factory.makePerson(name='bugsupervisor')
+        bug_supervisor = self.factory.makePerson(
+            name='bugsupervisor', email='bugsupervisor@xxxxxxxxxxx')
         product_owner = self.factory.makePerson(name='productowner')
         bug_product = self.factory.makeProduct(
             owner=product_owner, bug_supervisor=bug_supervisor)
         if self.private_project:
             removeSecurityProxy(bug_product).private_bugs = True
-        bug = self.factory.makeBug(
-            owner=bug_owner,
-            product=bug_product,
-            private=private_security_related,
-            security_related=private_security_related)
+        bug = self.factory.makeBug(owner=bug_owner, product=bug_product)
+        with person_logged_in(bug_owner):
+            bug.setPrivacyAndSecurityRelated(
+                private_security_related, private_security_related, bug_owner)
         owner_a = self.factory.makePerson(name='ownera')
-        security_contact_a = self.factory.makePerson(name='securitycontacta')
-        bug_supervisor_a = self.factory.makePerson(name='bugsupervisora')
+        security_contact_a = self.factory.makePerson(
+            name='securitycontacta', email='securitycontacta@xxxxxxxxxxx')
+        bug_supervisor_a = self.factory.makePerson(
+            name='bugsupervisora', email='bugsupervisora@xxxxxxxxxxx')
         driver_a = self.factory.makePerson(name='drivera')
         product_a = self.factory.makeProduct(
             owner=owner_a,
@@ -517,7 +520,8 @@
             bug_supervisor=bug_supervisor_a,
             driver=driver_a)
         owner_b = self.factory.makePerson(name='ownerb')
-        security_contact_b = self.factory.makePerson(name='securitycontactb')
+        security_contact_b = self.factory.makePerson(
+            name='securitycontactb', email='securitycontactb@xxxxxxxxxxx')
         product_b = self.factory.makeProduct(
             owner=owner_b,
             security_contact=security_contact_b)
@@ -652,7 +656,10 @@
             bug.setPrivacyAndSecurityRelated(
                 private=False, security_related=False, who=who)
         subscribers = set(bug.getDirectSubscribers())
-        expected_direct_subscribers.remove(bugtask_a.pillar.security_contact)
+        expected_direct_subscribers.difference_update(
+            (default_bugtask.pillar.security_contact,
+             default_bugtask.pillar.bug_supervisor,
+             bugtask_a.pillar.security_contact))
         self.assertContentEqual(expected_direct_subscribers, subscribers)
 
     def test_setPillarOwnerSubscribedIfNoBugSupervisor(self):
@@ -687,13 +694,110 @@
             subscribers
         )
 
-    def _fetch_notifications(self, bug, header, body):
-        return IStore(BugNotification).find(
+    def _fetch_notifications(self, bug, reason_header):
+        store = IStore(BugNotification)
+        return store.find(
             BugNotification,
-            BugNotification.id == BugNotificationRecipient.bug_notificationID,
-            BugNotificationRecipient.reason_header == header,
-            BugNotificationRecipient.reason_body == body,
-            BugNotification.bug == bug)
+            BugNotificationRecipient.bug_notificationID == BugNotification.id,
+            BugNotificationRecipient.reason_header == reason_header,
+            BugNotification.bug == bug,
+            BugNotification.status == BugNotificationStatus.PENDING,
+            BugNotification.date_emailed == None)
+
+    def _check_email_content(self, message, expected_headers,
+                             expected_body_text):
+        # Ensure that the email header values and message body text is as
+        # expected.
+        headers = {}
+        for header in ['X-Launchpad-Message-Rationale',
+                       'X-Launchpad-Bug-Security-Vulnerability',
+                       'X-Launchpad-Bug-Private']:
+            if message[header]:
+                headers[header] = message[header]
+        self.assertEqual(expected_headers, headers)
+        body_text = message.get_payload(decode=True)
+        self.assertTrue(expected_body_text in body_text)
+
+    def _check_notifications(self, bug, expected_recipients,
+                             expected_body_text, expected_reason_body,
+                             is_private, is_security_related, role):
+        # Ensure that the content of the pending email notifications is
+        # correct.
+        notifications = self._fetch_notifications(bug, role)
+        actual_recipients = []
+        email_notifications = get_email_notifications(notifications)
+        for bug_notifications, omitted, messages in email_notifications:
+            for message in messages:
+                expected_headers = {
+                    'X-Launchpad-Bug-Private':
+                        'yes' if is_private else 'no',
+                    'X-Launchpad-Bug-Security-Vulnerability':
+                        'yes' if is_security_related else 'no',
+                    'X-Launchpad-Message-Rationale': role,
+                }
+                self._check_email_content(
+                    message, expected_headers, expected_body_text)
+            expected_reason_header = role
+            for notification in bug_notifications:
+                for recipient in notification.recipients:
+                    self.assertEqual(
+                        expected_reason_header, recipient.reason_header)
+                    self.assertEqual(
+                        expected_reason_body, recipient.reason_body)
+                    actual_recipients.append(recipient.person)
+        self.assertContentEqual(expected_recipients, actual_recipients)
+
+    def test_bugSupervisorUnsubscribedIfBugMadePublic(self):
+        # The bug supervisors are unsubscribed if a bug is made public and an
+        # email is sent telling them they have been unsubscribed.
+
+        (bug, bug_owner,  bugtask_a, bugtask_b, default_bugtask) = (
+            self.createBugTasksAndSubscribers(private_security_related=True))
+
+        with person_logged_in(bug_owner):
+            bug.subscribe(bugtask_a.pillar.bug_supervisor, bug_owner)
+            who = self.factory.makePerson(name="who")
+            bug.setPrivacyAndSecurityRelated(
+                private=False, security_related=True, who=who)
+            subscribers = bug.getDirectSubscribers()
+            self.assertFalse(bugtask_a.pillar.bug_supervisor in subscribers)
+
+            expected_recipients = [
+                default_bugtask.pillar.bug_supervisor,
+                bugtask_a.pillar.bug_supervisor,
+                ]
+            expected_body_text = '** This bug is no longer private'
+            expected_reason_body = ('You received this bug notification '
+                    'because you are a bug supervisor.')
+            self._check_notifications(
+                bug, expected_recipients, expected_body_text,
+                expected_reason_body, False, True, 'Bug Supervisor')
+
+    def test_securityContactUnsubscribedIfBugNotSecurityRelated(self):
+        # The security contacts are unsubscribed if a bug has security_related
+        # set to false and an email is sent telling them they have been
+        # unsubscribed.
+
+        (bug, bug_owner,  bugtask_a, bugtask_b, default_bugtask) = (
+            self.createBugTasksAndSubscribers(private_security_related=True))
+
+        with person_logged_in(bug_owner):
+            bug.subscribe(bugtask_a.pillar.security_contact, bug_owner)
+            who = self.factory.makePerson(name="who")
+            bug.setPrivacyAndSecurityRelated(
+                private=True, security_related=False, who=who)
+            subscribers = bug.getDirectSubscribers()
+            self.assertFalse(bugtask_a.pillar.security_contact in subscribers)
+
+            expected_recipients = [
+                bugtask_a.pillar.security_contact,
+                ]
+            expected_body_text = '** This bug is no longer security related'
+            expected_reason_body = ('You received this bug notification '
+                    'because you are a security contact.')
+            self._check_notifications(
+                bug, expected_recipients, expected_body_text,
+                expected_reason_body, True, False, 'Security Contact')
 
 
 class TestBugPrivateAndSecurityRelatedUpdatesPrivateProject(

=== modified file 'lib/lp/bugs/scripts/bugnotification.py'
--- lib/lp/bugs/scripts/bugnotification.py	2011-08-16 02:08:25 +0000
+++ lib/lp/bugs/scripts/bugnotification.py	2011-09-22 11:14:28 +0000
@@ -105,7 +105,8 @@
         key = get_activity_key(notification)
         if (notification.is_comment or
             key is None or
-            old_values[key] != new_values[key]):
+            old_values[key] != new_values[key] or
+            (old_values[key] is None and new_values[key] is None)):
             # We will report this notification.
             filtered_notifications.append(notification)
             for subscription_source in notification.recipients: