Merge lp:~danilo/launchpad/bug-720826-emails into lp:launchpad/db-devel

Proposed by Данило Шеган
Status: Merged
Approved by: Graham Binns
Approved revision: no longer in the source branch.
Merged at revision: 10254
Proposed branch: lp:~danilo/launchpad/bug-720826-emails
Merge into: lp:launchpad/db-devel
Prerequisite: lp:~danilo/launchpad/bug-720826
Diff against target: 393 lines (+266/-11)
6 files modified
lib/lp/bugs/emailtemplates/bug-notification-verbose.txt (+1/-1)
lib/lp/bugs/emailtemplates/bug-notification.txt (+1/-1)
lib/lp/bugs/mail/bugnotificationbuilder.py (+6/-1)
lib/lp/bugs/mail/tests/test_bugnotificationbuilder.py (+51/-0)
lib/lp/bugs/scripts/bugnotification.py (+21/-6)
lib/lp/bugs/scripts/tests/test_bugnotification.py (+186/-2)
To merge this branch: bzr merge lp:~danilo/launchpad/bug-720826-emails
Reviewer Review Type Date Requested Status
Graham Binns (community) code Approve
Review via email: mp+51747@code.launchpad.net

Commit message

[r=gmb][bug=720826][incr] Add headers/content describing filters that have caused an email notification to go off. Part of bug 720826 fix.

Description of the change

= Bug 720826: modify emails =

This is a part of work on 720826: adding headers and email body content
describing bug subscription filters which have caused that email to go off.

This branch adds the actual headers based on the BugNotificationFilter
table linking BugNotifications with BugSubscriptionFilters.

== Proposed fix ==

 * BugNotificationBuilder (which constructs the actual email text) gets a new filters parameter on the build() method
 * scripts/bugnotification.py construct_email_notifications now finds all matching filters for each recipient, constructs a text to include in the body of the email (for gmail users) and adds X-Launchpad-Subscription-Filter header for each of the filters with non-empty description

== Implementation details ==

I'm also moving bug-notification*.txt email templates to lib/lp/bugs/emailtemplates and using them by adding 'bugs' to the get_email_template call.

Rest of the bits are mostly lint fixes.

== Tests ==

bin/test -cvvt TestEmailNotificationsWithFilters -t TestBugNotificationBuilder

== Demo and Q/A ==

no-qa on it's own (as illustrated by tests, appropriate records do not get created automatically yet)

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/bugs/emailtemplates/bug-notification-verbose.txt
  lib/lp/bugs/emailtemplates/bug-notification.txt
  lib/lp/bugs/interfaces/structuralsubscription.py
  lib/lp/bugs/mail/bugnotificationbuilder.py
  lib/lp/bugs/mail/tests/test_bugnotificationbuilder.py
  lib/lp/bugs/model/structuralsubscription.py
  lib/lp/bugs/scripts/bugnotification.py
  lib/lp/bugs/scripts/tests/test_bugnotification.py

./lib/lp/bugs/emailtemplates/bug-notification-verbose.txt
       3: Line has trailing whitespace.
./lib/lp/bugs/emailtemplates/bug-notification.txt
       3: Line has trailing whitespace.

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== renamed file 'lib/canonical/launchpad/emailtemplates/bug-notification-verbose.txt' => 'lib/lp/bugs/emailtemplates/bug-notification-verbose.txt'
2--- lib/canonical/launchpad/emailtemplates/bug-notification-verbose.txt 2011-01-06 14:43:46 +0000
3+++ lib/lp/bugs/emailtemplates/bug-notification-verbose.txt 2011-03-02 17:29:24 +0000
4@@ -1,7 +1,7 @@
5 %(content)s
6
7 --
8-%(notification_rationale)s
9+%(notification_rationale)s%(subscription_filters)s
10 %(bug_url)s
11
12 Title:
13
14=== renamed file 'lib/canonical/launchpad/emailtemplates/bug-notification.txt' => 'lib/lp/bugs/emailtemplates/bug-notification.txt'
15--- lib/canonical/launchpad/emailtemplates/bug-notification.txt 2011-02-14 19:45:00 +0000
16+++ lib/lp/bugs/emailtemplates/bug-notification.txt 2011-03-02 17:29:24 +0000
17@@ -1,7 +1,7 @@
18 %(content)s
19
20 --
21-%(notification_rationale)s
22+%(notification_rationale)s%(subscription_filters)s
23 %(bug_url)s
24
25 Title:
26
27=== modified file 'lib/lp/bugs/mail/bugnotificationbuilder.py'
28--- lib/lp/bugs/mail/bugnotificationbuilder.py 2010-08-20 20:31:18 +0000
29+++ lib/lp/bugs/mail/bugnotificationbuilder.py 2011-03-02 17:29:24 +0000
30@@ -151,7 +151,7 @@
31 event_creator.name)))
32
33 def build(self, from_address, to_address, body, subject, email_date,
34- rationale=None, references=None, message_id=None):
35+ rationale=None, references=None, message_id=None, filters=None):
36 """Construct the notification.
37
38 :param from_address: The From address of the notification.
39@@ -192,4 +192,9 @@
40 if rationale is not None:
41 message.add_header('X-Launchpad-Message-Rationale', rationale)
42
43+ if filters is not None:
44+ for filter in filters:
45+ message.add_header(
46+ 'X-Launchpad-Subscription-Filter', filter)
47+
48 return message
49
50=== added file 'lib/lp/bugs/mail/tests/test_bugnotificationbuilder.py'
51--- lib/lp/bugs/mail/tests/test_bugnotificationbuilder.py 1970-01-01 00:00:00 +0000
52+++ lib/lp/bugs/mail/tests/test_bugnotificationbuilder.py 2011-03-02 17:29:24 +0000
53@@ -0,0 +1,51 @@
54+# Copyright 2011 Canonical Ltd. This software is licensed under the
55+# GNU Affero General Public License version 3 (see the file LICENSE).
56+
57+"""Tests for BugNotificationBuilder email construction."""
58+
59+from datetime import datetime
60+import pytz
61+
62+from canonical.testing.layers import ZopelessDatabaseLayer
63+from lp.bugs.mail.bugnotificationbuilder import BugNotificationBuilder
64+from lp.testing import TestCaseWithFactory
65+
66+
67+class TestBugNotificationBuilder(TestCaseWithFactory):
68+ """Test emails sent when subscribed by someone else."""
69+
70+ layer = ZopelessDatabaseLayer
71+
72+ def setUp(self):
73+ # Run the tests as a logged-in user.
74+ super(TestBugNotificationBuilder, self).setUp(
75+ user='test@canonical.com')
76+ self.bug = self.factory.makeBug()
77+ self.builder = BugNotificationBuilder(self.bug)
78+
79+ def test_build_filters_empty(self):
80+ """Filters are added."""
81+ utc_now = datetime.now(pytz.UTC)
82+ message = self.builder.build('from', 'to', 'body', 'subject',
83+ utc_now, filters=[])
84+ self.assertIs(None,
85+ message.get("X-Launchpad-Subscription-Filter", None))
86+
87+ def test_build_filters_single(self):
88+ """Filters are added."""
89+ utc_now = datetime.now(pytz.UTC)
90+ message = self.builder.build('from', 'to', 'body', 'subject',
91+ utc_now, filters=[u"Testing filter"])
92+ self.assertContentEqual(
93+ [u"Testing filter"],
94+ message.get_all("X-Launchpad-Subscription-Filter"))
95+
96+ def test_build_filters_multiple(self):
97+ """Filters are added."""
98+ utc_now = datetime.now(pytz.UTC)
99+ message = self.builder.build(
100+ 'from', 'to', 'body', 'subject', utc_now,
101+ filters=[u"Testing filter", u"Second testing filter"])
102+ self.assertContentEqual(
103+ [u"Testing filter", u"Second testing filter"],
104+ message.get_all("X-Launchpad-Subscription-Filter"))
105
106=== modified file 'lib/lp/bugs/scripts/bugnotification.py'
107--- lib/lp/bugs/scripts/bugnotification.py 2011-02-17 17:30:22 +0000
108+++ lib/lp/bugs/scripts/bugnotification.py 2011-03-02 17:29:24 +0000
109@@ -33,17 +33,17 @@
110
111 def get_activity_key(notification):
112 """Given a notification, return a key for the activity if it exists.
113-
114+
115 The key will be used to determine whether changes for the activity are
116 undone within the same batch of notifications (which are supposed to
117 be all for the same bug when they get to this function). Therefore,
118 the activity's attribute is a good start for the key.
119-
120+
121 If the activity was on a bugtask, we will also want to distinguish
122 by bugtask, because, for instance, changing a status from INPROGRESS
123 to FIXCOMMITED on one bug task is not undone if the status changes
124 from FIXCOMMITTED to INPROGRESS on another bugtask.
125-
126+
127 Similarly, if the activity is about adding or removing something
128 that we can have multiple of, like a branch or an attachment, the
129 key should include information on that value, because adding one
130@@ -163,6 +163,18 @@
131 reason = recipient.reason_body
132 rationale = recipient.reason_header
133
134+ filters = set()
135+ for notification in filtered_notifications:
136+ notification_filters = notification.getFiltersByRecipient(
137+ email_person)
138+ for notification_filter in notification_filters:
139+ if notification_filter.description is not None:
140+ filters.add(notification_filter.description)
141+ if filters:
142+ # There are some filters as well, add it to the email body.
143+ filters_text = u"\nMatching filters: %s" % ", ".join(filters)
144+ else:
145+ filters_text = u""
146 # XXX deryck 2009-11-17 Bug #484319
147 # This should be refactored to add a link inside the
148 # code where we build `reason`. However, this will
149@@ -180,7 +192,9 @@
150 'bug_title': data_wrapper.format(bug.title),
151 'bug_url': canonical_url(bug),
152 'unsubscribe_notice': unsubscribe_notice,
153- 'notification_rationale': mail_wrapper.format(reason)}
154+ 'notification_rationale': mail_wrapper.format(reason),
155+ 'subscription_filters': filters_text,
156+ }
157
158 # If the person we're sending to receives verbose notifications
159 # we include the description and status of the bug in the email
160@@ -200,10 +214,11 @@
161 else:
162 email_template = 'bug-notification.txt'
163
164- body = (get_email_template(email_template) % body_data).strip()
165+ body_template = get_email_template(email_template, 'bugs')
166+ body = (body_template % body_data).strip()
167 msg = bug_notification_builder.build(
168 from_address, address, body, subject, email_date,
169- rationale, references, msgid)
170+ rationale, references, msgid, filters=filters)
171 messages.append(msg)
172
173 return filtered_notifications, omitted_notifications, messages
174
175=== modified file 'lib/lp/bugs/scripts/tests/test_bugnotification.py'
176--- lib/lp/bugs/scripts/tests/test_bugnotification.py 2011-02-17 17:30:22 +0000
177+++ lib/lp/bugs/scripts/tests/test_bugnotification.py 2011-03-02 17:29:24 +0000
178@@ -14,7 +14,10 @@
179 from zope.interface import implements
180
181 from canonical.config import config
182-from canonical.database.sqlbase import flush_database_updates
183+from canonical.database.sqlbase import (
184+ flush_database_updates,
185+ sqlvalues,
186+ )
187 from canonical.launchpad.ftests import login
188 from canonical.launchpad.helpers import get_contact_email_addresses
189 from canonical.launchpad.interfaces.message import IMessageSet
190@@ -133,10 +136,13 @@
191 self.recipients = [MockBugNotificationRecipient()]
192 self.activity = None
193
194+ def getFiltersByRecipient(self, recipient):
195+ return []
196+
197
198 class FakeNotification:
199 """An even simpler fake notification.
200-
201+
202 Used by TestGetActivityKey, TestNotificationCommentBatches and
203 TestNotificationBatches."""
204
205@@ -153,6 +159,7 @@
206
207 class MockBugActivity:
208 """A mock BugActivity used for testing."""
209+
210 def __init__(self, target=None, attribute=None,
211 oldvalue=None, newvalue=None):
212 self.target = target
213@@ -868,3 +875,180 @@
214 BugAttachmentChange(
215 self.ten_minutes_ago, self.person, 'attachment',
216 item, None))
217+
218+from lp.bugs.model.bugnotification import (
219+ BugNotification,
220+ BugNotificationFilter,
221+# BugNotificationRecipient,
222+ )
223+from lp.bugs.scripts.bugnotification import construct_email_notifications
224+from storm.store import Store
225+
226+
227+class TestEmailNotificationsWithFilters(TestCaseWithFactory):
228+ """Ensure outgoing mails have corresponding mail headers.
229+
230+ Every filter that could have potentially caused a notification to
231+ go off has a `BugNotificationFilter` record linking a `BugNotification`
232+ and a `BugSubscriptionFilter`.
233+
234+ From those records, we include all BugSubscriptionFilter.description
235+ in X-Subscription-Filter-Description headers in each email.
236+ """
237+
238+ layer = LaunchpadZopelessLayer
239+
240+ def setUp(self):
241+ super(TestEmailNotificationsWithFilters, self).setUp()
242+ self.bug=self.factory.makeBug()
243+ subscriber = self.factory.makePerson()
244+ self.subscription = self.bug.default_bugtask.target.addSubscription(
245+ subscriber, subscriber)
246+ self.filter_count = 0
247+ self.notification = self.addNotification(subscriber)
248+
249+ def addNotificationRecipient(self, notification, person):
250+ # Manually insert BugNotificationRecipient for
251+ # construct_email_notifications to work.
252+ # Not sure why using SQLObject constructor doesn't work (it
253+ # tries to insert a row with only the ID which fails).
254+ Store.of(notification).execute("""
255+ INSERT INTO BugNotificationRecipient
256+ (bug_notification, person, reason_header, reason_body)
257+ VALUES (%s, %s, %s, %s)""" % sqlvalues(
258+ notification, person,
259+ u'reason header', u'reason body'))
260+
261+ def addNotification(self, person):
262+ # Add a notification along with recipient data.
263+ # This is generally done with BugTaskSet.addNotification()
264+ # but that requires a more complex set-up.
265+ message = self.factory.makeMessage()
266+ notification = BugNotification(
267+ message=message, activity=None, bug=self.bug,
268+ is_comment=False, date_emailed=None)
269+ self.addNotificationRecipient(notification, person)
270+ return notification
271+
272+ def addFilter(self, description, subscription=None):
273+ if subscription is None:
274+ subscription = self.subscription
275+ filter_count = self.filter_count
276+ self.filter_count += 1
277+ else:
278+ # For a non-default subscription, always use
279+ # the initial filter.
280+ filter_count = 0
281+
282+ # If no filters have been requested before,
283+ # use the initial auto-created filter for a subscription.
284+ if filter_count == 0:
285+ bug_filter = subscription.bug_filters.one()
286+ else:
287+ bug_filter = subscription.newBugFilter()
288+ bug_filter.description = description
289+ BugNotificationFilter(
290+ bug_notification=self.notification,
291+ bug_subscription_filter=bug_filter)
292+
293+ def getSubscriptionEmailHeaders(self, by_person=False):
294+ filtered, omitted, messages = construct_email_notifications(
295+ [self.notification])
296+ if by_person:
297+ headers = {}
298+ else:
299+ headers = set()
300+ for message in messages:
301+ if by_person:
302+ headers[message['to']] = message.get_all(
303+ "X-Launchpad-Subscription-Filter", [])
304+ else:
305+ headers = headers.union(
306+ set(message.get_all(
307+ "X-Launchpad-Subscription-Filter", [])))
308+ return headers
309+
310+ def getSubscriptionEmailBody(self, by_person=False):
311+ filtered, omitted, messages = construct_email_notifications(
312+ [self.notification])
313+ if by_person:
314+ filter_texts = {}
315+ else:
316+ filter_texts = set()
317+ for message in messages:
318+ filters_line = None
319+ for line in message.get_payload().splitlines():
320+ if line.startswith("Matching filters: "):
321+ filters_line = line
322+ break
323+ if filters_line is not None:
324+ if by_person:
325+ filter_texts[message['to']] = filters_line
326+ else:
327+ filter_texts.add(filters_line)
328+ return filter_texts
329+
330+ def test_header_empty(self):
331+ # An initial filter with no description doesn't cause any
332+ # headers to be added.
333+ self.assertContentEqual([],
334+ self.getSubscriptionEmailHeaders())
335+
336+ def test_header_single(self):
337+ # A single filter with a description makes all emails
338+ # include that particular filter description in a header.
339+ bug_filter = self.addFilter(u"Test filter")
340+
341+ self.assertContentEqual([u"Test filter"],
342+ self.getSubscriptionEmailHeaders())
343+
344+ def test_header_multiple(self):
345+ # Multiple filters with a description make all emails
346+ # include all filter descriptions in the header.
347+ bug_filter = self.addFilter(u"First filter")
348+ bug_filter = self.addFilter(u"Second filter")
349+
350+ self.assertContentEqual([u"First filter", u"Second filter"],
351+ self.getSubscriptionEmailHeaders())
352+
353+ def test_header_other_subscriber_by_person(self):
354+ # Filters for a different subscribers are included only
355+ # in email messages relevant to them, even if they might
356+ # all be for the same notification.
357+ other_person = self.factory.makePerson()
358+ other_subscription = self.bug.default_bugtask.target.addSubscription(
359+ other_person, other_person)
360+ bug_filter = self.addFilter(u"Someone's filter", other_subscription)
361+ self.addNotificationRecipient(self.notification, other_person)
362+
363+ this_filter = self.addFilter(u"Test filter")
364+
365+ the_subscriber = self.subscription.subscriber
366+ self.assertEquals(
367+ {other_person.preferredemail.email: [u"Someone's filter"],
368+ the_subscriber.preferredemail.email: [u"Test filter"]},
369+ self.getSubscriptionEmailHeaders(by_person=True))
370+
371+ def test_body_empty(self):
372+ # An initial filter with no description doesn't cause any
373+ # text to be added to the email body.
374+ self.assertContentEqual([],
375+ self.getSubscriptionEmailBody())
376+
377+ def test_body_single(self):
378+ # A single filter with a description makes all emails
379+ # include that particular filter description in the body.
380+ bug_filter = self.addFilter(u"Test filter")
381+
382+ self.assertContentEqual([u"Matching filters: Test filter"],
383+ self.getSubscriptionEmailBody())
384+
385+ def test_body_multiple(self):
386+ # Multiple filters with description make all emails
387+ # include them in the email body.
388+ bug_filter = self.addFilter(u"First filter")
389+ bug_filter = self.addFilter(u"Second filter")
390+
391+ self.assertContentEqual(
392+ [u"Matching filters: First filter, Second filter"],
393+ self.getSubscriptionEmailBody())

Subscribers

People subscribed via source and target branches

to status/vote changes: