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

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 11972
Proposed branch: lp:~allenap/launchpad/subscribe-to-tag-bug-151129
Merge into: lp:launchpad
Diff against target: 588 lines (+259/-126)
2 files modified
lib/lp/registry/model/structuralsubscription.py (+44/-0)
lib/lp/registry/tests/test_structuralsubscriptiontarget.py (+215/-126)
To merge this branch: bzr merge lp:~allenap/launchpad/subscribe-to-tag-bug-151129
Reviewer Review Type Date Requested Status
Henning Eggers (community) code Approve
Review via email: mp+40324@code.launchpad.net

Commit message

[r=henninge][ui=none][bug=151129] Specific tag criteria in structural subscriptions are now taken into consideration. Previously they were ignored.

Description of the change

This adds the ability to subscribe to tags as part of a structural
subscription. It was already possible to subscribe to the presence or
absense of tags, just not specific tags (presence or absense thereof).

The implementation is perhaps interesting. Because set operations need
to be performed, it's not enough to simply left join to the subscribed
tags table (BugSubscriptionFilterTag). So, in this implementation I've
used PostgreSQL ARRAY types.

To post a comment you must log in.
Revision history for this message
Henning Eggers (henninge) wrote :
Download full text (9.7 KiB)

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

Hi Gavin,
thank you for extending the Launchpad feature set in such a useful way.

  review approve code

There is nothing wrong with the branch but I'd like you to consider some
suggestions of mine, especially with regard to splitting up the tests. I don't
mean to push my coding style on you so I hope you can see what I am trying to
achieve.

Thanks for letting me learn about ARRAY! ;-)

Cheers,
Henning

Am 08.11.2010 14:14, schrieb Gavin Panella:
> === modified file 'lib/lp/registry/model/structuralsubscription.py'
> --- lib/lp/registry/model/structuralsubscription.py 2010-10-25 13:24:39 +0000
> +++ lib/lp/registry/model/structuralsubscription.py 2010-11-08 13:14:20 +0000
> @@ -10,6 +10,7 @@
> And,
> LeftJoin,
> Or,
> + SQL,
> )
> from storm.store import Store
> from zope.component import (
> @@ -484,13 +485,55 @@
> BugSubscriptionFilter.id)),
> ]
>
> + # An ARRAY[] expression for the given bug's tags.
> + tags_array = "ARRAY[%s]::TEXT[]" % ",".join(
> + quote(tag) for tag in bugtask.bug.tags)
> +
> + # The tags a subscription requests for inclusion.
> + tags_include_expr = (
> + "SELECT tag FROM BugSubscriptionFilterTag "
> + "WHERE filter = BugSubscriptionFilter.id AND include")
> + tags_include_array = "ARRAY(%s)" % tags_include_expr
> + tags_include_is_empty = SQL(
> + "ARRAY[]::TEXT[] = ARRAY(%s)" % tags_include_expr)

I don't understand why you are not reusing tags_include_array here.

+ "ARRAY[]::TEXT[] = %s" % tags_include_array)

I think that reads a lot clearer, or am I missing something?

> +
> + # The tags a subscription requests for exclusion.
> + tags_exclude_expr = (
> + "SELECT tag FROM BugSubscriptionFilterTag "
> + "WHERE filter = BugSubscriptionFilter.id AND NOT include")
> + tags_exclude_array = "ARRAY(%s)" % tags_exclude_expr
> + tags_exclude_is_empty = SQL(
> + "ARRAY[]::TEXT[] = ARRAY(%s)" % tags_exclude_expr)

Same here.

> +
> + # Choose the correct expression depending on the find_all_tags flag.
> + def tags_find_all_combinator(find_all_expr, find_any_expr):

"The Combinator!" I like that ... ;)

> + return SQL(
> + "CASE WHEN BugSubscriptionFilter.find_all_tags "
> + "THEN (%s) ELSE (%s) END" % (find_all_expr, find_any_expr))
> +
> if len(bugtask.bug.tags) == 0:
> tag_conditions = [
> BugSubscriptionFilter.include_any_tags == False,
> + # The subscription's required tags must be an empty set.
> + SQL("%s = %s" % (tags_array, tags_include_array)),

I understand that because len(tags) == 0, tags_array is an empty array but had
to wrap my head around it first. Why not use an explicit empty array?

+ SQL("ARRAY[]::TEXT[] = %s" % tags_include_array),

... and when you look at that, you'll quickly notice that it matches the
definition of "tags_include_is_empty", so you could just use that here.

> + # The ...

Read more...

review: Approve (code)
Revision history for this message
Robert Collins (lifeless) wrote :

Why use ARRAY rather than an explicit modelling of tag?
 We model tags explicitly in bugtag....

-Rob

Revision history for this message
Gavin Panella (allenap) wrote :

Rob, I don't know! My thinking was already going down one path and I
didn't see the wood for the trees. I'll change it in a follow-on
branch (and I won't land this as it stands).

Revision history for this message
Gavin Panella (allenap) wrote :

Henning, thank you for the review. I'm going to switch away from
ARRAYs (unless I hit some big problem), but I'll keep the tests of
course. I'm working on your suggestions :)

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

> > + tags_include_is_empty = SQL(
> > + "ARRAY[]::TEXT[] = ARRAY(%s)" % tags_include_expr)
>
> I don't understand why you are not reusing tags_include_array here.

Neither do I. Fixed.

> > +
> > + # Choose the correct expression depending on the find_all_tags flag.
> > + def tags_find_all_combinator(find_all_expr, find_any_expr):
>
> "The Combinator!" I like that ... ;)

Hehe. I discovered that it's a term from lambda calculus, which I have
never studied. So I'm probably abusing the term and, worse, abusing it
with flagrant ignorance :)

> + SQL("ARRAY[]::TEXT[] = %s" % tags_include_array),
>
> ... and when you look at that, you'll quickly notice that it matches the
> definition of "tags_include_is_empty", so you could just use that here.

Erm, yes, I should. Fixed. Thanks.

> > + # The subscription's excluded tags can be anything.
>
> "..., so no condition is needed for them." This comment at the end got me
> confused at first, so I think it would benefit from the added explanation.

Good suggestion.

> This code from here ...
> > + # If a subscription filter specifies that any of one or more specific
> > + # tags must be present, bugs with any of those tags are matched.
> > + bugtask = self.makeBugTask()
> > +
> > + # Create a new subscription on self.target.
> > + login_person(self.ordinary_subscriber)
> > + subscription = self.target.addSubscription(
> > + self.ordinary_subscriber, self.ordinary_subscriber)
> > + subscription.bug_notification_level = BugNotificationLevel.COMMENTS
> > + subscription_filter = subscription.newBugFilter()
> > +
> > + # Looking for either the "foo" or the "bar" tag.
> > + subscription_filter.tags = [u"foo", u"bar"]
> > + subscription_filter.find_all_tags = False
>
> ... to here is repeated in the next two tests, with only the tags and
> find_all_tags changing. I think that should go into a
> "makeBugTaskAndSubscription" method or similar with parameters for the
> changing values.

I've changed the inheritance of various tests and added several new
TestStructuralSubscriptionFiltersFor$THING classes so that I could put
this common stuff in setUp().

> > + # Without either tag the subscription is not found.
> > + subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
> > + bugtask, BugNotificationLevel.NOTHING)
> > + self.assertEqual([], list(subscriptions_for_bugtask))
> > +
>
> Having a factory method makes it also easy to split the test up into smaller
> chunks for the different conditions under which getSubscriptionsForBugTask is
> being tested. I think you can fulfill the "one assert per test" paradigm here
> easily. ;-)

I've moved this into a new assertSubscriptions() method, and used it
everywhere this pattern appears. That has reduced the line count a
*lot*, more than I realised as I slowly grew this class. Good
suggestion, thanks.

However, I'm going to resist the "one assert per test" thing. I
personally think this is better because it reduces the setup and
teardown costs, makes it no more difficult to ...

Read more...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/registry/model/structuralsubscription.py'
2--- lib/lp/registry/model/structuralsubscription.py 2010-11-09 11:00:55 +0000
3+++ lib/lp/registry/model/structuralsubscription.py 2010-11-24 09:39:54 +0000
4@@ -10,6 +10,7 @@
5 And,
6 LeftJoin,
7 Or,
8+ SQL,
9 )
10 from storm.store import Store
11 from zope.component import (
12@@ -487,13 +488,56 @@
13 BugSubscriptionFilter.id)),
14 ]
15
16+ # An ARRAY[] expression for the given bug's tags.
17+ tags_array = "ARRAY[%s]::TEXT[]" % ",".join(
18+ quote(tag) for tag in bugtask.bug.tags)
19+
20+ # The tags a subscription requests for inclusion.
21+ tags_include_expr = (
22+ "SELECT tag FROM BugSubscriptionFilterTag "
23+ "WHERE filter = BugSubscriptionFilter.id AND include")
24+ tags_include_array = "ARRAY(%s)" % tags_include_expr
25+ tags_include_is_empty = SQL(
26+ "ARRAY[]::TEXT[] = %s" % tags_include_array)
27+
28+ # The tags a subscription requests for exclusion.
29+ tags_exclude_expr = (
30+ "SELECT tag FROM BugSubscriptionFilterTag "
31+ "WHERE filter = BugSubscriptionFilter.id AND NOT include")
32+ tags_exclude_array = "ARRAY(%s)" % tags_exclude_expr
33+ tags_exclude_is_empty = SQL(
34+ "ARRAY[]::TEXT[] = %s" % tags_exclude_array)
35+
36+ # Choose the correct expression depending on the find_all_tags flag.
37+ def tags_find_all_combinator(find_all_expr, find_any_expr):
38+ return SQL(
39+ "CASE WHEN BugSubscriptionFilter.find_all_tags "
40+ "THEN (%s) ELSE (%s) END" % (find_all_expr, find_any_expr))
41+
42 if len(bugtask.bug.tags) == 0:
43 tag_conditions = [
44 BugSubscriptionFilter.include_any_tags == False,
45+ # The subscription's required tags must be an empty set.
46+ tags_include_is_empty,
47+ # The subscription's excluded tags can be anything so no
48+ # condition is needed.
49 ]
50 else:
51 tag_conditions = [
52 BugSubscriptionFilter.exclude_any_tags == False,
53+ # The bug's tags must contain the subscription's required tags
54+ # if find_all_tags is set, else there must be an intersection.
55+ Or(tags_include_is_empty,
56+ tags_find_all_combinator(
57+ "%s @> %s" % (tags_array, tags_include_array),
58+ "%s && %s" % (tags_array, tags_include_array))),
59+ # The bug's tags must not contain the subscription's excluded
60+ # tags if find_all_tags is set, else there must not be an
61+ # intersection.
62+ Or(tags_exclude_is_empty,
63+ tags_find_all_combinator(
64+ "NOT (%s @> %s)" % (tags_array, tags_exclude_array),
65+ "NOT (%s && %s)" % (tags_array, tags_exclude_array))),
66 ]
67
68 conditions = [
69
70=== modified file 'lib/lp/registry/tests/test_structuralsubscriptiontarget.py'
71--- lib/lp/registry/tests/test_structuralsubscriptiontarget.py 2010-10-07 10:07:51 +0000
72+++ lib/lp/registry/tests/test_structuralsubscriptiontarget.py 2010-11-24 09:39:54 +0000
73@@ -32,7 +32,6 @@
74 BugTaskImportance,
75 BugTaskStatus,
76 )
77-from lp.bugs.model.bugsubscriptionfilter import BugSubscriptionFilter
78 from lp.bugs.tests.test_bugtarget import bugtarget_filebug
79 from lp.registry.enum import BugNotificationLevel
80 from lp.registry.errors import (
81@@ -66,6 +65,10 @@
82 self.team_owner = self.factory.makePerson()
83 self.team = self.factory.makeTeam(owner=self.team_owner)
84
85+
86+class RestrictedStructuralSubscription(StructuralSubscriptionTestBase):
87+ # Tests suitable for a target that restricts structural subscriptions.
88+
89 def test_target_implements_structural_subscription_target(self):
90 self.assertTrue(verifyObject(IStructuralSubscriptionTarget,
91 self.target))
92@@ -124,7 +127,9 @@
93 self.ordinary_subscriber, self.ordinary_subscriber)
94
95
96-class UnrestrictedStructuralSubscription(StructuralSubscriptionTestBase):
97+class UnrestrictedStructuralSubscription(RestrictedStructuralSubscription):
98+ # Tests suitable for a target that does not restrict structural
99+ # subscriptions.
100
101 def test_structural_subscription_by_ordinary_user(self):
102 # ordinary users can subscribe themselves
103@@ -170,195 +175,243 @@
104 class FilteredStructuralSubscriptionTestBase(StructuralSubscriptionTestBase):
105 """Tests for filtered structural subscriptions."""
106
107+ layer = LaunchpadFunctionalLayer
108+
109+ def makeTarget(self):
110+ raise NotImplementedError(self.makeTarget)
111+
112 def makeBugTask(self):
113 return self.factory.makeBugTask(target=self.target)
114
115+ def setUp(self):
116+ super(FilteredStructuralSubscriptionTestBase, self).setUp()
117+ login_person(self.ordinary_subscriber)
118+ self.target = self.makeTarget()
119+ self.bugtask = self.makeBugTask()
120+ self.bug = self.bugtask.bug
121+ self.subscription = self.target.addSubscription(
122+ self.ordinary_subscriber, self.ordinary_subscriber)
123+ self.subscription.bug_notification_level = (
124+ BugNotificationLevel.COMMENTS)
125+
126+ def assertSubscriptions(
127+ self, expected_subscriptions, level=BugNotificationLevel.NOTHING):
128+ observed_subscriptions = list(
129+ self.target.getSubscriptionsForBugTask(self.bugtask, level))
130+ self.assertEqual(expected_subscriptions, observed_subscriptions)
131+
132 def test_getSubscriptionsForBugTask(self):
133 # If no one has a filtered subscription for the given bug, the result
134 # of getSubscriptionsForBugTask() is the same as for
135 # getSubscriptions().
136- bugtask = self.makeBugTask()
137 subscriptions = self.target.getSubscriptions(
138 min_bug_notification_level=BugNotificationLevel.NOTHING)
139- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
140- bugtask, BugNotificationLevel.NOTHING)
141- self.assertEqual(list(subscriptions), list(subscriptions_for_bugtask))
142+ self.assertSubscriptions(list(subscriptions))
143
144 def test_getSubscriptionsForBugTask_with_filter_on_status(self):
145 # If a status filter exists for a subscription, the result of
146 # getSubscriptionsForBugTask() may be a subset of getSubscriptions().
147- bugtask = self.makeBugTask()
148-
149- # Create a new subscription on self.target.
150- login_person(self.ordinary_subscriber)
151- subscription = self.target.addSubscription(
152- self.ordinary_subscriber, self.ordinary_subscriber)
153- subscription.bug_notification_level = BugNotificationLevel.COMMENTS
154
155 # Without any filters the subscription is found.
156- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
157- bugtask, BugNotificationLevel.NOTHING)
158- self.assertEqual([subscription], list(subscriptions_for_bugtask))
159+ self.assertSubscriptions([self.subscription])
160
161 # Filter the subscription to bugs in the CONFIRMED state.
162- subscription_filter = BugSubscriptionFilter()
163- subscription_filter.structural_subscription = subscription
164+ subscription_filter = self.subscription.newBugFilter()
165 subscription_filter.statuses = [BugTaskStatus.CONFIRMED]
166
167 # With the filter the subscription is not found.
168- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
169- bugtask, BugNotificationLevel.NOTHING)
170- self.assertEqual([], list(subscriptions_for_bugtask))
171+ self.assertSubscriptions([])
172
173 # If the filter is adjusted, the subscription is found again.
174- subscription_filter.statuses = [bugtask.status]
175- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
176- bugtask, BugNotificationLevel.NOTHING)
177- self.assertEqual([subscription], list(subscriptions_for_bugtask))
178+ subscription_filter.statuses = [self.bugtask.status]
179+ self.assertSubscriptions([self.subscription])
180
181 def test_getSubscriptionsForBugTask_with_filter_on_importance(self):
182 # If an importance filter exists for a subscription, the result of
183 # getSubscriptionsForBugTask() may be a subset of getSubscriptions().
184- bugtask = self.makeBugTask()
185-
186- # Create a new subscription on self.target.
187- login_person(self.ordinary_subscriber)
188- subscription = self.target.addSubscription(
189- self.ordinary_subscriber, self.ordinary_subscriber)
190- subscription.bug_notification_level = BugNotificationLevel.COMMENTS
191
192 # Without any filters the subscription is found.
193- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
194- bugtask, BugNotificationLevel.NOTHING)
195- self.assertEqual([subscription], list(subscriptions_for_bugtask))
196+ self.assertSubscriptions([self.subscription])
197
198 # Filter the subscription to bugs in the CRITICAL state.
199- subscription_filter = BugSubscriptionFilter()
200- subscription_filter.structural_subscription = subscription
201+ subscription_filter = self.subscription.newBugFilter()
202 subscription_filter.importances = [BugTaskImportance.CRITICAL]
203
204 # With the filter the subscription is not found.
205- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
206- bugtask, BugNotificationLevel.NOTHING)
207- self.assertEqual([], list(subscriptions_for_bugtask))
208+ self.assertSubscriptions([])
209
210 # If the filter is adjusted, the subscription is found again.
211- subscription_filter.importances = [bugtask.importance]
212- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
213- bugtask, BugNotificationLevel.NOTHING)
214- self.assertEqual([subscription], list(subscriptions_for_bugtask))
215+ subscription_filter.importances = [self.bugtask.importance]
216+ self.assertSubscriptions([self.subscription])
217
218 def test_getSubscriptionsForBugTask_with_filter_on_level(self):
219 # All structural subscriptions have a level for bug notifications
220 # which getSubscriptionsForBugTask() observes.
221- bugtask = self.makeBugTask()
222
223- # Create a new METADATA level subscription on self.target.
224- login_person(self.ordinary_subscriber)
225- subscription = self.target.addSubscription(
226- self.ordinary_subscriber, self.ordinary_subscriber)
227- subscription.bug_notification_level = BugNotificationLevel.METADATA
228+ # Adjust the subscription level to METADATA.
229+ self.subscription.bug_notification_level = (
230+ BugNotificationLevel.METADATA)
231
232 # The subscription is found when looking for NOTHING or above.
233- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
234- bugtask, BugNotificationLevel.NOTHING)
235- self.assertEqual([subscription], list(subscriptions_for_bugtask))
236+ self.assertSubscriptions(
237+ [self.subscription], BugNotificationLevel.NOTHING)
238 # The subscription is found when looking for METADATA or above.
239- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
240- bugtask, BugNotificationLevel.METADATA)
241- self.assertEqual([subscription], list(subscriptions_for_bugtask))
242+ self.assertSubscriptions(
243+ [self.subscription], BugNotificationLevel.METADATA)
244 # The subscription is not found when looking for COMMENTS or above.
245- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
246- bugtask, BugNotificationLevel.COMMENTS)
247- self.assertEqual([], list(subscriptions_for_bugtask))
248+ self.assertSubscriptions(
249+ [], BugNotificationLevel.COMMENTS)
250
251 def test_getSubscriptionsForBugTask_with_filter_include_any_tags(self):
252 # If a subscription filter has include_any_tags, a bug with one or
253 # more tags is matched.
254- bugtask = self.makeBugTask()
255
256- # Create a new subscription on self.target.
257- login_person(self.ordinary_subscriber)
258- subscription = self.target.addSubscription(
259- self.ordinary_subscriber, self.ordinary_subscriber)
260- subscription.bug_notification_level = BugNotificationLevel.COMMENTS
261- subscription_filter = subscription.newBugFilter()
262+ subscription_filter = self.subscription.newBugFilter()
263 subscription_filter.include_any_tags = True
264
265 # Without any tags the subscription is not found.
266- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
267- bugtask, BugNotificationLevel.NOTHING)
268- self.assertEqual([], list(subscriptions_for_bugtask))
269+ self.assertSubscriptions([])
270
271 # With any tag the subscription is found.
272- bugtask.bug.tags = ["foo"]
273- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
274- bugtask, BugNotificationLevel.NOTHING)
275- self.assertEqual([subscription], list(subscriptions_for_bugtask))
276+ self.bug.tags = ["foo"]
277+ self.assertSubscriptions([self.subscription])
278
279 def test_getSubscriptionsForBugTask_with_filter_exclude_any_tags(self):
280 # If a subscription filter has exclude_any_tags, only bugs with no
281 # tags are matched.
282- bugtask = self.makeBugTask()
283
284- # Create a new subscription on self.target.
285- login_person(self.ordinary_subscriber)
286- subscription = self.target.addSubscription(
287- self.ordinary_subscriber, self.ordinary_subscriber)
288- subscription.bug_notification_level = BugNotificationLevel.COMMENTS
289- subscription_filter = subscription.newBugFilter()
290+ subscription_filter = self.subscription.newBugFilter()
291 subscription_filter.exclude_any_tags = True
292
293 # Without any tags the subscription is found.
294- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
295- bugtask, BugNotificationLevel.NOTHING)
296- self.assertEqual([subscription], list(subscriptions_for_bugtask))
297+ self.assertSubscriptions([self.subscription])
298
299 # With any tag the subscription is not found.
300- bugtask.bug.tags = ["foo"]
301- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
302- bugtask, BugNotificationLevel.NOTHING)
303- self.assertEqual([], list(subscriptions_for_bugtask))
304+ self.bug.tags = ["foo"]
305+ self.assertSubscriptions([])
306+
307+ def test_getSubscriptionsForBugTask_with_filter_for_any_tag(self):
308+ # If a subscription filter specifies that any of one or more specific
309+ # tags must be present, bugs with any of those tags are matched.
310+
311+ # Looking for either the "foo" or the "bar" tag.
312+ subscription_filter = self.subscription.newBugFilter()
313+ subscription_filter.tags = [u"foo", u"bar"]
314+ subscription_filter.find_all_tags = False
315+
316+ # Without either tag the subscription is not found.
317+ self.assertSubscriptions([])
318+
319+ # With either tag the subscription is found.
320+ self.bug.tags = ["bar", "baz"]
321+ self.assertSubscriptions([self.subscription])
322+
323+ def test_getSubscriptionsForBugTask_with_filter_for_all_tags(self):
324+ # If a subscription filter specifies that all of one or more specific
325+ # tags must be present, bugs with all of those tags are matched.
326+
327+ # Looking for both the "foo" and the "bar" tag.
328+ subscription_filter = self.subscription.newBugFilter()
329+ subscription_filter.tags = [u"foo", u"bar"]
330+ subscription_filter.find_all_tags = True
331+
332+ # Without either tag the subscription is not found.
333+ self.assertSubscriptions([])
334+
335+ # Without only one of the required tags the subscription is not found.
336+ self.bug.tags = ["foo"]
337+ self.assertSubscriptions([])
338+
339+ # With both required tags the subscription is found.
340+ self.bug.tags = ["foo", "bar"]
341+ self.assertSubscriptions([self.subscription])
342+
343+ def test_getSubscriptionsForBugTask_with_filter_for_not_any_tag(self):
344+ # If a subscription filter specifies that any of one or more specific
345+ # tags must not be present, bugs without any of those tags are
346+ # matched.
347+
348+ # Looking to exclude the "foo" or "bar" tags.
349+ subscription_filter = self.subscription.newBugFilter()
350+ subscription_filter.tags = [u"-foo", u"-bar"]
351+ subscription_filter.find_all_tags = False
352+
353+ # Without either tag the subscription is found.
354+ self.assertSubscriptions([self.subscription])
355+
356+ # With either tag the subscription is no longer found.
357+ self.bug.tags = ["foo"]
358+ self.assertSubscriptions([])
359+
360+ def test_getSubscriptionsForBugTask_with_filter_for_not_all_tags(self):
361+ # If a subscription filter specifies that all of one or more specific
362+ # tags must not be present, bugs without all of those tags are
363+ # matched.
364+
365+ # Looking to exclude the "foo" and "bar" tags.
366+ subscription_filter = self.subscription.newBugFilter()
367+ subscription_filter.tags = [u"-foo", u"-bar"]
368+ subscription_filter.find_all_tags = True
369+
370+ # Without either tag the subscription is found.
371+ self.assertSubscriptions([self.subscription])
372+
373+ # With only one of the excluded tags the subscription is found.
374+ self.bug.tags = ["foo"]
375+ self.assertSubscriptions([self.subscription])
376+
377+ # With both tags the subscription is no longer found.
378+ self.bug.tags = ["foo", "bar"]
379+ self.assertSubscriptions([])
380
381 def test_getSubscriptionsForBugTask_with_multiple_filters(self):
382 # If multiple filters exist for a subscription, all filters must
383 # match.
384- bugtask = self.makeBugTask()
385
386- # Create a new subscription on self.target.
387- login_person(self.ordinary_subscriber)
388- subscription = self.target.addSubscription(
389- self.ordinary_subscriber, self.ordinary_subscriber)
390- subscription.bug_notification_level = BugNotificationLevel.COMMENTS
391+ # Add the "foo" tag to the bug.
392+ self.bug.tags = ["foo"]
393
394 # Filter the subscription to bugs in the CRITICAL state.
395- subscription_filter = BugSubscriptionFilter()
396- subscription_filter.structural_subscription = subscription
397+ subscription_filter = self.subscription.newBugFilter()
398 subscription_filter.statuses = [BugTaskStatus.CONFIRMED]
399 subscription_filter.importances = [BugTaskImportance.CRITICAL]
400
401 # With the filter the subscription is not found.
402- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
403- bugtask, BugNotificationLevel.NOTHING)
404- self.assertEqual([], list(subscriptions_for_bugtask))
405+ self.assertSubscriptions([])
406
407 # If the filter is adjusted to match status but not importance, the
408 # subscription is still not found.
409- subscription_filter.statuses = [bugtask.status]
410- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
411- bugtask, BugNotificationLevel.NOTHING)
412- self.assertEqual([], list(subscriptions_for_bugtask))
413+ subscription_filter.statuses = [self.bugtask.status]
414+ self.assertSubscriptions([])
415
416 # If the filter is adjusted to also match importance, the subscription
417 # is found again.
418- subscription_filter.importances = [bugtask.importance]
419- subscriptions_for_bugtask = self.target.getSubscriptionsForBugTask(
420- bugtask, BugNotificationLevel.NOTHING)
421- self.assertEqual([subscription], list(subscriptions_for_bugtask))
422+ subscription_filter.importances = [self.bugtask.importance]
423+ self.assertSubscriptions([self.subscription])
424+
425+ # If the filter is given some tag criteria, the subscription is not
426+ # found.
427+ subscription_filter.tags = [u"-foo", u"bar", u"baz"]
428+ subscription_filter.find_all_tags = False
429+ self.assertSubscriptions([])
430+
431+ # After removing the "foo" tag and adding the "bar" tag, the
432+ # subscription is found.
433+ self.bug.tags = ["bar"]
434+ self.assertSubscriptions([self.subscription])
435+
436+ # Requiring that all tag criteria are fulfilled causes the
437+ # subscription to no longer be found.
438+ subscription_filter.find_all_tags = True
439+ self.assertSubscriptions([])
440+
441+ # After adding the "baz" tag, the subscription is found again.
442+ self.bug.tags = ["bar", "baz"]
443+ self.assertSubscriptions([self.subscription])
444
445
446 class TestStructuralSubscriptionForDistro(
447- FilteredStructuralSubscriptionTestBase, TestCaseWithFactory):
448+ RestrictedStructuralSubscription, TestCaseWithFactory):
449
450 layer = LaunchpadFunctionalLayer
451
452@@ -421,10 +474,15 @@
453 StructuralSubscription)
454
455
456+class TestStructuralSubscriptionFiltersForDistro(
457+ FilteredStructuralSubscriptionTestBase, TestCaseWithFactory):
458+
459+ def makeTarget(self):
460+ return self.factory.makeDistribution()
461+
462+
463 class TestStructuralSubscriptionForProduct(
464- UnrestrictedStructuralSubscription,
465- FilteredStructuralSubscriptionTestBase,
466- TestCaseWithFactory):
467+ UnrestrictedStructuralSubscription, TestCaseWithFactory):
468
469 layer = LaunchpadFunctionalLayer
470
471@@ -433,10 +491,15 @@
472 self.target = self.factory.makeProduct()
473
474
475+class TestStructuralSubscriptionFiltersForProduct(
476+ FilteredStructuralSubscriptionTestBase, TestCaseWithFactory):
477+
478+ def makeTarget(self):
479+ return self.factory.makeProduct()
480+
481+
482 class TestStructuralSubscriptionForDistroSourcePackage(
483- UnrestrictedStructuralSubscription,
484- FilteredStructuralSubscriptionTestBase,
485- TestCaseWithFactory):
486+ UnrestrictedStructuralSubscription, TestCaseWithFactory):
487
488 layer = LaunchpadFunctionalLayer
489
490@@ -446,10 +509,15 @@
491 self.target = ProxyFactory(self.target)
492
493
494+class TestStructuralSubscriptionFiltersForDistroSourcePackage(
495+ FilteredStructuralSubscriptionTestBase, TestCaseWithFactory):
496+
497+ def makeTarget(self):
498+ return self.factory.makeDistributionSourcePackage()
499+
500+
501 class TestStructuralSubscriptionForMilestone(
502- UnrestrictedStructuralSubscription,
503- FilteredStructuralSubscriptionTestBase,
504- TestCaseWithFactory):
505+ UnrestrictedStructuralSubscription, TestCaseWithFactory):
506
507 layer = LaunchpadFunctionalLayer
508
509@@ -458,15 +526,19 @@
510 self.target = self.factory.makeMilestone()
511 self.target = ProxyFactory(self.target)
512
513+
514+class TestStructuralSubscriptionFiltersForMilestone(
515+ FilteredStructuralSubscriptionTestBase, TestCaseWithFactory):
516+
517+ def makeTarget(self):
518+ return self.factory.makeMilestone()
519+
520 def makeBugTask(self):
521- # XXX Should test with target *and* series_target.
522 return self.factory.makeBugTask(target=self.target.series_target)
523
524
525 class TestStructuralSubscriptionForDistroSeries(
526- UnrestrictedStructuralSubscription,
527- FilteredStructuralSubscriptionTestBase,
528- TestCaseWithFactory):
529+ UnrestrictedStructuralSubscription, TestCaseWithFactory):
530
531 layer = LaunchpadFunctionalLayer
532
533@@ -476,10 +548,15 @@
534 self.target = ProxyFactory(self.target)
535
536
537+class TestStructuralSubscriptionFiltersForDistroSeries(
538+ FilteredStructuralSubscriptionTestBase, TestCaseWithFactory):
539+
540+ def makeTarget(self):
541+ return self.factory.makeDistroSeries()
542+
543+
544 class TestStructuralSubscriptionForProjectGroup(
545- UnrestrictedStructuralSubscription,
546- FilteredStructuralSubscriptionTestBase,
547- TestCaseWithFactory):
548+ UnrestrictedStructuralSubscription, TestCaseWithFactory):
549
550 layer = LaunchpadFunctionalLayer
551
552@@ -488,15 +565,20 @@
553 self.target = self.factory.makeProject()
554 self.target = ProxyFactory(self.target)
555
556+
557+class TestStructuralSubscriptionFiltersForProjectGroup(
558+ FilteredStructuralSubscriptionTestBase, TestCaseWithFactory):
559+
560+ def makeTarget(self):
561+ return self.factory.makeProject()
562+
563 def makeBugTask(self):
564 return self.factory.makeBugTask(
565 target=self.factory.makeProduct(project=self.target))
566
567
568 class TestStructuralSubscriptionForProductSeries(
569- UnrestrictedStructuralSubscription,
570- FilteredStructuralSubscriptionTestBase,
571- TestCaseWithFactory):
572+ UnrestrictedStructuralSubscription, TestCaseWithFactory):
573
574 layer = LaunchpadFunctionalLayer
575
576@@ -506,6 +588,13 @@
577 self.target = ProxyFactory(self.target)
578
579
580+class TestStructuralSubscriptionFiltersForProductSeries(
581+ FilteredStructuralSubscriptionTestBase, TestCaseWithFactory):
582+
583+ def makeTarget(self):
584+ return self.factory.makeProductSeries()
585+
586+
587 class TestStructuralSubscriptionTargetHelper(TestCaseWithFactory):
588 """Tests for implementations of `IStructuralSubscriptionTargetHelper`."""
589