Merge lp:~danilo/launchpad/proper-bug-muting into lp:launchpad/db-devel

Proposed by Данило Шеган
Status: Merged
Approved by: Данило Шеган
Approved revision: no longer in the source branch.
Merged at revision: 10561
Proposed branch: lp:~danilo/launchpad/proper-bug-muting
Merge into: lp:launchpad/db-devel
Diff against target: 886 lines (+291/-86)
18 files modified
database/schema/comments.sql (+6/-0)
database/schema/patch-2208-70-0.sql (+36/-0)
database/schema/security.cfg (+2/-0)
lib/lp/bugs/browser/bugsubscription.py (+20/-3)
lib/lp/bugs/browser/tests/test_bug_context_menu.py (+2/-4)
lib/lp/bugs/browser/tests/test_bugsubscription_views.py (+5/-5)
lib/lp/bugs/configure.zcml (+7/-0)
lib/lp/bugs/interfaces/bug.py (+17/-0)
lib/lp/bugs/interfaces/bugnotification.py (+4/-2)
lib/lp/bugs/model/bug.py (+62/-22)
lib/lp/bugs/model/bugnotification.py (+25/-12)
lib/lp/bugs/model/bugsubscriptionfilter.py (+2/-2)
lib/lp/bugs/scripts/bugnotification.py (+1/-1)
lib/lp/bugs/scripts/tests/test_bugnotification.py (+2/-1)
lib/lp/bugs/tests/test_bug.py (+23/-17)
lib/lp/bugs/tests/test_bugchanges.py (+22/-7)
lib/lp/bugs/tests/test_bugnotification.py (+51/-9)
lib/lp/registry/model/person.py (+4/-1)
To merge this branch: bzr merge lp:~danilo/launchpad/proper-bug-muting
Reviewer Review Type Date Requested Status
Graham Binns (community) code Approve
Stuart Bishop (community) db Approve
Robert Collins db Pending
Review via email: mp+60615@code.launchpad.net

Commit message

[r=gmb,stub][bug=772763] Use a separate table for muting bug subscriptions to allow restoring one's subscription after unmuting.

Description of the change

= Bug 772763: step 1 =

At the moment, muting a bug is implemented using BugSubscription by setting bug_notification_level on it to NOTHING. That has a bad side-effect that once someone decides to unmute their subscription, we can't restore the previous one. UI is also confusing ("unmuting" just removes the "mute", without actually re-subscribing you).

== Proposed fix ==

To parallel BugSubscriptionFilterMute, which allows one to mute a structural subscription filter, we implement a BugMute table which allows a single person to mute all email for a bug.

Since we only "expand" subscribers at a very late stage when emails are constructed (in construct_email_notifications), we can either filter muted recipients in there or in getRecipientFilterData which is used by this method with expanded recipients passed in. I did the change in getRecipientFilterData because it seems more sane to have all of the direct DB access in a single place. It's also more easily unit-tested.

I wonder if I should do the migration of NOTHING subscriptions to BugMute records in the DB patch or not. That depends on how many of them there are, but it'd probably be best to do it that way.

== Implementation details ==

 * We provide a very basic BugMute table that is modelled after the BugSubscriptionFilterMute table. Bug muting/unmuting is modified to use it, and test is updated to match the new behaviour. This is all in model/bug.py, interfaces/bug.py and test_bug.py
 * XXXes are there to remind me to talk to the team if we really want to log bug muting/unmuting in the bug activity log (assuming bug.addChange does that), as it used to with the use of subscribe/unsubscribe. I assume we'll just end up removing the XXXes.
 * We modify getRecipientFilterData to accept a bug as the parameter as well, and fetch all muted subscribers so we can easily filter them out.
 * Test in test_bugchanges.py is the integration test (interestingly, one that even failed for the existing implementation, because muting didn't work for all cases).
 * UI is not modified in any way in this branch, which means that it will be a bit confusing; that's left for follow-up branches.
 * Another branch will go on with removing the NOTHING level if possible.
 * Some typos in registry/model/person.py and bugsubscriptionfilter.py are fixed along the way
 * I have another 144 diff of lint fixes for mostly things not caused by my changes, not including it so the branch is smaller; similar holds for sampledata updates, I'll commit them after review.

== Tests ==

bin/test -cvvt TestBugSubscriptionMethods -t getRecipientFilterData -t test_description_changed_no_muted_email

== Demo and Q/A ==

With 'malone.advanced-subscriptions.enabled default 1 on' set as the feature flag, try playing with muting/unmuting (and seeing how your bug subscription gets restored to appropriate "level") on any BugTask:+index page (i.e. a bug page).

Note that until UI is fully adapted as well, you might need to refresh the page between actions.

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/bugs/model/bugnotification.py
  database/schema/patch-2208-97-0.sql
  lib/lp/bugs/configure.zcml
  database/schema/comments.sql
  lib/lp/bugs/tests/test_bugnotification.py
  database/schema/security.cfg
  lib/lp/bugs/model/bugsubscriptionfilter.py
  lib/lp/bugs/scripts/tests/test_bugnotification.py
  lib/lp/bugs/model/bug.py
  lib/lp/bugs/tests/test_bugchanges.py
  lib/lp/bugs/scripts/bugnotification.py
  lib/lp/bugs/tests/test_bug.py
  lib/lp/registry/model/person.py
  lib/lp/bugs/interfaces/bugnotification.py
  lib/lp/bugs/interfaces/bug.py

== Schema ==

database/sampledata/current.sql
    database/sampledata/lintdata.sql differs from database/sampledata/current.sql.
    Patches to the schema, or manual edits to database/sampledata/current.sql
    do not match the dump of the launchpad_ftest_template database.
    If database/sampledata/lintdata.sql is correct, copy it to
    database/sampledata/current.sql.
    Otherwise update database/sampledata/current.sql and run:
        make schema
        make newsampledata
        cd database/sampledata
        cp newsampledata.sql database/sampledata/current.sql
    Run make schema again to update the test/dev database.

database/sampledata/current.sql
    database/sampledata/lintdata-dev.sql differs from database/sampledata/current-dev.sql.
    Patches to the schema, or manual edits to database/sampledata/current-dev.sql
    do not match the dump of the launchpad_dev_template database.
    If database/sampledata/lintdata-dev.sql is correct, copy it to
    database/sampledata/current-dev.sql.
    Otherwise update database/sampledata/current-dev.sql and run:
        make schema
        make newsampledata
        cd database/sampledata
        cp newsampledata-dev.sql database/sampledata/current-dev.sql
    Run make schema again to update the test/dev database.

./lib/lp/bugs/model/bugnotification.py
     147: local variable 'cached_people' is assigned to but never used
     152: local variable 'cached_bugs' is assigned to but never used
     273: E501 line too long (81 characters)
     192: Line exceeds 78 characters.
     273: Line exceeds 78 characters.
./lib/lp/bugs/tests/test_bugnotification.py
     387: E201 whitespace after '{'
./lib/lp/bugs/scripts/tests/test_bugnotification.py
     175: Line exceeds 78 characters.
./lib/lp/bugs/tests/test_bugchanges.py
     211: local variable 'bug_subscription' is assigned to but never used
     605: local variable 'old_tags' is assigned to but never used
     629: local variable 'old_tags' is assigned to but never used
    1020: local variable 'lifecycle_subscriber' is assigned to but never used
    1742: local variable 'old_description' is assigned to but never used
    1759: local variable 'old_description' is assigned to but never used
    1774: local variable 'old_description' is assigned to but never used
    1788: local variable 'old_description' is assigned to but never used
./lib/lp/registry/model/person.py
    3849: local variable 'karma_total' is assigned to but never used
    3573: W291 trailing whitespace
    3575: W291 trailing whitespace
    3577: W291 trailing whitespace
    3579: W291 trailing whitespace
    3581: W291 trailing whitespace
    3598: W291 trailing whitespace
    3573: Line has trailing whitespace.
    3575: Line has trailing whitespace.
    3577: Line has trailing whitespace.
    3579: Line has trailing whitespace.
    3581: Line has trailing whitespace.
    3598: Line has trailing whitespace.

To post a comment you must log in.
Revision history for this message
Stuart Bishop (stub) wrote :

So we have a UI problem, and the desire to restore subscriptions to the previous value when emails for a bug are reenabled.

Is this feature genuinely interesting enough to add the new table, increasing the complexity of how to determine who gets what email?

The DB patch itself seems generally fine

 - We will want a who-did-it column to track who muted a team subscription if that is possible.
 - You are deleting a load of BugSubscription records. This means the code will need to cope with unmuting a bug with no existing BugSubscription, as well as restoring an existing BugSubscription. Would it not be better to set the affected BugSubscription notification level to something meaningful and avoid the extra code path and tests?

Revision history for this message
Данило Шеган (danilo) wrote :

У чет, 12. 05 2011. у 11:56 +0000, Stuart Bishop пише:
> So we have a UI problem, and the desire to restore subscriptions to the
> previous value when emails for a bug are reenabled.

It's not that simple. "Muting" is orthogonal to any subscriptions you
might have and should affect a single bug. In the bug report, Gary did
propose an alternative which is storing an actual muted subscription and
then restoring it when "unmuting", but the yellow team didn't like that
approach much (and it would still mean another new table to store muted
bug subscriptions; it was also only considered as the simpler of the
approaches for implementation, not as the preferred one).

> Is this feature genuinely interesting enough to add the new table,
> increasing the complexity of how to determine who gets what email?

Yes: it has been discussed and priority raised—we've basically inherited
the previous implementation from the Bugs team work in 2010, and didn't
want to change it unless explicitely asked to. Since we are now
explicitely asked to, that's what we are doing.

Also, it was raised as a big problem with the "muting" concept from user
perspective.

> The DB patch itself seems generally fine
>
> - We will want a who-did-it column to track who muted a team subscription if that is possible.

That should not be possible. Perhaps I should assert that in the code.

> - You are deleting a load of BugSubscription records.

Are we? I was expecting only a few of them to have level set to NOTHING
(10) on production. Am I mistaken?

> This means the
> code will need to cope with unmuting a bug with no existing
> BugSubscription, as well as restoring an existing BugSubscription.

That's transparently handled in the backend code (simply removing the
BugMute record restores the existing BugSubscription). And yes, that's
what UI code will have to do anyway if we want this feature, so we are
aware of that.

> Would it not be better to set the affected BugSubscription notification
> level to something meaningful and avoid the extra code path and tests?

That's what the code does today (i.e. uses the "NOTHING" level). Since
"muting" should work regardless of the way you are subscribed to a bug,
that's actually more complex and when it works, it does so "by
accident". For instance, it doesn't work when you are a member of the
team subscribed to the bug.

It also represents exactly the same model as BugSubscriptionFilterMute,
which should make it easier to understand the code.

Also, this makes the logic explicit for muting, and actually *simpler*.
Thus, it's much easier to ensure and prove it is correct. When
something is in practice orthogonal to the problem, it's best to
implement it like that as well.

Revision history for this message
Stuart Bishop (stub) wrote :

That all seems fine.

Please add the assert to block teams ending up with mute records. Even if the UI doesn't allow it, exposure by the web service might let bad data in. The current model doesn't support team mutes and that is fine (and probably desirable).

patch-2208-70-0.sql

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

Why would a team mute be undesirable?

Consider Team inner a member of team outer. And me, an admin of team inner.

If team outer is subscribed to (say) all Launchpad bugs, team inner
may as a whole be uninterested in the larger subscription and want to
mute it.

Revision history for this message
Stuart Bishop (stub) wrote :

On Mon, May 16, 2011 at 6:21 AM, Robert Collins
<email address hidden> wrote:

> Why would a team mute be undesirable?

I think it becomes very confusing very quickly. eg. if you are a
member of two teams subscribed to a bug, what happens if one team
subscription is muted? Or if you are both directly subscribed and a
member of a team that is subscribed? If the team is muted, do you
receive an email stating this so team members who do want to receive
email can directly subscribe?

If we do want this in the future, the edge cases and UI need to be
thought through. If this is the future, we might as well add the
muted_by NOT NULL person reference and corresponding index now.

--
Stuart Bishop <email address hidden>
http://www.stuartbishop.net/

Revision history for this message
Данило Шеган (danilo) wrote :

У нед, 15. 05 2011. у 23:21 +0000, Robert Collins пише:
> Why would a team mute be undesirable?

We've only ever planned for personal muting to be possible: fwiw, we
have no UI making team muting possible at all, nor was it ever planned
to have it. I am not opposed, but it would be an entirely new feature,
and I suggest you discuss it with Jono and Gary if you feel like it's
very much desirable.

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

> У нед, 15. 05 2011. у 23:21 +0000, Robert Collins пише:
> > Why would a team mute be undesirable?
>
> We've only ever planned for personal muting to be possible: fwiw, we
> have no UI making team muting possible at all, nor was it ever planned
> to have it. I am not opposed, but it would be an entirely new feature,
> and I suggest you discuss it with Jono and Gary if you feel like it's
> very much desirable.

Uhm, so last week we muted the bug subscription for launchpad-bugs, successfully (well, until the backend blew up :P). I will raise with jono/gary for clarification. No objection to landing this - I was mainly responding to the desirability point raised earlier.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'database/schema/comments.sql'
2--- database/schema/comments.sql 2011-05-11 10:32:15 +0000
3+++ database/schema/comments.sql 2011-05-17 11:44:40 +0000
4@@ -190,6 +190,12 @@
5 COMMENT ON COLUMN BugJob.job_type IS 'The type of job (enumeration value). Allows us to query the database for a given subset of BugJobs.';
6 COMMENT ON COLUMN BugJob.json_data IS 'A JSON struct containing data for the job.';
7
8+-- BugMute
9+COMMENT ON TABLE BugMute IS 'Mutes for bug notifications.';
10+COMMENT ON COLUMN BugMute.person IS 'The person that muted all notifications from this bug.';
11+COMMENT ON COLUMN BugMute.bug IS 'The bug of this record';
12+COMMENT ON COLUMN BugMute.date_created IS 'The date at which this mute was created.';
13+
14 -- BugNomination
15 COMMENT ON TABLE BugNomination IS 'A bug nominated for fixing in a distroseries or productseries';
16 COMMENT ON COLUMN BugNomination.bug IS 'The bug being nominated.';
17
18=== added file 'database/schema/patch-2208-70-0.sql'
19--- database/schema/patch-2208-70-0.sql 1970-01-01 00:00:00 +0000
20+++ database/schema/patch-2208-70-0.sql 2011-05-17 11:44:40 +0000
21@@ -0,0 +1,36 @@
22+-- Copyright 2011 Canonical Ltd. This software is licensed under the
23+-- GNU Affero General Public License version 3 (see the file LICENSE).
24+
25+SET client_min_messages=ERROR;
26+
27+-- A table to store bug mutes in.
28+
29+CREATE TABLE BugMute (
30+ person integer REFERENCES Person(id)
31+ ON DELETE CASCADE NOT NULL,
32+ bug integer REFERENCES Bug(id)
33+ ON DELETE CASCADE NOT NULL,
34+ date_created timestamp without time zone
35+ DEFAULT timezone('UTC'::text, now()) NOT NULL,
36+ CONSTRAINT bugmute_pkey PRIMARY KEY (person, bug)
37+);
38+
39+-- We don't need an index on person, as the primary key index can be used
40+-- for those lookups. We have an index on just the bug, as the bulk of our
41+-- lookups will be on bugs.
42+CREATE INDEX bugmute__bug__idx
43+ ON BugMute(bug);
44+
45+-- Migrate existing BugSubscription's with
46+-- bug_notification_level == NOTHING
47+-- to BugMute table.
48+INSERT INTO BugMute (person, bug, date_created)
49+ SELECT person, bug, date_created
50+ FROM BugSubscription
51+ WHERE bug_notification_level=10;
52+-- Remove 'muting' BugSubscriptions.
53+DELETE
54+ FROM BugSubscription
55+ WHERE bug_notification_level=10;
56+
57+INSERT INTO LaunchpadDatabaseRevision VALUES (2208, 70, 0);
58
59=== modified file 'database/schema/security.cfg'
60--- database/schema/security.cfg 2011-05-11 16:07:09 +0000
61+++ database/schema/security.cfg 2011-05-17 11:44:40 +0000
62@@ -1062,6 +1062,7 @@
63 public.bugattachment = SELECT, INSERT, UPDATE
64 public.bugjob = SELECT, INSERT
65 public.bugmessage = SELECT, INSERT, UPDATE
66+public.bugmute = SELECT, INSERT, UPDATE, DELETE
67 public.bugnomination = SELECT, INSERT, UPDATE
68 public.bugpackageinfestation = SELECT, INSERT, UPDATE
69 public.bugproductinfestation = SELECT, INSERT, UPDATE
70@@ -1445,6 +1446,7 @@
71 public.bugattachment = SELECT
72 public.bugjob = SELECT, INSERT
73 public.bugmessage = SELECT, INSERT
74+public.bugmute = SELECT
75 public.bugnomination = SELECT
76 public.bugnotification = SELECT, INSERT, UPDATE
77 public.bugnotificationfilter = SELECT, INSERT
78
79=== modified file 'lib/lp/bugs/browser/bugsubscription.py'
80--- lib/lp/bugs/browser/bugsubscription.py 2011-05-12 14:59:21 +0000
81+++ lib/lp/bugs/browser/bugsubscription.py 2011-05-17 11:44:40 +0000
82@@ -216,6 +216,14 @@
83 persons_for_user[person.id] = person
84 person_count += 1
85
86+ # The view code previously expected a 'mute' to be a subscription
87+ # as well. Since it is not anymore, we add the user to the
88+ # subscribers list as needed.
89+ if self.user_is_muted:
90+ if self.user.id not in persons_for_user:
91+ persons_for_user[self.user.id] = self.user
92+ person_count += 1
93+
94 self._subscriber_count_for_current_user = person_count
95 return persons_for_user.values()
96
97@@ -254,7 +262,7 @@
98 if person.id == self.user.id:
99 if (self._use_advanced_features and
100 (self.user_is_subscribed_directly or
101- self.user_is_muted)):
102+ self.user_is_muted)):
103 subscription_terms.append(
104 self._update_subscription_term)
105 subscription_terms.insert(
106@@ -388,9 +396,14 @@
107
108 if (subscription_person == self._update_subscription_term.value and
109 (self.user_is_subscribed or self.user_is_muted)):
110- self._handleUpdateSubscription(level=bug_notification_level)
111+ if self.user_is_muted:
112+ self._handleUnmute()
113+ if self.user_is_subscribed:
114+ self._handleUpdateSubscription(level=bug_notification_level)
115+ else:
116+ self._handleSubscribe(level=bug_notification_level)
117 elif self.user_is_muted and subscription_person == self.user:
118- self._handleUnsubscribeCurrentUser()
119+ self._handleUnmute()
120 elif (not self.user_is_subscribed and
121 (subscription_person == self.user)):
122 self._handleSubscribe(bug_notification_level)
123@@ -411,6 +424,10 @@
124 else:
125 self._handleUnsubscribeOtherUser(user)
126
127+ def _handleUnmute(self):
128+ """Handle an unmute request."""
129+ self.context.bug.unmute(self.user, self.user)
130+
131 def _handleUnsubscribeCurrentUser(self):
132 """Handle the special cases for unsubscribing the current user.
133
134
135=== modified file 'lib/lp/bugs/browser/tests/test_bug_context_menu.py'
136--- lib/lp/bugs/browser/tests/test_bug_context_menu.py 2011-04-13 18:03:03 +0000
137+++ lib/lp/bugs/browser/tests/test_bug_context_menu.py 2011-05-17 11:44:40 +0000
138@@ -47,8 +47,7 @@
139 person = self.factory.makePerson()
140 with feature_flags():
141 with person_logged_in(person):
142- self.bug.subscribe(
143- person, person, level=BugNotificationLevel.NOTHING)
144+ self.bug.mute(person, person)
145 link = self.context_menu.subscription()
146 self.assertEqual('Subscribe', link.text)
147
148@@ -65,8 +64,7 @@
149 self.assertEqual("Mute bug mail", link.text)
150 # Once the user has muted the bug, the link text will
151 # change.
152- self.bug.subscribe(
153- person, person, level=BugNotificationLevel.NOTHING)
154+ self.bug.mute(person, person)
155 link = self.context_menu.mute_subscription()
156 self.assertEqual("Unmute bug mail", link.text)
157
158
159=== modified file 'lib/lp/bugs/browser/tests/test_bugsubscription_views.py'
160--- lib/lp/bugs/browser/tests/test_bugsubscription_views.py 2011-04-26 11:58:26 +0000
161+++ lib/lp/bugs/browser/tests/test_bugsubscription_views.py 2011-05-17 11:44:40 +0000
162@@ -5,6 +5,8 @@
163
164 __metaclass__ = type
165
166+import transaction
167+
168 from canonical.launchpad.ftests import LaunchpadFormHarness
169 from canonical.launchpad.webapp import canonical_url
170 from canonical.testing.layers import LaunchpadFunctionalLayer
171@@ -287,6 +289,7 @@
172
173 with FeatureFixture({self.feature_flag: ON}):
174 with person_logged_in(self.person):
175+ self.bug.mute(self.person, self.person)
176 subscribe_view = create_initialized_view(
177 self.bug.default_bugtask, name='+subscribe')
178 subscription_widget = (
179@@ -325,12 +328,10 @@
180
181 with FeatureFixture({self.feature_flag: ON}):
182 with person_logged_in(self.person):
183- level = BugNotificationLevel.METADATA
184 form_data = {
185 'field.subscription': self.person.name,
186 # Although this isn't used we must pass it for the
187 # sake of form validation.
188- 'field.bug_notification_level': level.title,
189 'field.actions.continue': 'Continue',
190 }
191 create_initialized_view(
192@@ -344,7 +345,7 @@
193 # muted subscription will update the existing subscription to a
194 # new BugNotificationLevel.
195 with person_logged_in(self.person):
196- muted_subscription = self.bug.mute(self.person, self.person)
197+ self.bug.mute(self.person, self.person)
198
199 with FeatureFixture({self.feature_flag: ON}):
200 with person_logged_in(self.person):
201@@ -357,10 +358,9 @@
202 create_initialized_view(
203 self.bug.default_bugtask, form=form_data,
204 name='+subscribe')
205+ transaction.commit()
206 self.assertFalse(self.bug.isMuted(self.person))
207 self.assertTrue(self.bug.isSubscribed(self.person))
208- self.assertEqual(
209- level, muted_subscription.bug_notification_level)
210
211 def test_bug_notification_level_field_has_widget_class(self):
212 # The bug_notification_level widget has a widget_class property
213
214=== modified file 'lib/lp/bugs/configure.zcml'
215--- lib/lp/bugs/configure.zcml 2011-04-13 18:48:42 +0000
216+++ lib/lp/bugs/configure.zcml 2011-05-17 11:44:40 +0000
217@@ -645,6 +645,13 @@
218 set_schema=".interfaces.bugsubscriptionfilter.IBugSubscriptionFilterAttributes" />
219 </class>
220
221+ <!-- BugMute -->
222+ <class
223+ class=".model.bug.BugMute">
224+ <allow
225+ interface=".interfaces.bug.IBugMute"/>
226+ </class>
227+
228 <!-- BugSubscriptionFilterMute -->
229 <class
230 class=".model.bugsubscriptionfilter.BugSubscriptionFilterMute">
231
232=== modified file 'lib/lp/bugs/interfaces/bug.py'
233--- lib/lp/bugs/interfaces/bug.py 2011-05-12 21:33:10 +0000
234+++ lib/lp/bugs/interfaces/bug.py 2011-05-17 11:44:40 +0000
235@@ -14,6 +14,7 @@
236 'IBugAddForm',
237 'IBugBecameQuestionEvent',
238 'IBugDelta',
239+ 'IBugMute',
240 'IBugSet',
241 'IFileBugData',
242 'IFrontPageBugAddForm',
243@@ -85,6 +86,7 @@
244 ContentNameField,
245 Description,
246 DuplicateBug,
247+ PersonChoice,
248 PublicPersonChoice,
249 Tag,
250 Title,
251@@ -1210,3 +1212,18 @@
252 comments = Attribute("Comments to add to the bug.")
253 attachments = Attribute("Attachments to add to the bug.")
254 hwdb_submission_keys = Attribute("HWDB submission keys for the bug.")
255+
256+
257+class IBugMute(Interface):
258+ """A mute on an IBug."""
259+
260+ person = PersonChoice(
261+ title=_('Person'), required=True, vocabulary='ValidPersonOrTeam',
262+ readonly=True, description=_("The person subscribed."))
263+ bug = Reference(
264+ IBug, title=_("Bug"),
265+ required=True, readonly=True,
266+ description=_("The bug to be muted."))
267+ date_created = Datetime(
268+ title=_("The date on which the mute was created."), required=False,
269+ readonly=True)
270
271=== modified file 'lib/lp/bugs/interfaces/bugnotification.py'
272--- lib/lp/bugs/interfaces/bugnotification.py 2011-04-05 22:34:35 +0000
273+++ lib/lp/bugs/interfaces/bugnotification.py 2011-05-17 11:44:40 +0000
274@@ -80,15 +80,17 @@
275 `BugNotificationRecipient` objects.
276 """
277
278- def getRecipientFilterData(recipient_to_sources, notifications):
279+ def getRecipientFilterData(bug, recipient_to_sources, notifications):
280 """Get non-muted recipients mapped to sources & filter descriptions.
281
282+ :param bug:
283+ A bug we are collecting filter data for.
284 :param recipient_to_sources:
285 A dict of people who are to receive the email to the sources
286 (BugNotificationRecipients) that represent the subscriptions that
287 caused the notifications to be sent.
288 :param notifications: the notifications that are being communicated.
289-
290+
291 The dict of recipients may have fewer recipients than were
292 provided if those users muted all of the subscription filters
293 that caused them to be sent.
294
295=== modified file 'lib/lp/bugs/model/bug.py'
296--- lib/lp/bugs/model/bug.py 2011-05-16 18:06:29 +0000
297+++ lib/lp/bugs/model/bug.py 2011-05-17 11:44:40 +0000
298@@ -11,6 +11,7 @@
299 'Bug',
300 'BugAffectsPerson',
301 'BugBecameQuestionEvent',
302+ 'BugMute',
303 'BugSet',
304 'BugTag',
305 'FileBugData',
306@@ -29,6 +30,7 @@
307 from functools import wraps
308 from itertools import chain
309 import operator
310+import pytz
311 import re
312
313 from lazr.lifecycle.event import (
314@@ -63,6 +65,11 @@
315 Union,
316 )
317 from storm.info import ClassAlias
318+from storm.locals import (
319+ DateTime,
320+ Int,
321+ Reference,
322+ )
323 from storm.store import (
324 EmptyResultSet,
325 Store,
326@@ -138,6 +145,7 @@
327 from lp.bugs.interfaces.bug import (
328 IBug,
329 IBugBecameQuestionEvent,
330+ IBugMute,
331 IBugSet,
332 IFileBugData,
333 )
334@@ -188,6 +196,7 @@
335 from lp.registry.interfaces.distroseries import IDistroSeries
336 from lp.registry.interfaces.person import (
337 IPersonSet,
338+ validate_person,
339 validate_public_person,
340 )
341 from lp.registry.interfaces.product import IProduct
342@@ -201,6 +210,7 @@
343 )
344 from lp.registry.model.pillar import pillar_sort_key
345 from lp.registry.model.teammembership import TeamParticipation
346+from lp.services.database.stormbase import StormBase
347 from lp.services.fields import DuplicateBug
348 from lp.services.propertycache import (
349 cachedproperty,
350@@ -843,40 +853,44 @@
351 """See `IBug`."""
352 return self.personIsSubscribedToDuplicate(person)
353
354+ def _getMutes(self, person):
355+ store = Store.of(self)
356+ mutes = store.find(
357+ BugMute,
358+ BugMute.bug == self,
359+ BugMute.person == person)
360+ return mutes
361+
362 def isMuted(self, person):
363 """See `IBug`."""
364- store = Store.of(self)
365- subscriptions = store.find(
366- BugSubscription,
367- BugSubscription.bug == self,
368- BugSubscription.person == person,
369- BugSubscription.bug_notification_level ==
370- BugNotificationLevel.NOTHING)
371- return not subscriptions.is_empty()
372+ mutes = self._getMutes(person)
373+ return not mutes.is_empty()
374
375 def mute(self, person, muted_by):
376 """See `IBug`."""
377 if person is None:
378 # This may be a webservice request.
379 person = muted_by
380- # If there's an existing subscription, update it.
381- store = Store.of(self)
382- subscriptions = store.find(
383- BugSubscription,
384- BugSubscription.bug == self,
385- BugSubscription.person == person)
386- if subscriptions.is_empty():
387- return self.subscribe(
388- person, muted_by, level=BugNotificationLevel.NOTHING)
389+ assert not person.is_team, (
390+ "Muting a subscription for entire team is not allowed.")
391+
392+ # If it's already muted, ignore the request.
393+ mutes = self._getMutes(person)
394+ if mutes.is_empty():
395+ mute = BugMute(person, self)
396+ Store.of(mute).flush()
397 else:
398- subscription = subscriptions.one()
399- subscription.bug_notification_level = (
400- BugNotificationLevel.NOTHING)
401- return subscription
402+ # It's already muted, pass.
403+ pass
404
405 def unmute(self, person, unmuted_by):
406 """See `IBug`."""
407- self.unsubscribe(person, unmuted_by)
408+ store = Store.of(self)
409+ if person is None:
410+ # This may be a webservice request.
411+ person = unmuted_by
412+ mutes = self._getMutes(person)
413+ store.remove(mutes.one())
414
415 @property
416 def subscriptions(self):
417@@ -2701,3 +2715,29 @@
418 def asDict(self):
419 """Return the FileBugData instance as a dict."""
420 return self.__dict__.copy()
421+
422+
423+class BugMute(StormBase):
424+ """Contains bugs a person has decided to block notifications from."""
425+
426+ implements(IBugMute)
427+
428+ __storm_table__ = "BugMute"
429+
430+ def __init__(self, person=None, bug=None):
431+ if person is not None:
432+ self.person = person
433+ if bug is not None:
434+ self.bug_id = bug.id
435+
436+ person_id = Int("person", allow_none=False, validator=validate_person)
437+ person = Reference(person_id, "Person.id")
438+
439+ bug_id = Int("bug", allow_none=False)
440+ bug = Reference(bug_id, "Bug.id")
441+
442+ __storm_primary__ = 'person_id', 'bug_id'
443+
444+ date_created = DateTime(
445+ "date_created", allow_none=False, default=UTC_NOW,
446+ tzinfo=pytz.UTC)
447
448=== modified file 'lib/lp/bugs/model/bugnotification.py'
449--- lib/lp/bugs/model/bugnotification.py 2011-05-12 21:33:10 +0000
450+++ lib/lp/bugs/model/bugnotification.py 2011-05-17 11:44:40 +0000
451@@ -144,12 +144,12 @@
452 # Now we do some calls that are purely for cacheing.
453 # Converting these into lists forces the queries to execute.
454 if pending_notifications:
455- cached_people = list(
456+ list(
457 getUtility(IPersonSet).getPrecachedPersonsFromIDs(
458 list(people_ids),
459 need_validity=True,
460 need_preferred_email=True))
461- cached_bugs = list(
462+ list(
463 IStore(Bug).find(Bug, In(Bug.id, list(bug_ids))))
464 pending_notifications.reverse()
465 return pending_notifications
466@@ -189,11 +189,18 @@
467
468 return bug_notification
469
470- def getRecipientFilterData(self, recipient_to_sources, notifications):
471+ def getRecipientFilterData(self, bug, recipient_to_sources,
472+ notifications):
473 """See `IBugNotificationSet`."""
474 if not notifications or not recipient_to_sources:
475 # This is a shortcut that will remove some error conditions.
476 return {}
477+ # Collect bug mute information.
478+ from lp.bugs.model.bug import BugMute
479+ store = IStore(BugMute)
480+ muted_person_ids = set(list(
481+ store.find(BugMute.person_id,
482+ BugMute.bug == bug)))
483 # This makes two calls to the database to get all the
484 # information we need. The first call gets the filter ids and
485 # descriptions for each recipient, and then we divide up the
486@@ -202,6 +209,8 @@
487 source_person_id_map = {}
488 recipient_id_map = {}
489 for recipient, sources in recipient_to_sources.items():
490+ if recipient.id in muted_person_ids:
491+ continue
492 source_person_ids = set()
493 recipient_id_map[recipient.id] = {
494 'principal': recipient,
495@@ -231,14 +240,17 @@
496 Join(StructuralSubscription,
497 BugSubscriptionFilter.structural_subscription_id ==
498 StructuralSubscription.id))
499- filter_data = source.find(
500- (StructuralSubscription.subscriberID,
501- BugSubscriptionFilter.id,
502- BugSubscriptionFilter.description),
503- In(BugNotificationFilter.bug_notification_id,
504- [notification.id for notification in notifications]),
505- In(StructuralSubscription.subscriberID,
506- source_person_id_map.keys()))
507+ if len(source_person_id_map) == 0:
508+ filter_data = []
509+ else:
510+ filter_data = source.find(
511+ (StructuralSubscription.subscriberID,
512+ BugSubscriptionFilter.id,
513+ BugSubscriptionFilter.description),
514+ In(BugNotificationFilter.bug_notification_id,
515+ [notification.id for notification in notifications]),
516+ In(StructuralSubscription.subscriberID,
517+ source_person_id_map.keys()))
518 filter_ids = []
519 # Record the filters for each source.
520 for source_person_id, filter_id, filter_description in filter_data:
521@@ -259,7 +271,8 @@
522 mute_data = store.find(
523 (BugSubscriptionFilterMute.person_id,
524 BugSubscriptionFilterMute.filter_id),
525- In(BugSubscriptionFilterMute.person_id, recipient_id_map.keys()),
526+ In(BugSubscriptionFilterMute.person_id,
527+ recipient_id_map.keys()),
528 In(BugSubscriptionFilterMute.filter_id, filter_ids))
529 for person_id, filter_id in mute_data:
530 del recipient_id_map[person_id]['filters'][filter_id]
531
532=== modified file 'lib/lp/bugs/model/bugsubscriptionfilter.py'
533--- lib/lp/bugs/model/bugsubscriptionfilter.py 2011-04-05 22:34:35 +0000
534+++ lib/lp/bugs/model/bugsubscriptionfilter.py 2011-05-17 11:44:40 +0000
535@@ -296,7 +296,7 @@
536
537
538 class BugSubscriptionFilterMute(StormBase):
539- """A filter to specialize a *structural* subscription."""
540+ """Bug subscription filters a person has decided to block emails from."""
541
542 implements(IBugSubscriptionFilterMute)
543
544@@ -312,7 +312,7 @@
545 person = Reference(person_id, "Person.id")
546
547 filter_id = Int("filter", allow_none=False)
548- filter = Reference(filter_id, "StructuralSubscription.id")
549+ filter = Reference(filter_id, "BugSubscriptionFilter.id")
550
551 __storm_primary__ = 'person_id', 'filter_id'
552
553
554=== modified file 'lib/lp/bugs/scripts/bugnotification.py'
555--- lib/lp/bugs/scripts/bugnotification.py 2011-04-05 22:34:35 +0000
556+++ lib/lp/bugs/scripts/bugnotification.py 2011-05-17 11:44:40 +0000
557@@ -174,7 +174,7 @@
558 from_address = get_bugmail_from_address(actor, bug)
559 bug_notification_builder = BugNotificationBuilder(bug, actor)
560 recipients = getUtility(IBugNotificationSet).getRecipientFilterData(
561- recipients, filtered_notifications)
562+ bug, recipients, filtered_notifications)
563 sorted_recipients = sorted(
564 recipients.items(), key=lambda t: t[0].preferredemail.email)
565
566
567=== modified file 'lib/lp/bugs/scripts/tests/test_bugnotification.py'
568--- lib/lp/bugs/scripts/tests/test_bugnotification.py 2011-05-12 21:33:10 +0000
569+++ lib/lp/bugs/scripts/tests/test_bugnotification.py 2011-05-17 11:44:40 +0000
570@@ -172,7 +172,8 @@
571
572 implements(IBugNotificationSet)
573
574- def getRecipientFilterData(self, recipient_to_sources, notifications):
575+ def getRecipientFilterData(self, bug, recipient_to_sources,
576+ notifications):
577 return dict(
578 (recipient, {'sources': sources, 'filter descriptions': []})
579 for recipient, sources in recipient_to_sources.items())
580
581=== modified file 'lib/lp/bugs/tests/test_bug.py'
582--- lib/lp/bugs/tests/test_bug.py 2011-05-10 14:19:24 +0000
583+++ lib/lp/bugs/tests/test_bug.py 2011-05-17 11:44:40 +0000
584@@ -8,6 +8,7 @@
585 from lazr.lifecycle.snapshot import Snapshot
586 from zope.component import getUtility
587 from zope.interface import providedBy
588+from zope.security.proxy import removeSecurityProxy
589
590 from canonical.testing.layers import DatabaseFunctionalLayer
591
592@@ -39,11 +40,9 @@
593 self.person = self.factory.makePerson()
594
595 def test_is_muted_returns_true_for_muted_users(self):
596- # Bug.isMuted() will return True if the passed to it has a
597- # BugSubscription with a BugNotificationLevel of NOTHING.
598+ # Bug.isMuted() will return True if the person passed to it is muted.
599 with person_logged_in(self.person):
600- self.bug.subscribe(
601- self.person, self.person, level=BugNotificationLevel.NOTHING)
602+ self.bug.mute(self.person, self.person)
603 self.assertEqual(True, self.bug.isMuted(self.person))
604
605 def test_is_muted_returns_false_for_direct_subscribers(self):
606@@ -60,15 +59,21 @@
607 with person_logged_in(self.person):
608 self.assertEqual(False, self.bug.isMuted(self.person))
609
610+ def test_mute_team_fails(self):
611+ # Muting a subscription for an entire team doesn't work.
612+ with person_logged_in(self.person):
613+ team = self.factory.makeTeam(owner=self.person)
614+ self.assertRaises(AssertionError,
615+ self.bug.mute, team, team)
616+
617 def test_mute_mutes_user(self):
618- # Bug.mute() adds a muted subscription for the user passed to
619- # it.
620+ # Bug.mute() adds a BugMute record for the person passed to it.
621 with person_logged_in(self.person):
622- muted_subscription = self.bug.mute(
623- self.person, self.person)
624- self.assertEqual(
625- BugNotificationLevel.NOTHING,
626- muted_subscription.bug_notification_level)
627+ self.bug.mute(self.person, self.person)
628+ naked_bug = removeSecurityProxy(self.bug)
629+ bug_mute = naked_bug._getMutes(self.person).one()
630+ self.assertEqual(self.bug, bug_mute.bug)
631+ self.assertEqual(self.person, bug_mute.person)
632
633 def test_mute_mutes_muter(self):
634 # When exposed in the web API, the mute method regards the
635@@ -80,14 +85,15 @@
636 self.assertTrue(self.bug.isMuted(self.person))
637
638 def test_mute_mutes_user_with_existing_subscription(self):
639- # Bug.mute() will update an existing subscription so that it
640- # becomes muted.
641+ # Bug.mute() will not touch the existing subscription.
642 with person_logged_in(self.person):
643- subscription = self.bug.subscribe(self.person, self.person)
644- muted_subscription = self.bug.mute(self.person, self.person)
645- self.assertEqual(subscription, muted_subscription)
646+ subscription = self.bug.subscribe(
647+ self.person, self.person,
648+ level=BugNotificationLevel.METADATA)
649+ self.bug.mute(self.person, self.person)
650+ self.assertTrue(self.bug.isMuted(self.person))
651 self.assertEqual(
652- BugNotificationLevel.NOTHING,
653+ BugNotificationLevel.METADATA,
654 subscription.bug_notification_level)
655
656 def test_unmute_unmutes_user(self):
657
658=== modified file 'lib/lp/bugs/tests/test_bugchanges.py'
659--- lib/lp/bugs/tests/test_bugchanges.py 2011-03-23 18:29:09 +0000
660+++ lib/lp/bugs/tests/test_bugchanges.py 2011-05-17 11:44:40 +0000
661@@ -208,7 +208,7 @@
662 # Unsubscribing someone from a bug adds an item to the activity
663 # log, but doesn't send an e-mail notification.
664 subscriber = self.factory.makePerson(displayname='Mom')
665- bug_subscription = self.bug.subscribe(self.user, subscriber)
666+ self.bug.subscribe(self.user, subscriber)
667 self.saveOldChanges()
668 # Only the user can unsubscribe him or her self.
669 self.bug.unsubscribe(self.user, self.user)
670@@ -602,7 +602,7 @@
671 def test_tags_added(self):
672 # Adding tags to a bug will add BugActivity and BugNotification
673 # entries.
674- old_tags = self.changeAttribute(
675+ self.changeAttribute(
676 self.bug, 'tags', ['first-new-tag', 'second-new-tag'])
677
678 tag_change_activity = {
679@@ -626,7 +626,7 @@
680 # entries.
681 self.bug.tags = ['first-new-tag', 'second-new-tag']
682 self.saveOldChanges()
683- old_tags = self.changeAttribute(
684+ self.changeAttribute(
685 self.bug, 'tags', ['first-new-tag'])
686
687 tag_change_activity = {
688@@ -1017,7 +1017,7 @@
689 target = self.factory.makeDistributionSourcePackage()
690 metadata_subscriber = self.newSubscriber(
691 target, "dsp-metadata", BugNotificationLevel.METADATA)
692- lifecycle_subscriber = self.newSubscriber(
693+ self.newSubscriber(
694 target, "dsp-lifecycle", BugNotificationLevel.LIFECYCLE)
695 new_target = self.factory.makeDistributionSourcePackage(
696 distribution=target.distribution)
697@@ -1739,7 +1739,7 @@
698 # do not get any bug email that they generated themselves.
699 self.user.selfgenerated_bugnotifications = False
700
701- old_description = self.changeAttribute(
702+ self.changeAttribute(
703 self.bug, 'description', 'New description')
704
705 # self.user is not included among the recipients.
706@@ -1756,7 +1756,22 @@
707
708 self.user.selfgenerated_bugnotifications = False
709
710- old_description = self.changeAttribute(
711+ self.changeAttribute(
712+ self.bug, 'description', 'New description')
713+
714+ # self.user is not included among the recipients.
715+ self.assertRecipients(
716+ [self.product_metadata_subscriber, team.teamowner])
717+
718+ def test_description_changed_no_muted_email(self):
719+ # Users who have muted a bug do not get any bug email for a bug,
720+ # even if they are subscribed through a team membership.
721+ team = self.factory.makeTeam()
722+ team.addMember(self.user, team.teamowner)
723+ self.bug.subscribe(team, self.user)
724+ self.bug.mute(self.user, self.user)
725+
726+ self.changeAttribute(
727 self.bug, 'description', 'New description')
728
729 # self.user is not included among the recipients.
730@@ -1770,7 +1785,7 @@
731 self.bug.subscribe(self.product_metadata_subscriber,
732 self.product_metadata_subscriber,
733 level=BugNotificationLevel.LIFECYCLE)
734- old_description = self.changeAttribute(
735+ self.changeAttribute(
736 self.bug, 'description', 'New description')
737
738 # self.product_metadata_subscriber is not included among the
739
740=== modified file 'lib/lp/bugs/tests/test_bugnotification.py'
741--- lib/lp/bugs/tests/test_bugnotification.py 2011-05-12 21:33:10 +0000
742+++ lib/lp/bugs/tests/test_bugnotification.py 2011-05-17 11:44:40 +0000
743@@ -233,11 +233,11 @@
744 def test_getRecipientFilterData_empty(self):
745 # When there is empty input, there is empty output.
746 self.assertEqual(
747- BugNotificationSet().getRecipientFilterData({}, []),
748+ BugNotificationSet().getRecipientFilterData(self.bug, {}, []),
749 {})
750 self.assertEqual(
751 BugNotificationSet().getRecipientFilterData(
752- {}, [self.notification]),
753+ self.bug, {}, [self.notification]),
754 {})
755
756 def test_getRecipientFilterData_other_persons(self):
757@@ -259,7 +259,7 @@
758 subscriber2: {'sources': sources2,
759 'filter descriptions': [u'Special Filter!']}},
760 BugNotificationSet().getRecipientFilterData(
761- {self.subscriber: sources, subscriber2: sources2},
762+ self.bug, {self.subscriber: sources, subscriber2: sources2},
763 [self.notification, notification2]))
764
765 def test_getRecipientFilterData_match(self):
766@@ -271,7 +271,7 @@
767 {self.subscriber: {'sources': sources,
768 'filter descriptions': ['Special Filter!']}},
769 BugNotificationSet().getRecipientFilterData(
770- {self.subscriber: sources}, [self.notification]))
771+ self.bug, {self.subscriber: sources}, [self.notification]))
772
773 def test_getRecipientFilterData_multiple_notifications_match(self):
774 # When there are bug filters for the recipient for multiple
775@@ -285,7 +285,7 @@
776 {self.subscriber: {'sources': sources,
777 'filter descriptions': ['Another Filter!', 'Special Filter!']}},
778 BugNotificationSet().getRecipientFilterData(
779- {self.subscriber: sources},
780+ self.bug, {self.subscriber: sources},
781 [self.notification, self.notification2]))
782
783 def test_getRecipientFilterData_mute(self):
784@@ -300,7 +300,7 @@
785 self.assertEqual(
786 {},
787 BugNotificationSet().getRecipientFilterData(
788- {self.subscriber: sources}, [self.notification]))
789+ self.bug, {self.subscriber: sources}, [self.notification]))
790
791 def test_getRecipientFilterData_mute_one_person_of_two(self):
792 self.includeFilterInNotification()
793@@ -321,7 +321,7 @@
794 {subscriber2: {'sources': sources2,
795 'filter descriptions': [u'Special Filter!']}},
796 BugNotificationSet().getRecipientFilterData(
797- {self.subscriber: sources, subscriber2: sources2},
798+ self.bug, {self.subscriber: sources, subscriber2: sources2},
799 [self.notification, notification2]))
800
801 def test_getRecipientFilterData_mute_one_filter_of_two(self):
802@@ -337,7 +337,7 @@
803 {self.subscriber: {'sources': sources,
804 'filter descriptions': ['Another Filter!']}},
805 BugNotificationSet().getRecipientFilterData(
806- {self.subscriber: sources},
807+ self.bug, {self.subscriber: sources},
808 [self.notification, self.notification2]))
809
810 def test_getRecipientFilterData_mute_both_filters_mutes(self):
811@@ -356,9 +356,51 @@
812 self.assertEqual(
813 {},
814 BugNotificationSet().getRecipientFilterData(
815- {self.subscriber: sources},
816+ self.bug, {self.subscriber: sources},
817 [self.notification, self.notification2]))
818
819+ def test_getRecipientFilterData_mute_bug_mutes(self):
820+ # Mute the bug for the subscriber.
821+ self.team = self.factory.makeTeam()
822+ self.subscriber.join(self.team)
823+
824+ self.bug.mute(self.subscriber, self.subscriber)
825+ sources = list(self.notification.recipients)
826+ # Perform the test.
827+ self.assertEqual(
828+ {},
829+ BugNotificationSet().getRecipientFilterData(
830+ self.bug, {self.subscriber: sources}, [self.notification]))
831+
832+ def test_getRecipientFilterData_mute_bug_mutes_only_themselves(self):
833+ # Mute the bug for the subscriber.
834+ self.bug.mute(self.subscriber, self.subscriber)
835+
836+ # Notification for the other person still goes through.
837+ person = self.factory.makePerson(name='other')
838+ self.addNotificationRecipient(self.notification, person)
839+
840+ sources = list(self.notification.recipients)
841+
842+ # Perform the test.
843+ self.assertEqual(
844+ {person: {'filter descriptions': [],
845+ 'sources': sources}},
846+ BugNotificationSet().getRecipientFilterData(
847+ self.bug, {self.subscriber: sources,
848+ person: sources},
849+ [self.notification]))
850+
851+ def test_getRecipientFilterData_mute_bug_mutes_filter(self):
852+ # Mute the bug for the subscriber.
853+ self.bug.mute(self.subscriber, self.subscriber)
854+ self.includeFilterInNotification(description=u'Special Filter!')
855+ sources = list(self.notification.recipients)
856+ self.assertEqual(
857+ {},
858+ BugNotificationSet().getRecipientFilterData(
859+ self.bug, {self.subscriber: sources}, [self.notification]))
860+
861
862 class TestNotificationProcessingWithoutRecipients(TestCaseWithFactory):
863 """Adding notificatons without any recipients does not cause any harm.
864
865=== modified file 'lib/lp/registry/model/person.py'
866--- lib/lp/registry/model/person.py 2011-05-10 18:02:28 +0000
867+++ lib/lp/registry/model/person.py 2011-05-17 11:44:40 +0000
868@@ -4041,6 +4041,9 @@
869 # We ignore BugSubscriptionFilterMutes.
870 skip.append(('bugsubscriptionfiltermute', 'person'))
871
872+ # We ignore BugMutes.
873+ skip.append(('bugmute', 'person'))
874+
875 self._mergePackageBugSupervisor(cur, from_id, to_id)
876 skip.append(('packagebugsupervisor', 'bug_supervisor'))
877
878@@ -4663,7 +4666,7 @@
879 if person.preferredemail:
880 return [person]
881 elif person.is_team:
882- # Get transitive members of team with a preferred email.
883+ # Get transitive members of team without a preferred email.
884 return _get_recipients_for_team(person)
885 else:
886 return []

Subscribers

People subscribed via source and target branches

to status/vote changes: