← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/launchpad/subscribe-to-tag-bug-151129-6 into lp:launchpad

 

Gavin Panella has proposed merging lp:~allenap/launchpad/subscribe-to-tag-bug-151129-6 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  #681326 getSubscriptionsForBugTask() is being called too many times
  https://bugs.launchpad.net/bugs/681326

For more details, see:
https://code.launchpad.net/~allenap/launchpad/subscribe-to-tag-bug-151129-6/+merge/48295

Some more work on infiltrating BugSubscriptionInfo into the position
of knowing all about subscriptions, and some other bits:

lib/lp/bugs/browser/bug.py

  Use the new Bug.getSubscriptionInfo() method in BugViewMixin.

lib/lp/bugs/configure.zcml

  Security declaration for BugSubscriptionInfo and
  Bug.getSubscriptionInfo().

lib/lp/bugs/doc/bugsubscription.txt

  BugSubscriptionInfo is immutable, and returns immutable data types,
  so there are tuples here where there were once lists.

lib/lp/bugs/interfaces/bug.py

  Interface declaration for the new getSubscriptionInfo() method.

lib/lp/bugs/model/bug.py

  Implementation of getSubscriptionInfo(). I've also used it in
  getDirectSubscriptions(), getDirectSubscribers() and
  getSubscribersFromDuplicates().

  BugSubscriptionInfo now implements IHasBug. This is true, and it
  also means that a security adapter can be selected for it. See the
  changes in test_bugsubscriptioninfo.py.

  I've put a lot of the design goals I had in mind for
  BugSubscriptionInfo into its docstring. Trying to convince people
  that it's a good idea to use :)

  Implementation of the duplicate_only_subscriptions set.

lib/lp/bugs/model/tests/test_bug.py

  Test for getSubscriptionInfo().

lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py

  Test for the new duplicate_only_subscriptions set.

  Test for BugSubscriptionInfo permissions.

-- 
https://code.launchpad.net/~allenap/launchpad/subscribe-to-tag-bug-151129-6/+merge/48295
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/launchpad/subscribe-to-tag-bug-151129-6 into lp:launchpad.
=== modified file 'lib/lp/bugs/browser/bug.py'
--- lib/lp/bugs/browser/bug.py	2011-01-18 20:49:35 +0000
+++ lib/lp/bugs/browser/bug.py	2011-02-02 11:14:58 +0000
@@ -414,39 +414,28 @@
     """Mix-in class to share methods between bug and portlet views."""
 
     @cachedproperty
+    def subscription_info(self):
+        return IBug(self.context).getSubscriptionInfo()
+
+    @property
     def direct_subscribers(self):
         """Return the list of direct subscribers."""
-        if IBug.providedBy(self.context):
-            return set(self.context.getDirectSubscribers())
-        elif IBugTask.providedBy(self.context):
-            return set(self.context.bug.getDirectSubscribers())
-        else:
-            raise NotImplementedError(
-                'direct_subscribers is not provided by %s' % self)
+        return self.subscription_info.direct_subscriptions.subscribers
 
-    @cachedproperty
+    @property
     def duplicate_subscribers(self):
         """Return the list of subscribers from duplicates.
 
-        Don't use getSubscribersFromDuplicates here because that method
-        omits a user if the user is also a direct or indirect subscriber.
-        getSubscriptionsFromDuplicates doesn't, so find person objects via
-        this method.
+        This includes all subscribers who are also direct or indirect
+        subscribers.
         """
-        if IBug.providedBy(self.context):
-            dupe_subs = self.context.getSubscriptionsFromDuplicates()
-            return set(sub.person for sub in dupe_subs)
-        elif IBugTask.providedBy(self.context):
-            dupe_subs = self.context.bug.getSubscriptionsFromDuplicates()
-            return set(sub.person for sub in dupe_subs)
-        else:
-            raise NotImplementedError(
-                'duplicate_subscribers is not implemented for %s' % self)
+        return self.subscription_info.duplicate_subscriptions.subscribers
 
     @cachedproperty
     def subscriber_ids(self):
         """Return a dictionary mapping a css_name to user name."""
-        subscribers = self.direct_subscribers.union(
+        subscribers = set().union(
+            self.direct_subscribers,
             self.duplicate_subscribers)
 
         # The current user has to be in subscribers_id so

=== modified file 'lib/lp/bugs/configure.zcml'
--- lib/lp/bugs/configure.zcml	2011-01-31 14:07:47 +0000
+++ lib/lp/bugs/configure.zcml	2011-02-02 11:14:58 +0000
@@ -638,6 +638,31 @@
           set_schema=".interfaces.bugsubscriptionfilter.IBugSubscriptionFilterAttributes" />
     </class>
 
+    <!-- BugSubscriptionInfo -->
+
+    <class class=".model.bug.BugSubscriptionInfo">
+      <!-- Instance variables -->
+      <require
+          permission="launchpad.View"
+          attributes="
+              bug
+              level
+              " />
+      <!-- Properties -->
+      <require
+          permission="launchpad.View"
+          attributes="
+              all_assignees
+              all_pillar_owners_without_bug_supervisors
+              also_notified_subscribers
+              direct_subscriptions
+              duplicate_only_subscriptions
+              duplicate_subscriptions
+              indirect_subscribers
+              structural_subscriptions
+              " />
+    </class>
+
     <facet
         facet="bugs">
 
@@ -719,6 +744,7 @@
                     getSubscriptionsFromDuplicates
                     getSubscribersForPerson
                     getSubscriptionForPerson
+                    getSubscriptionInfo
                     indexed_messages
                     getAlsoNotifiedSubscribers
                     getBugWatch

=== modified file 'lib/lp/bugs/doc/bugsubscription.txt'
--- lib/lp/bugs/doc/bugsubscription.txt	2011-01-27 09:47:06 +0000
+++ lib/lp/bugs/doc/bugsubscription.txt	2011-02-02 11:14:58 +0000
@@ -101,7 +101,7 @@
     >>> linux_source_bug.getAlsoNotifiedSubscribers()
     [<Person at ...>]
     >>> linux_source_bug.getSubscribersFromDuplicates()
-    []
+    ()
 
 It is also possible to get the list of indirect subscribers for an
 individual bug task.
@@ -137,7 +137,7 @@
     Ubuntu Team
 
     >>> linux_source_bug.getSubscribersFromDuplicates()
-    []
+    ()
 
     >>> print_displayname(linux_source_bug.getAlsoNotifiedSubscribers())
     Sample Person
@@ -161,7 +161,7 @@
     Ubuntu Team
 
     >>> linux_source_bug.getSubscribersFromDuplicates()
-    []
+    ()
     >>> print_displayname(linux_source_bug.getAlsoNotifiedSubscribers())
     No Privileges Person
     Sample Person
@@ -365,7 +365,7 @@
     >>> subscription_keybuk = linux_source.addBugSubscription(
     ...     keybuk, keybuk)
     >>> linux_source_bug.getSubscribersFromDuplicates()
-    []
+    ()
 
 When a bug is marked private, all its indirect subscribers become direct
 subscribers.
@@ -409,7 +409,7 @@
     []
 
     >>> linux_source_bug.getSubscribersFromDuplicates()
-    []
+    ()
 
 Direct subscriptions always take precedence over indirect
 subscriptions. So, if we unmark the above bug as private,

=== modified file 'lib/lp/bugs/interfaces/bug.py'
--- lib/lp/bugs/interfaces/bug.py	2011-01-31 14:07:47 +0000
+++ lib/lp/bugs/interfaces/bug.py	2011-02-02 11:14:58 +0000
@@ -529,6 +529,12 @@
         If no such `BugSubscription` exists, return None.
         """
 
+    def getSubscriptionInfo(level):
+        """Return a `BugSubscriptionInfo` at the given `level`.
+
+        :param level: A member of `BugNotificationLevel`.
+        """
+
     def getBugNotificationRecipients(duplicateof=None, old_bug=None,
                                      include_master_dupe_subscribers=False):
         """Return a complete INotificationRecipientSet instance.

=== modified file 'lib/lp/bugs/model/bug.py'
--- lib/lp/bugs/model/bug.py	2011-01-31 09:24:13 +0000
+++ lib/lp/bugs/model/bug.py	2011-02-02 11:14:58 +0000
@@ -26,6 +26,7 @@
     )
 from email.Utils import make_msgid
 from functools import wraps
+from itertools import chain
 import operator
 import re
 
@@ -90,6 +91,7 @@
     )
 from canonical.launchpad.helpers import shortlist
 from canonical.launchpad.interfaces.launchpad import (
+    IHasBug,
     ILaunchpadCelebrities,
     IPersonRoles,
     )
@@ -822,10 +824,13 @@
             BugSubscription.bug_id == self.id).order_by(BugSubscription.id)
         return DecoratedResultSet(results, operator.itemgetter(1))
 
+    def getSubscriptionInfo(self, level=BugNotificationLevel.NOTHING):
+        """See `IBug`."""
+        return BugSubscriptionInfo(self, level)
+
     def getDirectSubscriptions(self):
         """See `IBug`."""
-        return BugSubscriptionInfo(
-            self, BugNotificationLevel.NOTHING).direct_subscriptions
+        return self.getSubscriptionInfo().direct_subscriptions
 
     def getDirectSubscribers(self, recipients=None, level=None):
         """See `IBug`.
@@ -837,7 +842,7 @@
         """
         if level is None:
             level = BugNotificationLevel.NOTHING
-        subscriptions = BugSubscriptionInfo(self, level).direct_subscriptions
+        subscriptions = self.getSubscriptionInfo(level).direct_subscriptions
         if recipients is not None:
             for subscriber in subscriptions.subscribers:
                 recipients.addDirectSubscriber(subscriber)
@@ -851,8 +856,8 @@
         """
         # "Also notified" and duplicate subscribers are mutually
         # exclusive, so return both lists.
-        indirect_subscribers = (
-            self.getAlsoNotifiedSubscribers(recipients, level) +
+        indirect_subscribers = chain(
+            self.getAlsoNotifiedSubscribers(recipients, level),
             self.getSubscribersFromDuplicates(recipients, level))
 
         # Remove security proxy for the sort key, but return
@@ -887,37 +892,16 @@
         See the comment in getDirectSubscribers for a description of the
         recipients argument.
         """
-        if self.private:
-            return []
-
-        if level is None:
-            notification_level = BugNotificationLevel.NOTHING
-        else:
-            notification_level = level
-
-        dupe_details = dict(
-            Store.of(self).find(
-                (Person, Bug),
-                BugSubscription.person == Person.id,
-                BugSubscription.bug_id == Bug.id,
-                BugSubscription.bug_notification_level >= notification_level,
-                Bug.duplicateof == self.id))
-
-        dupe_subscribers = set(dupe_details)
-
-        # Direct and "also notified" subscribers take precedence over
-        # subscribers from dupes. Note that we don't supply recipients
-        # here because we are doing this to /remove/ subscribers.
-        dupe_subscribers -= set(self.getDirectSubscribers())
-        dupe_subscribers -= set(self.getAlsoNotifiedSubscribers())
+        info = self.getSubscriptionInfo()
 
         if recipients is not None:
-            for subscriber in dupe_subscribers:
+            # Pre-load duplicate bugs.
+            list(self.duplicates)
+            for subscription in info.duplicate_only_subscriptions:
                 recipients.addDupeSubscriber(
-                    subscriber, dupe_details[subscriber])
+                    subscription.person, subscription.bug)
 
-        return sorted(
-            dupe_subscribers, key=operator.attrgetter("displayname"))
+        return info.duplicate_only_subscriptions.subscribers.sorted
 
     def getSubscribersForPerson(self, person):
         """See `IBug."""
@@ -2091,7 +2075,30 @@
 
 
 class BugSubscriptionInfo:
-    """Represents bug subscription sets."""
+    """Represents bug subscription sets.
+
+    The intention for this class is to encapsulate all calculations of
+    subscriptions and subscribers for a bug. Some design considerations:
+
+    * Immutable.
+
+    * Set-based.
+
+    * Sets are cached.
+
+    * Usable with a *snapshot* of a bug. This is interesting for two reasons:
+
+      - Event subscribers commonly deal with snapshots. An instance of this
+        class could be added to a custom snapshot so that multiple subscribers
+        can share the information it contains.
+
+      - Use outside of the web request. A serialized snapshot could be used to
+        calculate subscribers for a particular bug state. This could help us
+        to move even more bug mail processing out of the web request.
+
+    """
+
+    implements(IHasBug)
 
     def __init__(self, bug, level):
         self.bug = bug
@@ -2120,6 +2127,22 @@
                 Bug.duplicateof == self.bug)
 
     @cachedproperty
+    @freeze(BugSubscriptionSet)
+    def duplicate_only_subscriptions(self):
+        """Subscripitions to duplicates of the bug.
+
+        Excludes subscriptions for people who have a direct subscription or
+        are also notified for another reason.
+        """
+        self.duplicate_subscriptions.subscribers # Pre-load subscribers.
+        higher_precedence = (
+            self.direct_subscriptions.subscribers.union(
+                self.also_notified_subscribers))
+        return (
+            subscription for subscription in self.duplicate_subscriptions
+            if subscription.person not in higher_precedence)
+
+    @cachedproperty
     @freeze(StructuralSubscriptionSet)
     def structural_subscriptions(self):
         """Structural subscriptions to the bug's targets."""
@@ -2138,13 +2161,14 @@
             if bugtask.milestone is not None:
                 query_arguments.append((bugtask.milestone, bugtask))
         # Build the query.
+        empty = EmptyResultSet()
         union = lambda left, right: (
             removeSecurityProxy(left).union(
                 removeSecurityProxy(right)))
         queries = (
             target.getSubscriptionsForBugTask(bugtask, self.level)
             for target, bugtask in query_arguments)
-        return reduce(union, queries)
+        return reduce(union, queries, empty)
 
     @cachedproperty
     @freeze(BugSubscriberSet)

=== modified file 'lib/lp/bugs/model/tests/test_bug.py'
--- lib/lp/bugs/model/tests/test_bug.py	2010-11-12 18:05:45 +0000
+++ lib/lp/bugs/model/tests/test_bug.py	2011-02-02 11:14:58 +0000
@@ -4,6 +4,7 @@
 __metaclass__ = type
 
 from canonical.testing.layers import DatabaseFunctionalLayer
+from lp.bugs.model.bug import BugSubscriptionInfo
 from lp.registry.enum import BugNotificationLevel
 from lp.registry.interfaces.person import PersonVisibility
 from lp.registry.model.structuralsubscription import StructuralSubscription
@@ -240,3 +241,16 @@
         self.assertTrue(
             subscriber not in duplicate_subscribers,
             "Subscriber should not be in duplicate_subscribers.")
+
+    def test_getSubscriptionInfo(self):
+        # getSubscriptionInfo() returns a BugSubscriptionInfo object.
+        bug = self.factory.makeBug()
+        with person_logged_in(bug.owner):
+            info = bug.getSubscriptionInfo()
+        self.assertIsInstance(info, BugSubscriptionInfo)
+        self.assertEqual(bug, info.bug)
+        self.assertEqual(BugNotificationLevel.NOTHING, info.level)
+        # A level can also be specified.
+        with person_logged_in(bug.owner):
+            info = bug.getSubscriptionInfo(BugNotificationLevel.METADATA)
+        self.assertEqual(BugNotificationLevel.METADATA, info.level)

=== modified file 'lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py'
--- lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py	2010-12-06 20:58:55 +0000
+++ lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py	2011-02-02 11:14:58 +0000
@@ -9,7 +9,10 @@
 
 from storm.store import Store
 from testtools.matchers import Equals
+from zope.component import queryAdapter
+from zope.security.checker import getChecker
 
+from canonical.launchpad.webapp.interfaces import IAuthorization
 from canonical.testing.layers import DatabaseFunctionalLayer
 from lp.bugs.model.bug import (
     BugSubscriberSet,
@@ -18,6 +21,9 @@
     load_people,
     StructuralSubscriptionSet,
     )
+from lp.bugs.security import (
+    PublicToAllOrPrivateToExplicitSubscribersForBugTask,
+    )
 from lp.registry.enum import BugNotificationLevel
 from lp.registry.model.person import Person
 from lp.testing import (
@@ -182,6 +188,28 @@
         self.assertEqual(set(), found_subscriptions.subscribers)
         self.assertEqual((), found_subscriptions.subscribers.sorted)
 
+    def test_duplicate_only(self):
+        # The set of duplicate subscriptions where the subscriber has no other
+        # subscriptions.
+        duplicate_bug = self.factory.makeBug(product=self.target)
+        with person_logged_in(duplicate_bug.owner):
+            duplicate_bug.markAsDuplicate(self.bug)
+            duplicate_bug_subscription = (
+                duplicate_bug.getSubscriptionForPerson(
+                    duplicate_bug.owner))
+        found_subscriptions = self.getInfo().duplicate_only_subscriptions
+        self.assertEqual(
+            set([duplicate_bug_subscription]),
+            found_subscriptions)
+        # If a user is subscribed to a duplicate bug and is a bugtask
+        # assignee, for example, their duplicate subscription will not be
+        # included.
+        with person_logged_in(self.target.owner):
+            self.bug.default_bugtask.transitionToAssignee(
+                duplicate_bug_subscription.person)
+        found_subscriptions = self.getInfo().duplicate_only_subscriptions
+        self.assertEqual(set(), found_subscriptions)
+
     def test_structural(self):
         # The set of structural subscribers.
         subscribers = (
@@ -309,6 +337,30 @@
             found_subscribers.sorted)
 
 
+class TestBugSubscriptionInfoPermissions(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test(self):
+        bug = self.factory.makeBug()
+        info = bug.getSubscriptionInfo()
+        checker = getChecker(info)
+
+        # BugSubscriptionInfo objects are immutable.
+        self.assertEqual({}, checker.set_permissions)
+
+        # All attributes require launchpad.View.
+        permissions = set(checker.get_permissions.itervalues())
+        self.assertEqual(set(["launchpad.View"]), permissions)
+
+        # The security adapter for launchpad.View lets anyone reference the
+        # attributes unless the bug is private, in which case only explicit
+        # subscribers are permitted.
+        adapter = queryAdapter(info, IAuthorization, "launchpad.View")
+        self.assertIsInstance(
+            adapter, PublicToAllOrPrivateToExplicitSubscribersForBugTask)
+
+
 class TestBugSubscriptionInfoQueries(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer