Merge lp:~allenap/launchpad/bugnomination-timeout-bug-874250-preload-email into lp:launchpad

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 14638
Proposed branch: lp:~allenap/launchpad/bugnomination-timeout-bug-874250-preload-email
Merge into: lp:launchpad
Diff against target: 1541 lines (+706/-257)
9 files modified
lib/lp/bugs/configure.zcml (+13/-0)
lib/lp/bugs/doc/bugsubscription.txt (+4/-4)
lib/lp/bugs/interfaces/bug.py (+3/-2)
lib/lp/bugs/interfaces/bugtask.py (+1/-1)
lib/lp/bugs/model/bug.py (+180/-100)
lib/lp/bugs/model/structuralsubscription.py (+84/-41)
lib/lp/bugs/model/tests/test_bug.py (+82/-8)
lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py (+239/-99)
lib/lp/bugs/tests/test_structuralsubscription.py (+100/-2)
To merge this branch: bzr merge lp:~allenap/launchpad/bugnomination-timeout-bug-874250-preload-email
Reviewer Review Type Date Requested Status
Jeroen T. Vermeulen (community) Approve
Review via email: mp+87397@code.launchpad.net

Commit message

Load preferredemail when calculating bug subscribers of any kind via BugSubscriptionInfo.

Description of the change

This branch is long, but is actually quite readable, I think :)

One big problem when updating bugs is sending notifications to
subscribers. It looks like preferred email addresses are being loaded
one by one (though I started this branch long enough ago that the
details have faded).

In getting email addresses preloaded I updated BugSubscriptionInfo,
fixed some issues with it, and gotten get_also_notified_subscribers()
using it.

I think BSI is now a fairly good and comprehensive foundation for any
bug subscription calculation. It may not do everything in the minimum
number of queries possible, but it's does everything in a constant
number of queries, and makes it easy for code that uses it to also do
so.

There are still methods in IBug (and probably elsewhere) that could be
refactored to use BSI more directly, but I'll leave that for another
time.

The changes:

- Updates BugSubscriptionInfo:

  - Adds all_direct_subscriptions, all_direct_subscribers,
    direct_subscribers, muted_subscribers, and structural_subscribers
    properties.

  - Adds support for returning subscription info for only a single
    bugtask as well as all bugtasks of the given bug.

  - Adds forLevel() and forTask() methods.

  - When loading Person records, load preferred email information too.

- Updates get_also_notified_subscribers():

  - Use BugSubscriptionInfo more heavily.

  - Adds tests around performance of this function when passed a
    recipients argument. This was previously poor (potato potato).

- Updates structural subscriptions:

  Split get_structural_subscribers() into two functions -
  get_structural_subscribers() and get_structural_subscriptions() -
  which both back onto query_structural_subscriptions(), a new
  function. This supports the changes to BugSubscriptionInfo and also
  makes the implementation cleaner.

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

Fwiw, passes in devel:

Tests started at approximately 2012-01-03 16:07:06.335715
Source: bzr+ssh://bazaar.launchpad.net/~allenap/launchpad/bugnomination-timeout-bug-874250-preload-email r14523
Target: bzr+ssh://bazaar.launchpad.net/~launchpad-pqm/launchpad/devel/ r14623

17326 tests run in 4:21:55.682084, 0 failures, 0 errors

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :
Download full text (22.9 KiB)

Hi Gavin,

This took me a while to review. By and large it looks nice, but I have a few cosmetic issues to rant on. Not all of them are vital, and some will seem more trouble than they're worth — unless they can become habits. It may seem petty of me; but I could give a more effective review without these things in the way.

=== modified file 'lib/lp/bugs/doc/bugsubscription.txt'
--- lib/lp/bugs/doc/bugsubscription.txt 2011-12-24 17:49:30 +0000
+++ lib/lp/bugs/doc/bugsubscription.txt 2012-01-03 17:52:00 +0000
@@ -99,7 +99,7 @@
> getAlsoNotifiedSubscribers() and getSubscribersFromDuplicates().
>
> >>> linux_source_bug.getAlsoNotifiedSubscribers()
> - [<Person at ...>]
> + (<Person at ...>,)
> >>> linux_source_bug.getSubscribersFromDuplicates()
> ()
>
@@ -109,7 +109,7 @@
> >>> from lp.bugs.model.bug import get_also_notified_subscribers
> >>> res = get_also_notified_subscribers(linux_source_bug.bugtasks[0])
> >>> res
> - [<Person at ...>]
> + (<Person at ...>,)

These two original tests were brittle and a bit obscure. They seemed to establish that (1) a list is returned and (2) the list contains one or more Persons. I suspect (1) is of no significance.

Your new tests establish that (1) a tuple is returned and (2) the tuple contains exactly one Person, as implied by the trailing comma. So you test more, but it's still brittle and still a bit obscure.

It's not worth spending too much time on any given instance of these problems, but it's good to have fixes ready when you come across them: listify so that it won't matter what kind of iterable you get, or print just the length, or use a “[foo] = give_me_a_list_or_tuple()” assignment. The big question of course is what the test means to prove and what is incidental. Stay close to the former and away from the latter.

=== modified file 'lib/lp/bugs/model/bug.py'
--- lib/lp/bugs/model/bug.py 2011-12-30 06:14:56 +0000
+++ lib/lp/bugs/model/bug.py 2012-01-03 17:52:00 +0000

@@ -948,9 +949,10 @@
> BugSubscription.bug_id == self.id).order_by(BugSubscription.id)
> return DecoratedResultSet(results, operator.itemgetter(1))
>
> - def getSubscriptionInfo(self, level=BugNotificationLevel.LIFECYCLE):
> + def getSubscriptionInfo(self, level=None):
> """See `IBug`."""
> - return BugSubscriptionInfo(self, level)
> + return BugSubscriptionInfo(
> + self, BugNotificationLevel.LIFECYCLE if level is None else level)

Found the "if/else" slightly hard to read here. Maybe I'm just not used to the syntax yet, but it made me double-check that nothing else goes on in this line. Please consider putting the if/else on a separate line.

@@ -1002,6 +1004,8 @@
> # the regular proxied object.
 > return sorted(
 > indirect_subscribers,
> + # XXX: GavinPanella 2011-12-12 bug=???: Should probably use
> + # person_sort_key.
> key=lambda x: removeSecurityProxy(x).displayname)

Don't forget to file that bug!

@@ -2389,48 +2392,44 @@

> - # Direct subscriptions always take precedence over indirect
> - # subscriptions.
> - direct_subscriber...

review: Approve
Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (28.3 KiB)

> Hi Gavin,
>
> This took me a while to review. By and large it looks nice, but I
> have a few cosmetic issues to rant on. Not all of them are vital,
> and some will seem more trouble than they're worth — unless they can
> become habits. It may seem petty of me; but I could give a more
> effective review without these things in the way.

Thanks for going through this branch!

>
>
> === modified file 'lib/lp/bugs/doc/bugsubscription.txt'
> --- lib/lp/bugs/doc/bugsubscription.txt 2011-12-24 17:49:30 +0000
> +++ lib/lp/bugs/doc/bugsubscription.txt 2012-01-03 17:52:00 +0000
> @@ -99,7 +99,7 @@
> > getAlsoNotifiedSubscribers() and getSubscribersFromDuplicates().
> >
> > >>> linux_source_bug.getAlsoNotifiedSubscribers()
> > - [<Person at ...>]
> > + (<Person at ...>,)
> > >>> linux_source_bug.getSubscribersFromDuplicates()
> > ()
> >
> @@ -109,7 +109,7 @@
> > >>> from lp.bugs.model.bug import get_also_notified_subscribers
> > >>> res = get_also_notified_subscribers(linux_source_bug.bugtasks[0])
> > >>> res
> > - [<Person at ...>]
> > + (<Person at ...>,)
>
> These two original tests were brittle and a bit obscure. They
> seemed to establish that (1) a list is returned and (2) the list
> contains one or more Persons. I suspect (1) is of no significance.
>
> Your new tests establish that (1) a tuple is returned and (2) the
> tuple contains exactly one Person, as implied by the trailing comma.
> So you test more, but it's still brittle and still a bit obscure.
>
> It's not worth spending too much time on any given instance of these
> problems, but it's good to have fixes ready when you come across
> them: listify so that it won't matter what kind of iterable you get,
> or print just the length, or use a “[foo] =
> give_me_a_list_or_tuple()” assignment. The big question of course
> is what the test means to prove and what is incidental. Stay close
> to the former and away from the latter.

Good points, well put. I've amended those.

>
>
> === modified file 'lib/lp/bugs/model/bug.py'
> --- lib/lp/bugs/model/bug.py 2011-12-30 06:14:56 +0000
> +++ lib/lp/bugs/model/bug.py 2012-01-03 17:52:00 +0000
>
> @@ -948,9 +949,10 @@
> > BugSubscription.bug_id == self.id).order_by(BugSubscription.id)
> > return DecoratedResultSet(results, operator.itemgetter(1))
> >
> > - def getSubscriptionInfo(self, level=BugNotificationLevel.LIFECYCLE):
> > + def getSubscriptionInfo(self, level=None):
> > """See `IBug`."""
> > - return BugSubscriptionInfo(self, level)
> > + return BugSubscriptionInfo(
> > + self, BugNotificationLevel.LIFECYCLE if level is None else
> level)
>
> Found the "if/else" slightly hard to read here. Maybe I'm just not
> used to the syntax yet, but it made me double-check that nothing
> else goes on in this line. Please consider putting the if/else on a
> separate line.

Done.

>
>
> @@ -1002,6 +1004,8 @@
> > # the regular proxied object.
> > return sorted(
> > indirect_subscribers,
> > + # XXX: GavinPanella 2011-12-12 bug=???: Should probably use
> > + # person_sort_key.
> ...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/configure.zcml'
2--- lib/lp/bugs/configure.zcml 2011-12-30 08:03:42 +0000
3+++ lib/lp/bugs/configure.zcml 2012-01-04 18:01:30 +0000
4@@ -650,6 +650,7 @@
5 permission="launchpad.View"
6 attributes="
7 bug
8+ bugtask
9 level
10 " />
11 <!-- Properties -->
12@@ -659,12 +660,24 @@
13 all_assignees
14 all_pillar_owners_without_bug_supervisors
15 also_notified_subscribers
16+ direct_subscribers
17+ direct_subscribers_at_all_levels
18 direct_subscriptions
19+ direct_subscriptions_at_all_levels
20 duplicate_only_subscriptions
21 duplicate_subscriptions
22 indirect_subscribers
23+ muted_subscribers
24+ structural_subscribers
25 structural_subscriptions
26 " />
27+ <!-- Methods -->
28+ <require
29+ permission="launchpad.View"
30+ attributes="
31+ forLevel
32+ forTask
33+ " />
34 </class>
35
36 <!-- PersonSubscriptionInfo -->
37
38=== modified file 'lib/lp/bugs/doc/bugsubscription.txt'
39--- lib/lp/bugs/doc/bugsubscription.txt 2011-12-24 17:49:30 +0000
40+++ lib/lp/bugs/doc/bugsubscription.txt 2012-01-04 18:01:30 +0000
41@@ -98,17 +98,17 @@
42 Finer-grained access to indirect subscribers is provided by
43 getAlsoNotifiedSubscribers() and getSubscribersFromDuplicates().
44
45- >>> linux_source_bug.getAlsoNotifiedSubscribers()
46+ >>> list(linux_source_bug.getAlsoNotifiedSubscribers())
47 [<Person at ...>]
48- >>> linux_source_bug.getSubscribersFromDuplicates()
49- ()
50+ >>> list(linux_source_bug.getSubscribersFromDuplicates())
51+ []
52
53 It is also possible to get the list of indirect subscribers for an
54 individual bug task.
55
56 >>> from lp.bugs.model.bug import get_also_notified_subscribers
57 >>> res = get_also_notified_subscribers(linux_source_bug.bugtasks[0])
58- >>> res
59+ >>> list(res)
60 [<Person at ...>]
61
62 These are security proxied.
63
64=== modified file 'lib/lp/bugs/interfaces/bug.py'
65--- lib/lp/bugs/interfaces/bug.py 2012-01-01 02:58:52 +0000
66+++ lib/lp/bugs/interfaces/bug.py 2012-01-04 18:01:30 +0000
67@@ -578,10 +578,11 @@
68 If no such `BugSubscription` exists, return None.
69 """
70
71- def getSubscriptionInfo(level):
72+ def getSubscriptionInfo(level=None):
73 """Return a `BugSubscriptionInfo` at the given `level`.
74
75- :param level: A member of `BugNotificationLevel`.
76+ :param level: A member of `BugNotificationLevel`. Defaults to
77+ `BugSubscriptionLevel.LIFECYCLE` if unspecified.
78 """
79
80 def getBugNotificationRecipients(duplicateof=None, old_bug=None,
81
82=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
83--- lib/lp/bugs/interfaces/bugtask.py 2012-01-01 02:58:52 +0000
84+++ lib/lp/bugs/interfaces/bugtask.py 2012-01-04 18:01:30 +0000
85@@ -560,7 +560,7 @@
86 title=_('Assigned to'), required=False,
87 vocabulary='ValidAssignee',
88 readonly=True))
89- assigneeID = Attribute('The assignee ID (for eager loading)')
90+ assigneeID = Int(title=_('The assignee ID (for eager loading)'))
91 bugtargetdisplayname = exported(
92 Text(title=_("The short, descriptive name of the target"),
93 readonly=True),
94
95=== modified file 'lib/lp/bugs/model/bug.py'
96--- lib/lp/bugs/model/bug.py 2011-12-30 06:14:56 +0000
97+++ lib/lp/bugs/model/bug.py 2012-01-04 18:01:30 +0000
98@@ -158,6 +158,7 @@
99 from lp.bugs.model.bugwatch import BugWatch
100 from lp.bugs.model.structuralsubscription import (
101 get_structural_subscribers,
102+ get_structural_subscriptions,
103 get_structural_subscriptions_for_bug,
104 )
105 from lp.code.interfaces.branchcollection import IAllBranches
106@@ -948,8 +949,10 @@
107 BugSubscription.bug_id == self.id).order_by(BugSubscription.id)
108 return DecoratedResultSet(results, operator.itemgetter(1))
109
110- def getSubscriptionInfo(self, level=BugNotificationLevel.LIFECYCLE):
111+ def getSubscriptionInfo(self, level=None):
112 """See `IBug`."""
113+ if level is None:
114+ level = BugNotificationLevel.LIFECYCLE
115 return BugSubscriptionInfo(self, level)
116
117 def getDirectSubscriptions(self):
118@@ -1002,6 +1005,7 @@
119 # the regular proxied object.
120 return sorted(
121 indirect_subscribers,
122+ # XXX: GavinPanella 2011-12-12 bug=911752: Use person_sort_key.
123 key=lambda x: removeSecurityProxy(x).displayname)
124
125 def getSubscriptionsFromDuplicates(self, recipients=None):
126@@ -1030,14 +1034,13 @@
127 if level is None:
128 level = BugNotificationLevel.LIFECYCLE
129 info = self.getSubscriptionInfo(level)
130-
131 if recipients is not None:
132- # Pre-load duplicate bugs.
133- list(self.duplicates)
134+ list(self.duplicates) # Pre-load duplicate bugs.
135+ info.duplicate_only_subscribers # Pre-load subscribers.
136 for subscription in info.duplicate_only_subscriptions:
137 recipients.addDupeSubscriber(
138 subscription.person, subscription.bug)
139- return info.duplicate_only_subscriptions.subscribers.sorted
140+ return info.duplicate_only_subscribers.sorted
141
142 def getSubscribersForPerson(self, person):
143 """See `IBug."""
144@@ -2389,48 +2392,44 @@
145 if IBug.providedBy(bug_or_bugtask):
146 bug = bug_or_bugtask
147 bugtasks = bug.bugtasks
148+ info = bug.getSubscriptionInfo(level)
149 elif IBugTask.providedBy(bug_or_bugtask):
150 bug = bug_or_bugtask.bug
151 bugtasks = [bug_or_bugtask]
152+ info = bug.getSubscriptionInfo(level).forTask(bug_or_bugtask)
153 else:
154 raise ValueError('First argument must be bug or bugtask')
155
156 if bug.private:
157 return []
158
159- # Direct subscriptions always take precedence over indirect
160- # subscriptions.
161- direct_subscribers = set(bug.getDirectSubscribers())
162-
163- also_notified_subscribers = set()
164-
165- for bugtask in bugtasks:
166- if (bugtask.assignee and
167- bugtask.assignee not in direct_subscribers):
168- # We have an assignee that is not a direct subscriber.
169- also_notified_subscribers.add(bugtask.assignee)
170- if recipients is not None:
171+ # Subscribers to exclude.
172+ exclude_subscribers = frozenset().union(
173+ info.direct_subscribers_at_all_levels, info.muted_subscribers)
174+ # Get also-notified subscribers at the given level for the given tasks.
175+ also_notified_subscribers = info.also_notified_subscribers
176+
177+ if recipients is not None:
178+ for bugtask in bugtasks:
179+ assignee = bugtask.assignee
180+ if assignee in also_notified_subscribers:
181+ # We have an assignee that is not a direct subscriber.
182 recipients.addAssignee(bugtask.assignee)
183-
184- # If the target's bug supervisor isn't set...
185- pillar = bugtask.pillar
186- if (pillar.bug_supervisor is None and
187- pillar.official_malone and
188- pillar.owner not in direct_subscribers):
189- # ...we add the owner as a subscriber.
190- also_notified_subscribers.add(pillar.owner)
191- if recipients is not None:
192- recipients.addRegistrant(pillar.owner, pillar)
193+ # If the target's bug supervisor isn't set...
194+ pillar = bugtask.pillar
195+ if pillar.official_malone and pillar.bug_supervisor is None:
196+ if pillar.owner in also_notified_subscribers:
197+ # ...we add the owner as a subscriber.
198+ recipients.addRegistrant(pillar.owner, pillar)
199
200 # This structural subscribers code omits direct subscribers itself.
201- also_notified_subscribers.update(
202- get_structural_subscribers(
203- bug_or_bugtask, recipients, level, direct_subscribers))
204+ # TODO: Pass the info object into get_structural_subscribers for
205+ # efficiency... or do the recipients stuff here.
206+ structural_subscribers = get_structural_subscribers(
207+ bug_or_bugtask, recipients, level, exclude_subscribers)
208+ assert also_notified_subscribers.issuperset(structural_subscribers)
209
210- # Remove security proxy for the sort key, but return
211- # the regular proxied object.
212- return sorted(also_notified_subscribers,
213- key=lambda x: removeSecurityProxy(x).displayname)
214+ return also_notified_subscribers.sorted
215
216
217 def load_people(*where):
218@@ -2443,7 +2442,8 @@
219 `ValidPersonCache` records are loaded simultaneously.
220 """
221 return PersonSet()._getPrecachedPersons(
222- origin=[Person], conditions=where, need_validity=True)
223+ origin=[Person], conditions=where, need_validity=True,
224+ need_preferred_email=True)
225
226
227 class BugSubscriberSet(frozenset):
228@@ -2573,13 +2573,57 @@
229
230 def __init__(self, bug, level):
231 self.bug = bug
232+ self.bugtask = None # Implies all.
233 assert level is not None
234 self.level = level
235+ # This cache holds related `BugSubscriptionInfo` instances relating to
236+ # the same bug but with different levels and/or choice of bugtask.
237+ self.cache = {self.cache_key: self}
238+ # This is often used in event handlers, many of which block implicit
239+ # flushes. However, the data needs to be in the database for the
240+ # queries herein to give correct answers.
241+ Store.of(bug).flush()
242+
243+ @property
244+ def cache_key(self):
245+ """A (bug ID, bugtask ID, level) tuple for use as a hash key.
246+
247+ This helps `forTask()` and `forLevel()` to be more efficient,
248+ returning previously populated instances to avoid running the same
249+ queries against the database again and again.
250+ """
251+ bugtask_id = None if self.bugtask is None else self.bugtask.id
252+ return self.bug.id, bugtask_id, self.level
253+
254+ def forTask(self, bugtask):
255+ """Create a new `BugSubscriptionInfo` limited to `bugtask`.
256+
257+ The given task must refer to this object's bug. If `None` is passed a
258+ new `BugSubscriptionInfo` instance is returned with no limit.
259+ """
260+ info = self.__class__(self.bug, self.level)
261+ info.bugtask, info.cache = bugtask, self.cache
262+ return self.cache.setdefault(info.cache_key, info)
263+
264+ def forLevel(self, level):
265+ """Create a new `BugSubscriptionInfo` limited to `level`."""
266+ info = self.__class__(self.bug, level)
267+ info.bugtask, info.cache = self.bugtask, self.cache
268+ return self.cache.setdefault(info.cache_key, info)
269+
270+ @cachedproperty
271+ @freeze(BugSubscriberSet)
272+ def muted_subscribers(self):
273+ muted_people = Select(BugMute.person_id, BugMute.bug == self.bug)
274+ return load_people(Person.id.is_in(muted_people))
275
276 @cachedproperty
277 @freeze(BugSubscriptionSet)
278- def old_direct_subscriptions(self):
279- """The bug's direct subscriptions."""
280+ def direct_subscriptions(self):
281+ """The bug's direct subscriptions.
282+
283+ Excludes muted subscriptions.
284+ """
285 return IStore(BugSubscription).find(
286 BugSubscription,
287 BugSubscription.bug_notification_level >= self.level,
288@@ -2587,66 +2631,64 @@
289 Not(In(BugSubscription.person_id,
290 Select(BugMute.person_id, BugMute.bug_id == self.bug.id))))
291
292- @cachedproperty
293- def direct_subscriptions_and_subscribers(self):
294- """The bug's direct subscriptions."""
295- res = IStore(BugSubscription).find(
296- (BugSubscription, Person),
297- BugSubscription.bug_notification_level >= self.level,
298- BugSubscription.bug == self.bug,
299- BugSubscription.person_id == Person.id,
300- Not(In(BugSubscription.person_id,
301- Select(BugMute.person_id,
302- BugMute.bug_id == self.bug.id))))
303- # Here we could test for res.count() but that will execute another
304- # query. This structure avoids the extra query.
305- return zip(*res) or ((), ())
306-
307- @cachedproperty
308- @freeze(BugSubscriptionSet)
309- def direct_subscriptions(self):
310- return self.direct_subscriptions_and_subscribers[0]
311-
312- @cachedproperty
313- @freeze(BugSubscriberSet)
314+ @property
315 def direct_subscribers(self):
316- return self.direct_subscriptions_and_subscribers[1]
317+ """The bug's direct subscriptions.
318+
319+ Excludes muted subscribers.
320+ """
321+ return self.direct_subscriptions.subscribers
322+
323+ @property
324+ def direct_subscriptions_at_all_levels(self):
325+ """The bug's direct subscriptions at all levels.
326+
327+ Excludes muted subscriptions.
328+ """
329+ return self.forLevel(
330+ BugNotificationLevel.LIFECYCLE).direct_subscriptions
331+
332+ @property
333+ def direct_subscribers_at_all_levels(self):
334+ """The bug's direct subscribers at all levels.
335+
336+ Excludes muted subscribers.
337+ """
338+ return self.direct_subscriptions_at_all_levels.subscribers
339
340 @cachedproperty
341- def duplicate_subscriptions_and_subscribers(self):
342- """Subscriptions to duplicates of the bug."""
343+ @freeze(BugSubscriptionSet)
344+ def duplicate_subscriptions(self):
345+ """Subscriptions to duplicates of the bug.
346+
347+ Excludes muted subscriptions.
348+ """
349 if self.bug.private:
350- return ((), ())
351+ return ()
352 else:
353- res = IStore(BugSubscription).find(
354- (BugSubscription, Person),
355+ return IStore(BugSubscription).find(
356+ BugSubscription,
357 BugSubscription.bug_notification_level >= self.level,
358 BugSubscription.bug_id == Bug.id,
359- BugSubscription.person_id == Person.id,
360 Bug.duplicateof == self.bug,
361 Not(In(BugSubscription.person_id,
362 Select(BugMute.person_id, BugMute.bug_id == Bug.id))))
363- # Here we could test for res.count() but that will execute another
364- # query. This structure avoids the extra query.
365- return zip(*res) or ((), ())
366-
367- @cachedproperty
368- @freeze(BugSubscriptionSet)
369- def duplicate_subscriptions(self):
370- return self.duplicate_subscriptions_and_subscribers[0]
371-
372- @cachedproperty
373- @freeze(BugSubscriberSet)
374+
375+ @property
376 def duplicate_subscribers(self):
377- return self.duplicate_subscriptions_and_subscribers[1]
378+ """Subscribers to duplicates of the bug.
379+
380+ Excludes muted subscribers.
381+ """
382+ return self.duplicate_subscriptions.subscribers
383
384 @cachedproperty
385 @freeze(BugSubscriptionSet)
386 def duplicate_only_subscriptions(self):
387- """Subscriptions to duplicates of the bug.
388+ """Subscriptions to duplicates of the bug only.
389
390- Excludes subscriptions for people who have a direct subscription or
391- are also notified for another reason.
392+ Excludes muted subscriptions, subscriptions for people who have a
393+ direct subscription, or who are also notified for another reason.
394 """
395 self.duplicate_subscribers # Pre-load subscribers.
396 higher_precedence = (
397@@ -2656,47 +2698,85 @@
398 subscription for subscription in self.duplicate_subscriptions
399 if subscription.person not in higher_precedence)
400
401+ @property
402+ def duplicate_only_subscribers(self):
403+ """Subscribers to duplicates of the bug only.
404+
405+ Excludes muted subscribers, subscribers who have a direct
406+ subscription, or who are also notified for another reason.
407+ """
408+ return self.duplicate_only_subscriptions.subscribers
409+
410 @cachedproperty
411 @freeze(StructuralSubscriptionSet)
412 def structural_subscriptions(self):
413- """Structural subscriptions to the bug's targets."""
414- return list(get_structural_subscriptions_for_bug(self.bug))
415+ """Structural subscriptions to the bug's targets.
416+
417+ Excludes direct subscriptions.
418+ """
419+ subject = self.bug if self.bugtask is None else self.bugtask
420+ return get_structural_subscriptions(subject, self.level)
421+
422+ @property
423+ def structural_subscribers(self):
424+ """Structural subscribers to the bug's targets.
425+
426+ Excludes direct subscribers.
427+ """
428+ return self.structural_subscriptions.subscribers
429
430 @cachedproperty
431 @freeze(BugSubscriberSet)
432 def all_assignees(self):
433- """Assignees of the bug's tasks."""
434- assignees = Select(BugTask.assigneeID, BugTask.bug == self.bug)
435- return load_people(Person.id.is_in(assignees))
436+ """Assignees of the bug's tasks.
437+
438+ *Does not* exclude muted subscribers.
439+ """
440+ if self.bugtask is None:
441+ assignees = Select(BugTask.assigneeID, BugTask.bug == self.bug)
442+ return load_people(Person.id.is_in(assignees))
443+ else:
444+ return load_people(Person.id == self.bugtask.assigneeID)
445
446 @cachedproperty
447 @freeze(BugSubscriberSet)
448 def all_pillar_owners_without_bug_supervisors(self):
449- """Owners of pillars for which no Bug supervisor is configured."""
450- for bugtask in self.bug.bugtasks:
451+ """Owners of pillars for which there is no bug supervisor.
452+
453+ The pillars must also use Launchpad for bug tracking.
454+
455+ *Does not* exclude muted subscribers.
456+ """
457+ if self.bugtask is None:
458+ bugtasks = self.bug.bugtasks
459+ else:
460+ bugtasks = [self.bugtask]
461+ for bugtask in bugtasks:
462 pillar = bugtask.pillar
463- if pillar.bug_supervisor is None:
464- yield pillar.owner
465+ if pillar.official_malone:
466+ if pillar.bug_supervisor is None:
467+ yield pillar.owner
468
469 @cachedproperty
470 def also_notified_subscribers(self):
471- """All subscribers except direct and dupe subscribers."""
472+ """All subscribers except direct, dupe, and muted subscribers."""
473 if self.bug.private:
474 return BugSubscriberSet()
475 else:
476- muted = IStore(BugMute).find(
477- Person,
478- BugMute.person_id == Person.id,
479- BugMute.bug == self.bug)
480- return BugSubscriberSet().union(
481- self.structural_subscriptions.subscribers,
482+ subscribers = BugSubscriberSet().union(
483+ self.structural_subscribers,
484 self.all_pillar_owners_without_bug_supervisors,
485- self.all_assignees).difference(
486- self.direct_subscribers).difference(muted)
487+ self.all_assignees)
488+ return subscribers.difference(
489+ self.direct_subscribers_at_all_levels,
490+ self.muted_subscribers)
491
492 @cachedproperty
493 def indirect_subscribers(self):
494- """All subscribers except direct subscribers."""
495+ """All subscribers except direct subscribers.
496+
497+ Excludes muted subscribers.
498+ """
499 return self.also_notified_subscribers.union(
500 self.duplicate_subscribers)
501
502
503=== modified file 'lib/lp/bugs/model/structuralsubscription.py'
504--- lib/lp/bugs/model/structuralsubscription.py 2011-12-30 06:14:56 +0000
505+++ lib/lp/bugs/model/structuralsubscription.py 2012-01-04 18:01:30 +0000
506@@ -3,10 +3,11 @@
507
508 __metaclass__ = type
509 __all__ = [
510+ 'get_structural_subscribers',
511+ 'get_structural_subscription_targets',
512+ 'get_structural_subscriptions',
513 'get_structural_subscriptions_for_bug',
514 'get_structural_subscriptions_for_target',
515- 'get_structural_subscribers',
516- 'get_structural_subscription_targets',
517 'StructuralSubscription',
518 'StructuralSubscriptionTargetMixin',
519 ]
520@@ -478,14 +479,14 @@
521 def getSubscriptions(self, subscriber=None):
522 """See `IStructuralSubscriptionTarget`."""
523 from lp.registry.model.person import Person
524- clauses = [StructuralSubscription.subscriberID==Person.id]
525+ clauses = [StructuralSubscription.subscriberID == Person.id]
526 for key, value in self._target_args.iteritems():
527 clauses.append(
528- getattr(StructuralSubscription, key)==value)
529+ getattr(StructuralSubscription, key) == value)
530
531 if subscriber is not None:
532 clauses.append(
533- StructuralSubscription.subscriberID==subscriber.id)
534+ StructuralSubscription.subscriberID == subscriber.id)
535
536 store = Store.of(self.__helper.pillar)
537 return store.find(
538@@ -588,54 +589,96 @@
539 *conditions)
540
541
542-def get_structural_subscribers(
543- bug_or_bugtask, recipients, level, direct_subscribers=None):
544- """Return subscribers for bug or bugtask at level.
545-
546- :param bug: a bug.
547- :param recipients: a BugNotificationRecipients object or None.
548- Populates if given.
549- :param level: a level from lp.bugs.enum.BugNotificationLevel.
550- :param direct_subscribers: a collection of Person objects who are
551- directly subscribed to the bug.
552-
553- Excludes structural subscriptions for people who are directly subscribed
554- to the bug."""
555- if IBug.providedBy(bug_or_bugtask):
556- bug = bug_or_bugtask
557- bugtasks = bug.bugtasks
558- elif IBugTask.providedBy(bug_or_bugtask):
559- bug = bug_or_bugtask.bug
560- bugtasks = [bug_or_bugtask]
561- else:
562- raise ValueError('First argument must be bug or bugtask')
563+def query_structural_subscriptions(
564+ what, bug, bugtasks, level, exclude=None):
565+ """Query into structural subscriptions for a given bug.
566+
567+ :param what: The fields to fetch. Choose from `Person`,
568+ `StructuralSubscription`, `BugSubscriptionFilter`, or a combo.
569+ :param bug: An `IBug`
570+ :param bugtasks: An iterable of `IBugTask`.
571+ :param level: A level from `BugNotificationLevel`. Filters below this
572+ level will be excluded.
573+ :param exclude: `Person`s to exclude (e.g. direct subscribers).
574+ """
575+ from lp.registry.model.person import Person # Circular.
576 filter_id_query = (
577 _get_structural_subscription_filter_id_query(
578- bug, bugtasks, level, direct_subscribers))
579+ bug, bugtasks, level, exclude))
580 if not filter_id_query:
581 return EmptyResultSet()
582- # This is here because of a circular import.
583- from lp.registry.model.person import Person
584 source = IStore(StructuralSubscription).using(
585 StructuralSubscription,
586 Join(BugSubscriptionFilter,
587 BugSubscriptionFilter.structural_subscription_id ==
588 StructuralSubscription.id),
589 Join(Person,
590- Person.id == StructuralSubscription.subscriberID),
591- )
592+ Person.id == StructuralSubscription.subscriberID))
593+ conditions = In(
594+ BugSubscriptionFilter.id, filter_id_query)
595+ return source.find(what, conditions)
596+
597+
598+def get_bug_and_bugtasks(bug_or_bugtask):
599+ """Return a bug and a list of bugtasks given a bug or a bugtask.
600+
601+ :param bug_or_bugtask: An `IBug` or `IBugTask`.
602+ :raises ValueError: If `bug_or_bugtask` does not provide `IBug` or
603+ `IBugTask`.
604+ """
605+ if IBug.providedBy(bug_or_bugtask):
606+ return bug_or_bugtask, bug_or_bugtask.bugtasks
607+ elif IBugTask.providedBy(bug_or_bugtask):
608+ return bug_or_bugtask.bug, [bug_or_bugtask]
609+ else:
610+ raise ValueError(
611+ "Expected bug or bugtask, got %r" % (bug_or_bugtask,))
612+
613+
614+def get_structural_subscriptions(bug_or_bugtask, level, exclude=None):
615+ """Return subscriptions for bug or bugtask at level.
616+
617+ :param bug_or_bugtask: An `IBug` or `IBugTask`.
618+ :param level: A level from `BugNotificationLevel`. Filters below this
619+ level will be excluded.
620+ :param exclude: `Person`s to exclude (e.g. direct subscribers).
621+ """
622+ from lp.registry.model.person import Person # Circular.
623+ bug, bugtasks = get_bug_and_bugtasks(bug_or_bugtask)
624+ subscriptions = query_structural_subscriptions(
625+ StructuralSubscription, bug, bugtasks, level, exclude)
626+ # Return only the first subscription and filter per subscriber.
627+ subscriptions.config(distinct=(Person.id,))
628+ subscriptions.order_by(
629+ Person.id, StructuralSubscription.id,
630+ BugSubscriptionFilter.id)
631+ return subscriptions
632+
633+
634+def get_structural_subscribers(
635+ bug_or_bugtask, recipients, level, exclude=None):
636+ """Return subscribers for bug or bugtask at level.
637+
638+ :param bug_or_bugtask: An `IBug` or `IBugTask`.
639+ :param recipients: A `BugNotificationRecipients` object or
640+ `None`, which will be populated if provided.
641+ :param level: A level from `BugNotificationLevel`. Filters below this
642+ level will be excluded.
643+ :param exclude: `Person`s to exclude (e.g. direct subscribers).
644+ """
645+ from lp.registry.model.person import Person # Circular.
646+ bug, bugtasks = get_bug_and_bugtasks(bug_or_bugtask)
647 if recipients is None:
648- return source.find(
649- Person,
650- In(BugSubscriptionFilter.id,
651- filter_id_query)).config(distinct=True).order_by()
652+ subscribers = query_structural_subscriptions(
653+ Person, bug, bugtasks, level, exclude)
654+ subscribers.config(distinct=True)
655+ return subscribers.order_by()
656 else:
657+ results = query_structural_subscriptions(
658+ (Person, StructuralSubscription, BugSubscriptionFilter),
659+ bug, bugtasks, level, exclude)
660 subscribers = []
661- query_results = source.find(
662- (Person, StructuralSubscription, BugSubscriptionFilter),
663- In(BugSubscriptionFilter.id, filter_id_query))
664- for person, subscription, filter in query_results:
665- # Set up results.
666+ for person, subscription, filter in results:
667 if person not in recipients:
668 subscribers.append(person)
669 recipients.addStructuralSubscriber(
670@@ -839,7 +882,7 @@
671 group_by=(BugSubscriptionFilter.id,),
672 having=Count(
673 SQL('CASE WHEN BugSubscriptionFilterTag.include '
674- 'THEN BugSubscriptionFilterTag.tag END'))==0)
675+ 'THEN BugSubscriptionFilterTag.tag END')) == 0)
676 else:
677 # The bug has some tags. This will require a bit of fancy
678 # footwork. First, though, we will simply want to leave out
679
680=== modified file 'lib/lp/bugs/model/tests/test_bug.py'
681--- lib/lp/bugs/model/tests/test_bug.py 2012-01-01 02:58:52 +0000
682+++ lib/lp/bugs/model/tests/test_bug.py 2012-01-04 18:01:30 +0000
683@@ -22,6 +22,7 @@
684 from lp.bugs.errors import BugCannotBePrivate
685 from lp.bugs.interfaces.bugnotification import IBugNotificationSet
686 from lp.bugs.interfaces.bugtask import BugTaskStatus
687+from lp.bugs.mail.bugnotificationrecipients import BugNotificationRecipients
688 from lp.bugs.model.bug import (
689 BugNotification,
690 BugSubscriptionInfo,
691@@ -35,6 +36,7 @@
692 feature_flags,
693 login_person,
694 person_logged_in,
695+ record_two_runs,
696 set_feature_flag,
697 StormStatementRecorder,
698 TestCaseWithFactory,
699@@ -166,7 +168,7 @@
700 with StormStatementRecorder() as recorder:
701 subscribers = list(bug.getDirectSubscribers())
702 self.assertThat(len(subscribers), Equals(10 + 1))
703- self.assertThat(recorder, HasQueryCount(Equals(1)))
704+ self.assertThat(recorder, HasQueryCount(Equals(2)))
705
706 def test_mark_as_duplicate_query_count(self):
707 bug = self.factory.makeBug()
708@@ -476,6 +478,78 @@
709 self.assertContentEqual(public_branches, linked_branches)
710 self.assertNotIn(private_branch, linked_branches)
711
712+ def test_getDirectSubscribers_with_recipients_query_count(self):
713+ # getDirectSubscribers() uses a constant number of queries when given
714+ # a recipients argument regardless of the number of subscribers.
715+ bug = self.factory.makeBug()
716+
717+ def create_subscriber():
718+ subscriber = self.factory.makePerson()
719+ with person_logged_in(subscriber):
720+ bug.subscribe(subscriber, subscriber)
721+
722+ def get_subscribers():
723+ recipients = BugNotificationRecipients()
724+ subs = bug.getDirectSubscribers(recipients=recipients)
725+ list(subs) # Ensure they're pulled.
726+
727+ recorder1, recorder2 = record_two_runs(
728+ get_subscribers, create_subscriber, 3)
729+ self.assertThat(
730+ recorder2, HasQueryCount(Equals(recorder1.count)))
731+
732+ def test_getSubscribersFromDuplicates_with_recipients_query_count(self):
733+ # getSubscribersFromDuplicates() uses a constant number of queries
734+ # when given a recipients argument regardless of the number of
735+ # subscribers.
736+ bug = self.factory.makeBug()
737+ duplicate_bug = self.factory.makeBug()
738+ with person_logged_in(duplicate_bug.owner):
739+ duplicate_bug.markAsDuplicate(bug)
740+
741+ def create_subscriber():
742+ subscriber = self.factory.makePerson()
743+ with person_logged_in(subscriber):
744+ duplicate_bug.subscribe(subscriber, subscriber)
745+
746+ def get_subscribers():
747+ recipients = BugNotificationRecipients()
748+ subs = bug.getSubscribersFromDuplicates(recipients=recipients)
749+ list(subs) # Ensure they're pulled.
750+
751+ recorder1, recorder2 = record_two_runs(
752+ get_subscribers, create_subscriber, 3)
753+ self.assertThat(
754+ recorder2, HasQueryCount(Equals(recorder1.count)))
755+
756+ def test_getAlsoNotifiedSubscribers_with_recipients_query_count(self):
757+ # getAlsoNotifiedSubscribers() uses a constant number of queries when
758+ # given a recipients argument regardless of the number of subscribers.
759+ bug = self.factory.makeBug()
760+
761+ def create_stuff():
762+ # Create a new bugtask, set its assignee, set its pillar's
763+ # official_malone=True, and subscribe someone to its target.
764+ bugtask = self.factory.makeBugTask(bug=bug)
765+ with person_logged_in(bugtask.owner):
766+ bugtask.transitionToAssignee(bugtask.owner)
767+ with person_logged_in(bugtask.pillar.owner):
768+ bugtask.pillar.official_malone = True
769+ subscriber = self.factory.makePerson()
770+ with person_logged_in(subscriber):
771+ bugtask.target.addSubscription(
772+ subscriber, subscriber)
773+
774+ def get_subscribers():
775+ recipients = BugNotificationRecipients()
776+ subs = bug.getAlsoNotifiedSubscribers(recipients=recipients)
777+ list(subs) # Ensure they're pulled.
778+
779+ recorder1, recorder2 = record_two_runs(
780+ get_subscribers, create_stuff, 3)
781+ self.assertThat(
782+ recorder2, HasQueryCount(Equals(recorder1.count)))
783+
784
785 class TestBugPrivateAndSecurityRelatedUpdatesMixin:
786
787@@ -553,7 +627,7 @@
788 # If the bug is for a private project, then other direct subscribers
789 # should be unsubscribed.
790
791- (bug, bug_owner, bugtask_a, bugtask_b, default_bugtask) = (
792+ (bug, bug_owner, bugtask_a, bugtask_b, default_bugtask) = (
793 self.createBugTasksAndSubscribers())
794 initial_subscribers = set((
795 self.factory.makePerson(), bugtask_a.owner, bug_owner,
796@@ -586,7 +660,7 @@
797 # If the bug is for a private project, then other direct subscribers
798 # should be unsubscribed.
799
800- (bug, bug_owner, bugtask_a, bugtask_b, default_bugtask) = (
801+ (bug, bug_owner, bugtask_a, bugtask_b, default_bugtask) = (
802 self.createBugTasksAndSubscribers(private_security_related=True))
803 initial_subscribers = set((
804 self.factory.makePerson(), bug_owner,
805@@ -617,10 +691,10 @@
806 # If the bug is for a private project, then other direct subscribers
807 # should be unsubscribed.
808
809- (bug, bug_owner, bugtask_a, bugtask_b, default_bugtask) = (
810+ (bug, bug_owner, bugtask_a, bugtask_b, default_bugtask) = (
811 self.createBugTasksAndSubscribers(private_security_related=True))
812 initial_subscribers = set((
813- self.factory.makePerson(), bug_owner,
814+ self.factory.makePerson(), bug_owner,
815 bugtask_a.pillar.security_contact, bugtask_a.pillar.driver,
816 bugtask_a.pillar.bug_supervisor))
817
818@@ -644,7 +718,7 @@
819 # When a bug is marked as private=false and security_related=false,
820 # any existing subscriptions are left alone.
821
822- (bug, bug_owner, bugtask_a, bugtask_b, default_bugtask) = (
823+ (bug, bug_owner, bugtask_a, bugtask_b, default_bugtask) = (
824 self.createBugTasksAndSubscribers(private_security_related=True))
825 initial_subscribers = set((
826 self.factory.makePerson(), bug_owner,
827@@ -751,7 +825,7 @@
828 # The bug supervisors are unsubscribed if a bug is made public and an
829 # email is sent telling them they have been unsubscribed.
830
831- (bug, bug_owner, bugtask_a, bugtask_b, default_bugtask) = (
832+ (bug, bug_owner, bugtask_a, bugtask_b, default_bugtask) = (
833 self.createBugTasksAndSubscribers(private_security_related=True))
834
835 with person_logged_in(bug_owner):
836@@ -796,7 +870,7 @@
837 # set to false and an email is sent telling them they have been
838 # unsubscribed.
839
840- (bug, bug_owner, bugtask_a, bugtask_b, default_bugtask) = (
841+ (bug, bug_owner, bugtask_a, bugtask_b, default_bugtask) = (
842 self.createBugTasksAndSubscribers(private_security_related=True))
843
844 with person_logged_in(bug_owner):
845
846=== modified file 'lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py'
847--- lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py 2012-01-01 02:58:52 +0000
848+++ lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py 2012-01-04 18:01:30 +0000
849@@ -46,7 +46,7 @@
850 ]
851 observed = load_people(
852 Person.id.is_in(person.id for person in expected))
853- self.assertEqual(set(expected), set(observed))
854+ self.assertContentEqual(expected, observed)
855
856
857 class TestSubscriptionRelatedSets(TestCaseWithFactory):
858@@ -128,9 +128,8 @@
859 with person_logged_in(self.bug.owner):
860 self.bug.unsubscribe(self.bug.owner, self.bug.owner)
861
862- def getInfo(self):
863- return BugSubscriptionInfo(
864- self.bug, BugNotificationLevel.LIFECYCLE)
865+ def getInfo(self, level=BugNotificationLevel.LIFECYCLE):
866+ return BugSubscriptionInfo(self.bug, level)
867
868 def _create_direct_subscriptions(self):
869 subscribers = (
870@@ -142,22 +141,93 @@
871 for subscriber in subscribers)
872 return subscribers, subscriptions
873
874+ def test_forTask(self):
875+ # `forTask` returns a new `BugSubscriptionInfo` narrowed to the given
876+ # bugtask.
877+ info = self.getInfo()
878+ self.assertIs(None, info.bugtask)
879+ # If called with the current bugtask the same `BugSubscriptionInfo`
880+ # instance is returned.
881+ self.assertIs(info, info.forTask(info.bugtask))
882+ # If called with a different bugtask a new `BugSubscriptionInfo` is
883+ # created.
884+ bugtask = self.bug.default_bugtask
885+ info_for_task = info.forTask(bugtask)
886+ self.assertIs(bugtask, info_for_task.bugtask)
887+ self.assertIsNot(info, info_for_task)
888+ # The instances share a cache of `BugSubscriptionInfo` instances.
889+ expected_cache = {
890+ info.cache_key: info,
891+ info_for_task.cache_key: info_for_task,
892+ }
893+ self.assertEqual(expected_cache, info.cache)
894+ self.assertIs(info.cache, info_for_task.cache)
895+ # Calling `forTask` again looks in the cache first.
896+ self.assertIs(info, info_for_task.forTask(info.bugtask))
897+ self.assertIs(info_for_task, info.forTask(info_for_task.bugtask))
898+ # The level is the same.
899+ self.assertEqual(info.level, info_for_task.level)
900+
901+ def test_forLevel(self):
902+ # `forLevel` returns a new `BugSubscriptionInfo` narrowed to the given
903+ # subscription level.
904+ info = self.getInfo(BugNotificationLevel.LIFECYCLE)
905+ # If called with the current level the same `BugSubscriptionInfo`
906+ # instance is returned.
907+ self.assertIs(info, info.forLevel(info.level))
908+ # If called with a different level a new `BugSubscriptionInfo` is
909+ # created.
910+ level = BugNotificationLevel.METADATA
911+ info_for_level = info.forLevel(level)
912+ self.assertEqual(level, info_for_level.level)
913+ self.assertIsNot(info, info_for_level)
914+ # The instances share a cache of `BugSubscriptionInfo` instances.
915+ expected_cache = {
916+ info.cache_key: info,
917+ info_for_level.cache_key: info_for_level,
918+ }
919+ self.assertEqual(expected_cache, info.cache)
920+ self.assertIs(info.cache, info_for_level.cache)
921+ # Calling `forLevel` again looks in the cache first.
922+ self.assertIs(info, info_for_level.forLevel(info.level))
923+ self.assertIs(info_for_level, info.forLevel(info_for_level.level))
924+ # The bugtask is the same.
925+ self.assertIs(info.bugtask, info_for_level.bugtask)
926+
927+ def test_muted(self):
928+ # The set of muted subscribers for the bug.
929+ subscribers, subscriptions = self._create_direct_subscriptions()
930+ sub1, sub2 = subscribers
931+ with person_logged_in(sub1):
932+ self.bug.mute(sub1, sub1)
933+ self.assertContentEqual([sub1], self.getInfo().muted_subscribers)
934+
935 def test_direct(self):
936 # The set of direct subscribers.
937 subscribers, subscriptions = self._create_direct_subscriptions()
938 found_subscriptions = self.getInfo().direct_subscriptions
939- self.assertEqual(set(subscriptions), found_subscriptions)
940- self.assertEqual(subscriptions, found_subscriptions.sorted)
941- self.assertEqual(set(subscribers), found_subscriptions.subscribers)
942- self.assertEqual(subscribers, found_subscriptions.subscribers.sorted)
943+ self.assertContentEqual(subscriptions, found_subscriptions)
944+ self.assertContentEqual(subscribers, found_subscriptions.subscribers)
945
946- def test_muted_direct(self):
947+ def test_direct_muted(self):
948 # If a direct is muted, it is not listed.
949 subscribers, subscriptions = self._create_direct_subscriptions()
950 with person_logged_in(subscribers[0]):
951 self.bug.mute(subscribers[0], subscribers[0])
952 found_subscriptions = self.getInfo().direct_subscriptions
953- self.assertEqual(set([subscriptions[1]]), found_subscriptions)
954+ self.assertContentEqual([subscriptions[1]], found_subscriptions)
955+
956+ def test_all_direct(self):
957+ # The set of all direct subscribers, regardless of level.
958+ subscribers, subscriptions = self._create_direct_subscriptions()
959+ # Change the first subscription to be for comments only.
960+ sub1, sub2 = subscriptions
961+ with person_logged_in(sub1.person):
962+ sub1.bug_notification_level = BugNotificationLevel.LIFECYCLE
963+ info = self.getInfo(BugNotificationLevel.COMMENTS)
964+ self.assertContentEqual([sub2], info.direct_subscriptions)
965+ self.assertContentEqual(
966+ [sub1, sub2], info.direct_subscriptions_at_all_levels)
967
968 def _create_duplicate_subscription(self):
969 duplicate_bug = self.factory.makeBug(product=self.target)
970@@ -171,34 +241,26 @@
971 def test_duplicate(self):
972 # The set of subscribers from duplicate bugs.
973 found_subscriptions = self.getInfo().duplicate_subscriptions
974- self.assertEqual(set(), found_subscriptions)
975- self.assertEqual((), found_subscriptions.sorted)
976- self.assertEqual(set(), found_subscriptions.subscribers)
977- self.assertEqual((), found_subscriptions.subscribers.sorted)
978+ self.assertContentEqual([], found_subscriptions)
979+ self.assertContentEqual([], found_subscriptions.subscribers)
980 duplicate_bug, duplicate_bug_subscription = (
981 self._create_duplicate_subscription())
982 found_subscriptions = self.getInfo().duplicate_subscriptions
983- self.assertEqual(
984- set([duplicate_bug_subscription]),
985+ self.assertContentEqual(
986+ [duplicate_bug_subscription],
987 found_subscriptions)
988- self.assertEqual(
989- (duplicate_bug_subscription,),
990- found_subscriptions.sorted)
991- self.assertEqual(
992- set([duplicate_bug.owner]),
993+ self.assertContentEqual(
994+ [duplicate_bug.owner],
995 found_subscriptions.subscribers)
996- self.assertEqual(
997- (duplicate_bug.owner,),
998- found_subscriptions.subscribers.sorted)
999
1000- def test_muted_duplicate(self):
1001+ def test_duplicate_muted(self):
1002 # If a duplicate is muted, it is not listed.
1003 duplicate_bug, duplicate_bug_subscription = (
1004 self._create_duplicate_subscription())
1005 with person_logged_in(duplicate_bug.owner):
1006 self.bug.mute(duplicate_bug.owner, duplicate_bug.owner)
1007 found_subscriptions = self.getInfo().duplicate_subscriptions
1008- self.assertEqual(set(), found_subscriptions)
1009+ self.assertContentEqual([], found_subscriptions)
1010
1011 def test_duplicate_for_private_bug(self):
1012 # The set of subscribers from duplicate bugs is always empty when the
1013@@ -209,10 +271,8 @@
1014 with person_logged_in(self.bug.owner):
1015 self.bug.setPrivate(True, self.bug.owner)
1016 found_subscriptions = self.getInfo().duplicate_subscriptions
1017- self.assertEqual(set(), found_subscriptions)
1018- self.assertEqual((), found_subscriptions.sorted)
1019- self.assertEqual(set(), found_subscriptions.subscribers)
1020- self.assertEqual((), found_subscriptions.subscribers.sorted)
1021+ self.assertContentEqual([], found_subscriptions)
1022+ self.assertContentEqual([], found_subscriptions.subscribers)
1023
1024 def test_duplicate_only(self):
1025 # The set of duplicate subscriptions where the subscriber has no other
1026@@ -224,8 +284,8 @@
1027 duplicate_bug.getSubscriptionForPerson(
1028 duplicate_bug.owner))
1029 found_subscriptions = self.getInfo().duplicate_only_subscriptions
1030- self.assertEqual(
1031- set([duplicate_bug_subscription]),
1032+ self.assertContentEqual(
1033+ [duplicate_bug_subscription],
1034 found_subscriptions)
1035 # If a user is subscribed to a duplicate bug and is a bugtask
1036 # assignee, for example, their duplicate subscription will not be
1037@@ -234,71 +294,136 @@
1038 self.bug.default_bugtask.transitionToAssignee(
1039 duplicate_bug_subscription.person)
1040 found_subscriptions = self.getInfo().duplicate_only_subscriptions
1041- self.assertEqual(set(), found_subscriptions)
1042-
1043- def test_structural(self):
1044+ self.assertContentEqual([], found_subscriptions)
1045+
1046+ def test_structural_subscriptions(self):
1047+ # The set of structural subscriptions.
1048+ subscribers = (
1049+ self.factory.makePerson(),
1050+ self.factory.makePerson())
1051+ with person_logged_in(self.bug.owner):
1052+ subscriptions = tuple(
1053+ self.target.addBugSubscription(subscriber, subscriber)
1054+ for subscriber in subscribers)
1055+ found_subscriptions = self.getInfo().structural_subscriptions
1056+ self.assertContentEqual(subscriptions, found_subscriptions)
1057+
1058+ def test_structural_subscriptions_muted(self):
1059+ # The set of structural subscriptions DOES NOT exclude muted
1060+ # subscriptions.
1061+ subscriber = self.factory.makePerson()
1062+ with person_logged_in(subscriber):
1063+ self.bug.mute(subscriber, subscriber)
1064+ with person_logged_in(self.bug.owner):
1065+ subscription = self.target.addBugSubscription(
1066+ subscriber, subscriber)
1067+ found_subscriptions = self.getInfo().structural_subscriptions
1068+ self.assertContentEqual([subscription], found_subscriptions)
1069+
1070+ def test_structural_subscribers(self):
1071 # The set of structural subscribers.
1072 subscribers = (
1073 self.factory.makePerson(),
1074 self.factory.makePerson())
1075 with person_logged_in(self.bug.owner):
1076- subscriptions = tuple(
1077+ for subscriber in subscribers:
1078 self.target.addBugSubscription(subscriber, subscriber)
1079- for subscriber in subscribers)
1080- found_subscriptions = self.getInfo().structural_subscriptions
1081- self.assertEqual(set(subscriptions), found_subscriptions)
1082- self.assertEqual(subscriptions, found_subscriptions.sorted)
1083- self.assertEqual(set(subscribers), found_subscriptions.subscribers)
1084- self.assertEqual(subscribers, found_subscriptions.subscribers.sorted)
1085+ found_subscribers = self.getInfo().structural_subscribers
1086+ self.assertContentEqual(subscribers, found_subscribers)
1087+
1088+ def test_structural_subscribers_muted(self):
1089+ # The set of structural subscribers DOES NOT exclude muted
1090+ # subscribers.
1091+ subscriber = self.factory.makePerson()
1092+ with person_logged_in(subscriber):
1093+ self.bug.mute(subscriber, subscriber)
1094+ with person_logged_in(self.bug.owner):
1095+ self.target.addBugSubscription(subscriber, subscriber)
1096+ found_subscribers = self.getInfo().structural_subscribers
1097+ self.assertContentEqual([subscriber], found_subscribers)
1098
1099 def test_all_assignees(self):
1100 # The set of bugtask assignees for bugtasks that have been assigned.
1101 found_assignees = self.getInfo().all_assignees
1102- self.assertEqual(set(), found_assignees)
1103- self.assertEqual((), found_assignees.sorted)
1104+ self.assertContentEqual([], found_assignees)
1105 bugtask = self.bug.default_bugtask
1106 with person_logged_in(bugtask.pillar.bug_supervisor):
1107 bugtask.transitionToAssignee(self.bug.owner)
1108 found_assignees = self.getInfo().all_assignees
1109- self.assertEqual(set([self.bug.owner]), found_assignees)
1110- self.assertEqual((self.bug.owner,), found_assignees.sorted)
1111+ self.assertContentEqual([self.bug.owner], found_assignees)
1112 bugtask2 = self.factory.makeBugTask(bug=self.bug)
1113 with person_logged_in(bugtask2.pillar.owner):
1114 bugtask2.transitionToAssignee(bugtask2.owner)
1115 found_assignees = self.getInfo().all_assignees
1116- self.assertEqual(
1117- set([self.bug.owner, bugtask2.owner]),
1118+ self.assertContentEqual(
1119+ [self.bug.owner, bugtask2.owner],
1120 found_assignees)
1121- self.assertEqual(
1122- (self.bug.owner, bugtask2.owner),
1123- found_assignees.sorted)
1124+ # Getting info for a specific bugtask will return the assignee for
1125+ # that bugtask only.
1126+ self.assertContentEqual(
1127+ [bugtask2.owner],
1128+ self.getInfo().forTask(bugtask2).all_assignees)
1129
1130 def test_all_pillar_owners_without_bug_supervisors(self):
1131 # The set of owners of pillars for which no bug supervisor is
1132- # configured.
1133+ # configured and which use Launchpad for bug tracking.
1134 [bugtask] = self.bug.bugtasks
1135 found_owners = (
1136 self.getInfo().all_pillar_owners_without_bug_supervisors)
1137- self.assertEqual(set(), found_owners)
1138- self.assertEqual((), found_owners.sorted)
1139- # Clear the supervisor for the first bugtask's target.
1140+ self.assertContentEqual([], found_owners)
1141+ # Clear the supervisor for the bugtask's target and ensure that the
1142+ # project uses Launchpad Bugs.
1143 with person_logged_in(bugtask.target.owner):
1144 bugtask.target.setBugSupervisor(None, bugtask.owner)
1145+ bugtask.pillar.official_malone = True
1146+ # The collection includes the pillar's owner.
1147 found_owners = (
1148 self.getInfo().all_pillar_owners_without_bug_supervisors)
1149- self.assertEqual(set([bugtask.pillar.owner]), found_owners)
1150- self.assertEqual((bugtask.pillar.owner,), found_owners.sorted)
1151- # Add another bugtask with a bug supervisor.
1152- target2 = self.factory.makeProduct(bug_supervisor=None)
1153+ self.assertContentEqual([bugtask.pillar.owner], found_owners)
1154+ # Add another bugtask for a pillar that uses Launchpad but does not
1155+ # have a bug supervisor.
1156+ target2 = self.factory.makeProduct(
1157+ bug_supervisor=None, official_malone=True)
1158 bugtask2 = self.factory.makeBugTask(bug=self.bug, target=target2)
1159 found_owners = (
1160 self.getInfo().all_pillar_owners_without_bug_supervisors)
1161- self.assertEqual(
1162- set([bugtask.pillar.owner, bugtask2.pillar.owner]),
1163+ self.assertContentEqual(
1164+ [bugtask.pillar.owner, bugtask2.pillar.owner],
1165 found_owners)
1166- self.assertEqual(
1167- (bugtask.pillar.owner, bugtask2.pillar.owner),
1168- found_owners.sorted)
1169+
1170+ def test_all_pillar_owners_without_bug_supervisors_not_using_malone(self):
1171+ # The set of owners of pillars for which no bug supervisor is
1172+ # configured and which do not use Launchpad for bug tracking is empty.
1173+ [bugtask] = self.bug.bugtasks
1174+ # Clear the supervisor for the first bugtask's target and ensure the
1175+ # project does not use Launchpad Bugs.
1176+ with person_logged_in(bugtask.target.owner):
1177+ bugtask.target.setBugSupervisor(None, bugtask.owner)
1178+ bugtask.pillar.official_malone = False
1179+ found_owners = (
1180+ self.getInfo().all_pillar_owners_without_bug_supervisors)
1181+ self.assertContentEqual([], found_owners)
1182+
1183+ def test_all_pillar_owners_without_bug_supervisors_for_bugtask(self):
1184+ # The set of the owner of the chosen bugtask's pillar when no bug
1185+ # supervisor is configured and which uses Launchpad for bug tracking.
1186+ [bugtask] = self.bug.bugtasks
1187+ # Clear the supervisor for the bugtask's target and ensure that the
1188+ # project uses Launchpad Bugs.
1189+ with person_logged_in(bugtask.target.owner):
1190+ bugtask.target.setBugSupervisor(None, bugtask.owner)
1191+ bugtask.pillar.official_malone = True
1192+ # Add another bugtask for a pillar that uses Launchpad but does not
1193+ # have a bug supervisor.
1194+ target2 = self.factory.makeProduct(
1195+ bug_supervisor=None, official_malone=True)
1196+ bugtask2 = self.factory.makeBugTask(bug=self.bug, target=target2)
1197+ # Getting subscription info for just a specific bugtask will yield
1198+ # owners for only the pillar associated with that bugtask.
1199+ info_for_bugtask2 = self.getInfo().forTask(bugtask2)
1200+ self.assertContentEqual(
1201+ [bugtask2.pillar.owner],
1202+ info_for_bugtask2.all_pillar_owners_without_bug_supervisors)
1203
1204 def _create_also_notified_subscribers(self):
1205 # Add an assignee, a bug supervisor and a structural subscriber.
1206@@ -318,8 +443,7 @@
1207 def test_also_notified_subscribers(self):
1208 # The set of also notified subscribers.
1209 found_subscribers = self.getInfo().also_notified_subscribers
1210- self.assertEqual(set(), found_subscribers)
1211- self.assertEqual((), found_subscribers.sorted)
1212+ self.assertContentEqual([], found_subscribers)
1213 assignee, supervisor, structural_subscriber = (
1214 self._create_also_notified_subscribers())
1215 # Add a direct subscription.
1216@@ -329,14 +453,11 @@
1217 # The direct subscriber does not appear in the also notified set, but
1218 # the assignee, supervisor and structural subscriber do.
1219 found_subscribers = self.getInfo().also_notified_subscribers
1220- self.assertEqual(
1221- set([assignee, supervisor, structural_subscriber]),
1222+ self.assertContentEqual(
1223+ [assignee, supervisor, structural_subscriber],
1224 found_subscribers)
1225- self.assertEqual(
1226- (assignee, supervisor, structural_subscriber),
1227- found_subscribers.sorted)
1228
1229- def test_muted_also_notified_subscribers(self):
1230+ def test_also_notified_subscribers_muted(self):
1231 # If someone is muted, they are not listed in the
1232 # also_notified_subscribers.
1233 assignee, supervisor, structural_subscriber = (
1234@@ -345,8 +466,8 @@
1235 # the assignee, supervisor and structural subscriber do show up
1236 # when they are not muted.
1237 found_subscribers = self.getInfo().also_notified_subscribers
1238- self.assertEqual(
1239- set([assignee, supervisor, structural_subscriber]),
1240+ self.assertContentEqual(
1241+ [assignee, supervisor, structural_subscriber],
1242 found_subscribers)
1243 # Now we mute all of the subscribers.
1244 with person_logged_in(assignee):
1245@@ -357,8 +478,7 @@
1246 self.bug.mute(structural_subscriber, structural_subscriber)
1247 # Now we don't see them.
1248 found_subscribers = self.getInfo().also_notified_subscribers
1249- self.assertEqual(
1250- set(), found_subscribers)
1251+ self.assertContentEqual([], found_subscribers)
1252
1253 def test_also_notified_subscribers_for_private_bug(self):
1254 # The set of also notified subscribers is always empty when the master
1255@@ -370,8 +490,7 @@
1256 with person_logged_in(self.bug.owner):
1257 self.bug.setPrivate(True, self.bug.owner)
1258 found_subscribers = self.getInfo().also_notified_subscribers
1259- self.assertEqual(set(), found_subscribers)
1260- self.assertEqual((), found_subscribers.sorted)
1261+ self.assertContentEqual([], found_subscribers)
1262
1263 def test_indirect_subscribers(self):
1264 # The set of indirect subscribers is the union of also notified
1265@@ -384,12 +503,9 @@
1266 with person_logged_in(duplicate_bug.owner):
1267 duplicate_bug.markAsDuplicate(self.bug)
1268 found_subscribers = self.getInfo().indirect_subscribers
1269- self.assertEqual(
1270- set([assignee, duplicate_bug.owner]),
1271+ self.assertContentEqual(
1272+ [assignee, duplicate_bug.owner],
1273 found_subscribers)
1274- self.assertEqual(
1275- (assignee, duplicate_bug.owner),
1276- found_subscribers.sorted)
1277
1278
1279 class TestBugSubscriptionInfoPermissions(TestCaseWithFactory):
1280@@ -406,7 +522,7 @@
1281
1282 # All attributes require launchpad.View.
1283 permissions = set(checker.get_permissions.itervalues())
1284- self.assertEqual(set(["launchpad.View"]), permissions)
1285+ self.assertContentEqual(["launchpad.View"], permissions)
1286
1287 # The security adapter for launchpad.View lets anyone reference the
1288 # attributes unless the bug is private, in which case only explicit
1289@@ -444,27 +560,46 @@
1290 yield recorder
1291 self.assertThat(recorder, condition)
1292
1293- def exercise_subscription_set(self, set_name):
1294+ def exercise_subscription_set(self, set_name, counts=(1, 1, 0)):
1295+ """Test the number of queries it takes to inspect a subscription set.
1296+
1297+ :param set_name: The name of the set, e.g. "direct_subscriptions".
1298+ :param counts: A triple of the expected query counts for each of three
1299+ operations: get the set, get the set's subscribers, get the set's
1300+ subscribers in order.
1301+ """
1302 # Looking up subscriptions takes a single query.
1303- with self.exactly_x_queries(1):
1304+ with self.exactly_x_queries(counts[0]):
1305 getattr(self.info, set_name)
1306 # Getting the subscribers results in one additional query.
1307- with self.exactly_x_queries(1):
1308+ with self.exactly_x_queries(counts[1]):
1309 getattr(self.info, set_name).subscribers
1310 # Everything is now cached so no more queries are needed.
1311- with self.exactly_x_queries(0):
1312+ with self.exactly_x_queries(counts[2]):
1313 getattr(self.info, set_name).subscribers
1314 getattr(self.info, set_name).subscribers.sorted
1315
1316- def exercise_subscription_set_sorted_first(self, set_name):
1317+ def exercise_subscription_set_sorted_first(
1318+ self, set_name, counts=(1, 1, 0)):
1319+ """Test the number of queries it takes to inspect a subscription set.
1320+
1321+ This differs from `exercise_subscription_set` in its second step, when
1322+ it looks at the sorted subscription list instead of the subscriber
1323+ set.
1324+
1325+ :param set_name: The name of the set, e.g. "direct_subscriptions".
1326+ :param counts: A triple of the expected query counts for each of three
1327+ operations: get the set, get the set in order, get the set's
1328+ subscribers in order.
1329+ """
1330 # Looking up subscriptions takes a single query.
1331- with self.exactly_x_queries(1):
1332+ with self.exactly_x_queries(counts[0]):
1333 getattr(self.info, set_name)
1334 # Getting the sorted subscriptions takes one additional query.
1335- with self.exactly_x_queries(1):
1336+ with self.exactly_x_queries(counts[1]):
1337 getattr(self.info, set_name).sorted
1338 # Everything is now cached so no more queries are needed.
1339- with self.exactly_x_queries(0):
1340+ with self.exactly_x_queries(counts[2]):
1341 getattr(self.info, set_name).subscribers
1342 getattr(self.info, set_name).subscribers.sorted
1343
1344@@ -476,6 +611,10 @@
1345 self.exercise_subscription_set_sorted_first(
1346 "direct_subscriptions")
1347
1348+ def test_direct_subscriptions_at_all_levels(self):
1349+ self.exercise_subscription_set(
1350+ "direct_subscriptions_at_all_levels")
1351+
1352 def make_duplicate_bug(self):
1353 duplicate_bug = self.factory.makeBug(product=self.target)
1354 with person_logged_in(duplicate_bug.owner):
1355@@ -501,18 +640,19 @@
1356 self.info.duplicate_subscriptions.subscribers
1357
1358 def add_structural_subscriber(self):
1359- with person_logged_in(self.bug.owner):
1360- self.target.addSubscription(self.bug.owner, self.bug.owner)
1361+ subscriber = self.factory.makePerson()
1362+ with person_logged_in(subscriber):
1363+ self.target.addSubscription(subscriber, subscriber)
1364
1365 def test_structural_subscriptions(self):
1366 self.add_structural_subscriber()
1367 self.exercise_subscription_set(
1368- "structural_subscriptions")
1369+ "structural_subscriptions", (2, 1, 0))
1370
1371 def test_structural_subscriptions_sorted_first(self):
1372 self.add_structural_subscriber()
1373 self.exercise_subscription_set_sorted_first(
1374- "structural_subscriptions")
1375+ "structural_subscriptions", (2, 1, 0))
1376
1377 def test_all_assignees(self):
1378 with self.exactly_x_queries(1):
1379@@ -522,8 +662,8 @@
1380 # Getting all bug supervisors and pillar owners can take several
1381 # queries. However, there are typically few tasks so the trade for
1382 # simplicity of implementation is acceptable. Only the simplest case
1383- # is tested here.
1384- with self.exactly_x_queries(1):
1385+ # is tested here (everything is already cached).
1386+ with self.exactly_x_queries(0):
1387 self.info.all_pillar_owners_without_bug_supervisors
1388
1389 def test_also_notified_subscribers(self):
1390@@ -536,7 +676,7 @@
1391 self.info.all_assignees
1392 self.info.all_pillar_owners_without_bug_supervisors
1393 self.info.direct_subscriptions.subscribers
1394- self.info.structural_subscriptions.subscribers
1395+ self.info.structural_subscribers
1396 with self.exactly_x_queries(1):
1397 self.info.also_notified_subscribers
1398
1399
1400=== modified file 'lib/lp/bugs/tests/test_structuralsubscription.py'
1401--- lib/lp/bugs/tests/test_structuralsubscription.py 2012-01-01 02:58:52 +0000
1402+++ lib/lp/bugs/tests/test_structuralsubscription.py 2012-01-04 18:01:30 +0000
1403@@ -27,8 +27,10 @@
1404 from lp.bugs.model.structuralsubscription import (
1405 get_structural_subscribers,
1406 get_structural_subscription_targets,
1407+ get_structural_subscriptions,
1408 get_structural_subscriptions_for_bug,
1409 )
1410+from lp.services.database.decoratedresultset import DecoratedResultSet
1411 from lp.testing import (
1412 anonymous_logged_in,
1413 login_person,
1414@@ -42,6 +44,9 @@
1415 )
1416
1417
1418+RESULT_SETS = ResultSet, EmptyResultSet, DecoratedResultSet
1419+
1420+
1421 class TestStructuralSubscription(TestCaseWithFactory):
1422
1423 layer = DatabaseFunctionalLayer
1424@@ -634,6 +639,99 @@
1425 self.assertEqual(set([self_sub]), set(subscriptions))
1426
1427
1428+class TestGetStructuralSubscriptions(TestCaseWithFactory):
1429+
1430+ layer = DatabaseFunctionalLayer
1431+
1432+ def make_product_with_bug(self):
1433+ product = self.factory.makeProduct()
1434+ bug = self.factory.makeBug(product=product)
1435+ return product, bug
1436+
1437+ def test_get_structural_subscriptions_no_subscriptions(self):
1438+ # If there are no subscriptions for any of the bug's targets then no
1439+ # subscriptions will be returned by get_structural_subscriptions().
1440+ product, bug = self.make_product_with_bug()
1441+ subscriptions = get_structural_subscriptions(bug, None)
1442+ self.assertIsInstance(subscriptions, RESULT_SETS)
1443+ self.assertEqual([], list(subscriptions))
1444+
1445+ def test_get_structural_subscriptions_single_target(self):
1446+ # Subscriptions for any of the bug's targets are returned.
1447+ subscriber = self.factory.makePerson()
1448+ login_person(subscriber)
1449+ product, bug = self.make_product_with_bug()
1450+ subscription = product.addBugSubscription(subscriber, subscriber)
1451+ self.assertContentEqual(
1452+ [subscription], get_structural_subscriptions(bug, None))
1453+
1454+ def test_get_structural_subscriptions_multiple_targets(self):
1455+ # Subscriptions for any of the bug's targets are returned.
1456+ actor = self.factory.makePerson()
1457+ login_person(actor)
1458+
1459+ subscriber1 = self.factory.makePerson()
1460+ subscriber2 = self.factory.makePerson()
1461+
1462+ product1 = self.factory.makeProduct(owner=actor)
1463+ subscription1 = product1.addBugSubscription(subscriber1, subscriber1)
1464+ product2 = self.factory.makeProduct(owner=actor)
1465+ subscription2 = product2.addBugSubscription(subscriber2, subscriber2)
1466+
1467+ bug = self.factory.makeBug(product=product1)
1468+ bug.addTask(actor, product2)
1469+
1470+ subscriptions = get_structural_subscriptions(bug, None)
1471+ self.assertIsInstance(subscriptions, RESULT_SETS)
1472+ self.assertContentEqual(
1473+ [subscription1, subscription2], subscriptions)
1474+
1475+ def test_get_structural_subscriptions_multiple_targets_2(self):
1476+ # Only the first of multiple subscriptions for a person is returned
1477+ # when they have multiple matching subscriptions.
1478+ actor = self.factory.makePerson()
1479+ login_person(actor)
1480+
1481+ subscriber = self.factory.makePerson()
1482+ product1 = self.factory.makeProduct(owner=actor)
1483+ subscription1 = product1.addBugSubscription(subscriber, subscriber)
1484+ product2 = self.factory.makeProduct(owner=actor)
1485+ product2.addBugSubscription(subscriber, subscriber)
1486+
1487+ bug = self.factory.makeBug(product=product1)
1488+ bug.addTask(actor, product2)
1489+
1490+ subscriptions = get_structural_subscriptions(bug, None)
1491+ self.assertIsInstance(subscriptions, RESULT_SETS)
1492+ self.assertContentEqual([subscription1], subscriptions)
1493+
1494+ def test_get_structural_subscriptions_level(self):
1495+ # get_structural_subscriptions() respects the given level.
1496+ subscriber = self.factory.makePerson()
1497+ login_person(subscriber)
1498+ product, bug = self.make_product_with_bug()
1499+ subscription = product.addBugSubscription(subscriber, subscriber)
1500+ filter = subscription.bug_filters.one()
1501+ filter.bug_notification_level = BugNotificationLevel.METADATA
1502+ self.assertContentEqual(
1503+ [subscription], get_structural_subscriptions(
1504+ bug, BugNotificationLevel.METADATA))
1505+ self.assertContentEqual(
1506+ [], get_structural_subscriptions(
1507+ bug, BugNotificationLevel.COMMENTS))
1508+
1509+ def test_get_structural_subscriptions_exclude(self):
1510+ # Subscriptions for any of the given excluded subscribers are not
1511+ # returned.
1512+ subscriber = self.factory.makePerson()
1513+ login_person(subscriber)
1514+ product, bug = self.make_product_with_bug()
1515+ product.addBugSubscription(subscriber, subscriber)
1516+ self.assertContentEqual(
1517+ [], get_structural_subscriptions(
1518+ bug, None, exclude=[subscriber]))
1519+
1520+
1521 class TestGetStructuralSubscribers(TestCaseWithFactory):
1522
1523 layer = DatabaseFunctionalLayer
1524@@ -648,7 +746,7 @@
1525 # subscribers will be returned by get_structural_subscribers().
1526 product, bug = self.make_product_with_bug()
1527 subscribers = get_structural_subscribers(bug, None, None, None)
1528- self.assertIsInstance(subscribers, (ResultSet, EmptyResultSet))
1529+ self.assertIsInstance(subscribers, RESULT_SETS)
1530 self.assertEqual([], list(subscribers))
1531
1532 def test_getStructuralSubscribers_single_target(self):
1533@@ -678,7 +776,7 @@
1534 bug.addTask(actor, product2)
1535
1536 subscribers = get_structural_subscribers(bug, None, None, None)
1537- self.assertIsInstance(subscribers, ResultSet)
1538+ self.assertIsInstance(subscribers, RESULT_SETS)
1539 self.assertEqual(set([subscriber1, subscriber2]), set(subscribers))
1540
1541 def test_getStructuralSubscribers_recipients(self):