Merge lp:~sinzui/launchpad/bugtask-create-question-1 into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Approved by: Brad Crittenden
Approved revision: no longer in the source branch.
Merged at revision: 12914
Proposed branch: lp:~sinzui/launchpad/bugtask-create-question-1
Merge into: lp:launchpad
Diff against target: 291 lines (+115/-27)
6 files modified
lib/lp/answers/doc/notifications.txt (+8/-4)
lib/lp/answers/model/question.py (+12/-7)
lib/lp/answers/notification.py (+17/-10)
lib/lp/answers/tests/test_question_notifications.py (+32/-1)
lib/lp/answers/tests/test_questiontarget.py (+42/-1)
lib/lp/bugs/tests/test_bugtarget.py (+4/-4)
To merge this branch: bzr merge lp:~sinzui/launchpad/bugtask-create-question-1
Reviewer Review Type Date Requested Status
Brad Crittenden (community) code Approve
Review via email: mp+58826@code.launchpad.net

Description of the change

Send the first and last messages only when converting a bug to a question.

    Launchpad bugs:
        https://bugs.launchpad.net/bugs/438116
        https://bugs.launchpad.net/bugs/247685
        https://bugs.launchpad.net/bugs/304960
    Pre-implementation: jcsackett, lifeless

BugTask:+create-question times out:
There are two principle issues described in this bug: sending multiple emails
to users, and sending them in proc. The later issue is also the cause of bug
608037
, bug 618390, and bug 618385. Fixing this bug will only be about
addressing the unpredictable number of messages.

While reading the CreateQuestionFromBug() I saw two places that are creating
more emails than we want. I also saw that there are two other bugs that
can be fixed while tuning this method.

--------------------------------------------------------------------

RULES

bugs #438116 "Timeout when converting bug into question "
    * Convert only the last bug message to a question message.
    * Do not send emails about the implicit bug link (create it directly?)

bug #247685 "Email for a question converted from a bug has the wrong sender"
    * Ensure the sender of the first message is the user who actually sent
      the message. The sender comes from the user in the event...this
      needs to be corrected.

bug #304960 "Bug converted to Question lost subscribers"
    * Ensure direct subscribers to the bug are added as direct subscribers
      to the question.

QA

    * Convert a bug with more than two comments and at least two subscribers
      to a question.
    * Verify each contact gets two messages, the first from the user
      who reported the bug, and a second from the user who converted the
      bug to a question.
    * Verify the question has the same direct subscribers as the bug.

LINT

    lib/lp/answers/notification.py
    lib/lp/answers/model/question.py
    lib/lp/answers/tests/test_question_notifications.py
    lib/lp/answers/tests/test_questiontarget.py
    lib/lp/bugs/tests/test_bugtarget.py

TEST

    ./bin/test -vv -t TestQuestionTargetCreateQuestionFromBug \
        -t QuestionCreatedTestCase -t bugtarget-questiontarget

IMPLEMENTATION

The bug link is directly created instead of using the helper method. The
method sends an duplicate email about the bug to the answer contacts, and
it is subscribing the question owner to a bug hie is already subscribed too.
The message loop is replaced with a single message pulled from the end of
the bug messages. The direct bug subscribers are subscribed to the question
so that they can learn the answer.
    lib/lp/answers/model/question.py
    lib/lp/answers/tests/test_questiontarget.py

Added a property to select the correct user when sending emails. Since
Questions can be created for users, the question owner is the real sender
of the email (bug #247685). The test is somewhat arcane. The use of fakes
allows the tests to run on the lowest layer and does not send emails.
    lib/lp/answers/notification.py
    lib/lp/answers/tests/test_question_notifications.py

Run the bugtarget doctests on the correct layer.
    lib/lp/bugs/tests/test_bugtarget.py

To post a comment you must log in.
Revision history for this message
Brad Crittenden (bac) wrote :

Hi Curtis,

Thanks for tackling this really old problem. Our users will be glad to get this fixed.

* s/recieve/receive

The tests look good and the code makes sense.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/lp/answers/doc/notifications.txt'
--- lib/lp/answers/doc/notifications.txt 2011-03-23 16:28:51 +0000
+++ lib/lp/answers/doc/notifications.txt 2011-04-22 21:17:28 +0000
@@ -852,15 +852,19 @@
852 >>> bug_question = ubuntu.createQuestionFromBug(bug)852 >>> bug_question = ubuntu.createQuestionFromBug(bug)
853 >>> notifications = pop_notifications()853 >>> notifications = pop_notifications()
854 >>> len(notifications)854 >>> len(notifications)
855 2855 4
856856
857 >>> [email_msg['To'] for email_msg in notifications]857 >>> [email_msg['To'] for email_msg in notifications]
858 ['no-priv@canonical.com', 'support@ubuntu.com']858 ['no-priv@canonical.com', 'no-priv@canonical.com',
859 'support@ubuntu.com', 'support@ubuntu.com']
860
861 >>> notifications[0]['Subject']
862 '[Question #...]: Installer fails on a Mac PPC'
859863
860 >>> notifications[1]['Subject']864 >>> notifications[1]['Subject']
861 '[Question #...]: Installer fails on a Mac PPC'865 'Re: [Question #...]: Installer fails on a Mac PPC'
862866
863 >>> notification_body = notifications[1].get_payload(decode=True)867 >>> notification_body = notifications[0].get_payload(decode=True)
864 >>> print notification_body868 >>> print notification_body
865 New question #... on Ubuntu:869 New question #... on Ubuntu:
866 http://answers.launchpad.dev/ubuntu/+question/...870 http://answers.launchpad.dev/ubuntu/+question/...
867871
=== modified file 'lib/lp/answers/model/question.py'
--- lib/lp/answers/model/question.py 2011-04-21 21:14:50 +0000
+++ lib/lp/answers/model/question.py 2011-04-22 21:17:28 +0000
@@ -1190,13 +1190,18 @@
1190 # Give the datelastresponse a current datetime, otherwise the1190 # Give the datelastresponse a current datetime, otherwise the
1191 # Launchpad Janitor would quickly expire questions made from old bugs.1191 # Launchpad Janitor would quickly expire questions made from old bugs.
1192 question.datelastresponse = datetime.now(pytz.timezone('UTC'))1192 question.datelastresponse = datetime.now(pytz.timezone('UTC'))
1193 question.linkBug(bug)1193 # Directly create the BugLink so that users do not receive duplicate
1194 for message in bug.messages[1:]:1194 # messages about the bug.
1195 # Bug.message[0] is the original message, and probably a duplicate1195 question.createBugLink(bug)
1196 # of Bug.description.1196 # Copy the last message that explains why the bug is a question.
1197 question.addComment(1197 message = bug.messages[-1]
1198 message.owner, message.text_contents,1198 question.addComment(
1199 datecreated=message.datecreated)1199 message.owner, message.text_contents,
1200 datecreated=message.datecreated)
1201 # Direct subscribers to the bug want to know the question answer.
1202 for subscriber in bug.getDirectSubscribers():
1203 if subscriber != question.owner:
1204 question.subscribe(subscriber)
1200 return question1205 return question
12011206
1202 def getQuestion(self, question_id):1207 def getQuestion(self, question_id):
12031208
=== modified file 'lib/lp/answers/notification.py'
--- lib/lp/answers/notification.py 2010-08-24 10:45:57 +0000
+++ lib/lp/answers/notification.py 2011-04-22 21:17:28 +0000
@@ -49,11 +49,16 @@
49 """49 """
50 self.question = question50 self.question = question
51 self.event = event51 self.event = event
52 self.user = IPerson(self.event.user)52 self._user = IPerson(self.event.user)
53 self.initialize()53 self.initialize()
54 if self.shouldNotify():54 if self.shouldNotify():
55 self.send()55 self.send()
5656
57 @property
58 def user(self):
59 """Return the user from the event. """
60 return self._user
61
57 def getFromAddress(self):62 def getFromAddress(self):
58 """Return a formatted email address suitable for user in the From63 """Return a formatted email address suitable for user in the From
59 header of the question notification.64 header of the question notification.
@@ -180,6 +185,15 @@
180class QuestionAddedNotification(QuestionNotification):185class QuestionAddedNotification(QuestionNotification):
181 """Notification sent when a question is added."""186 """Notification sent when a question is added."""
182187
188 @property
189 def user(self):
190 """Return the question owner.
191
192 Questions can be created by other users for the owner; the
193 question is from the owner.
194 """
195 return self.question.owner
196
183 def getBody(self):197 def getBody(self):
184 """See QuestionNotification."""198 """See QuestionNotification."""
185 question = self.question199 question = self.question
@@ -222,7 +236,7 @@
222 """Textual representation of the changes to the question metadata."""236 """Textual representation of the changes to the question metadata."""
223 question = self.question237 question = self.question
224 old_question = self.old_question238 old_question = self.old_question
225 indent = 4*' '239 indent = 4 * ' '
226 info_fields = []240 info_fields = []
227 if question.status != old_question.status:241 if question.status != old_question.status:
228 info_fields.append(indent + 'Status: %s => %s' % (242 info_fields.append(indent + 'Status: %s => %s' % (
@@ -306,10 +320,6 @@
306 # The first message cannot contain a References320 # The first message cannot contain a References
307 # because we don't create a Message instance for the321 # because we don't create a Message instance for the
308 # question description, so we don't have a Message-ID.322 # question description, so we don't have a Message-ID.
309
310 # XXX sinzui 2007-02-01 bug=164435:
311 # Added an assert to gather better Opps information about
312 # the state of the messages.
313 messages = list(self.question.messages)323 messages = list(self.question.messages)
314 assert self.new_message in messages, (324 assert self.new_message in messages, (
315 "Question %s: message id %s not in %s." % (325 "Question %s: message id %s not in %s." % (
@@ -318,7 +328,7 @@
318 index = messages.index(self.new_message)328 index = messages.index(self.new_message)
319 if index > 0:329 if index > 0:
320 headers['References'] = (330 headers['References'] = (
321 self.question.messages[index-1].rfc822msgid)331 self.question.messages[index - 1].rfc822msgid)
322 return headers332 return headers
323333
324 def shouldNotify(self):334 def shouldNotify(self):
@@ -352,7 +362,6 @@
352 """362 """
353 original_recipients = QuestionNotification.getRecipients(self)363 original_recipients = QuestionNotification.getRecipients(self)
354 recipients = NotificationRecipientSet()364 recipients = NotificationRecipientSet()
355 owner = self.question.owner
356 for person in original_recipients:365 for person in original_recipients:
357 if person != self.question.owner:366 if person != self.question.owner:
358 rationale, header = original_recipients.getReason(person)367 rationale, header = original_recipients.getReason(person)
@@ -471,5 +480,3 @@
471 'question_url': canonical_url(question),480 'question_url': canonical_url(question),
472 'question_language': question.language.englishname,481 'question_language': question.language.englishname,
473 'comment': question.description}482 'comment': question.description}
474
475
476483
=== modified file 'lib/lp/answers/tests/test_question_notifications.py'
--- lib/lp/answers/tests/test_question_notifications.py 2010-07-18 00:21:11 +0000
+++ lib/lp/answers/tests/test_question_notifications.py 2011-04-22 21:17:28 +0000
@@ -9,7 +9,10 @@
99
10from zope.interface import implements10from zope.interface import implements
1111
12from lp.answers.notification import QuestionModifiedDefaultNotification12from lp.answers.notification import (
13 QuestionAddedNotification,
14 QuestionModifiedDefaultNotification,
15 )
13from lp.registry.interfaces.person import IPerson16from lp.registry.interfaces.person import IPerson
1417
1518
@@ -35,6 +38,7 @@
35 def __init__(self, id=1, title="Question title"):38 def __init__(self, id=1, title="Question title"):
36 self.id = id39 self.id = id
37 self.title = title40 self.title = title
41 self.owner = FakeUser()
3842
3943
40class StubQuestionMessage:44class StubQuestionMessage:
@@ -109,3 +113,30 @@
109 self.assertEquals(113 self.assertEquals(
110 'Re: [Question #1]: Message subject',114 'Re: [Question #1]: Message subject',
111 self.notification.getSubject())115 self.notification.getSubject())
116
117 def test_user_is_event_user(self):
118 """The notification user is always the event user."""
119 question = StubQuestion()
120 event = FakeEvent()
121 notification = TestQuestionModifiedNotification(question, event)
122 self.assertEqual(event.user, notification.user)
123 self.assertNotEqual(question.owner, notification.user)
124
125
126class TestQuestionAddedNotification(QuestionAddedNotification):
127 """A subclass that does not send emails."""
128
129 def shouldNotify(self):
130 return False
131
132
133class QuestionCreatedTestCase(TestCase):
134 """Test cases for mail notifications about created questions."""
135
136 def test_user_is_question_owner(self):
137 """The notification user is always the question owner."""
138 question = StubQuestion()
139 event = FakeEvent()
140 notification = TestQuestionAddedNotification(question, event)
141 self.assertEqual(question.owner, notification.user)
142 self.assertNotEqual(event.user, notification.user)
112143
=== modified file 'lib/lp/answers/tests/test_questiontarget.py'
--- lib/lp/answers/tests/test_questiontarget.py 2011-04-21 21:22:16 +0000
+++ lib/lp/answers/tests/test_questiontarget.py 2011-04-22 21:17:28 +0000
@@ -14,7 +14,10 @@
14from canonical.testing.layers import DatabaseFunctionalLayer14from canonical.testing.layers import DatabaseFunctionalLayer
15from lp.registry.interfaces.distribution import IDistributionSet15from lp.registry.interfaces.distribution import IDistributionSet
16from lp.services.worlddata.interfaces.language import ILanguageSet16from lp.services.worlddata.interfaces.language import ILanguageSet
17from lp.testing import TestCaseWithFactory17from lp.testing import (
18 person_logged_in,
19 TestCaseWithFactory,
20 )
1821
1922
20class TestQuestionTarget_answer_contacts_with_languages(TestCaseWithFactory):23class TestQuestionTarget_answer_contacts_with_languages(TestCaseWithFactory):
@@ -74,3 +77,41 @@
74 lang.englishname for lang in answer_contact.getLanguagesCache()]77 lang.englishname for lang in answer_contact.getLanguagesCache()]
75 # The languages cache has been filled in the correct order.78 # The languages cache has been filled in the correct order.
76 self.failUnlessEqual(langs, [u'English', u'Portuguese (Brazil)'])79 self.failUnlessEqual(langs, [u'English', u'Portuguese (Brazil)'])
80
81
82class TestQuestionTargetCreateQuestionFromBug(TestCaseWithFactory):
83 """Test the createQuestionFromBug from bug behavior."""
84
85 layer = DatabaseFunctionalLayer
86
87 def setUp(self):
88 super(TestQuestionTargetCreateQuestionFromBug, self).setUp()
89 self.bug = self.factory.makeBug(description="first comment")
90 self.target = self.bug.bugtasks[0].target
91 self.contributor = self.target.owner
92 self.reporter = self.bug.owner
93
94 def test_first_and_last_messages_copied_to_question(self):
95 # The question is created with the bug's description and the last
96 # message which presumably is about why the bug was converted.
97 with person_logged_in(self.reporter):
98 self.bug.newMessage(owner=self.reporter, content='second comment')
99 with person_logged_in(self.contributor):
100 last_message = self.bug.newMessage(
101 owner=self.contributor, content='third comment')
102 question = self.target.createQuestionFromBug(self.bug)
103 question_messages = list(question.messages)
104 self.assertEqual(1, len(question_messages))
105 self.assertEqual(last_message.content, question_messages[0].content)
106 self.assertEqual(self.bug.description, question.description)
107
108 def test_bug_subscribers_copied_to_question(self):
109 # Users who subscribe to the bug are also interested in the answer.
110 subscriber = self.factory.makePerson()
111 with person_logged_in(subscriber):
112 self.bug.subscribe(subscriber, subscriber)
113 with person_logged_in(self.contributor):
114 self.bug.newMessage(owner=self.contributor, content='comment')
115 question = self.target.createQuestionFromBug(self.bug)
116 self.assertTrue(question.isSubscribed(subscriber))
117 self.assertTrue(question.isSubscribed(question.owner))
77118
=== modified file 'lib/lp/bugs/tests/test_bugtarget.py'
--- lib/lp/bugs/tests/test_bugtarget.py 2010-11-18 14:00:49 +0000
+++ lib/lp/bugs/tests/test_bugtarget.py 2011-04-22 21:17:28 +0000
@@ -28,7 +28,7 @@
28 tearDown,28 tearDown,
29 )29 )
30from canonical.launchpad.webapp.interfaces import ILaunchBag30from canonical.launchpad.webapp.interfaces import ILaunchBag
31from canonical.testing.layers import LaunchpadFunctionalLayer31from canonical.testing.layers import DatabaseFunctionalLayer
32from lp.bugs.interfaces.bug import CreateBugParams32from lp.bugs.interfaces.bug import CreateBugParams
33from lp.bugs.interfaces.bugtask import (33from lp.bugs.interfaces.bugtask import (
34 BugTaskSearchParams,34 BugTaskSearchParams,
@@ -191,7 +191,7 @@
191class TestBugTargetSearchTasks(TestCaseWithFactory):191class TestBugTargetSearchTasks(TestCaseWithFactory):
192 """Tests of IHasBugs.searchTasks()."""192 """Tests of IHasBugs.searchTasks()."""
193193
194 layer = LaunchpadFunctionalLayer194 layer = DatabaseFunctionalLayer
195195
196 def setUp(self):196 def setUp(self):
197 super(TestBugTargetSearchTasks, self).setUp()197 super(TestBugTargetSearchTasks, self).setUp()
@@ -299,7 +299,7 @@
299 for setUpMethod in setUpMethods:299 for setUpMethod in setUpMethods:
300 test = LayeredDocFileSuite('bugtarget-questiontarget.txt',300 test = LayeredDocFileSuite('bugtarget-questiontarget.txt',
301 setUp=setUpMethod, tearDown=tearDown,301 setUp=setUpMethod, tearDown=tearDown,
302 layer=LaunchpadFunctionalLayer)302 layer=DatabaseFunctionalLayer)
303 suite.addTest(test)303 suite.addTest(test)
304304
305 setUpMethods.remove(sourcePackageForQuestionSetUp)305 setUpMethods.remove(sourcePackageForQuestionSetUp)
@@ -309,7 +309,7 @@
309 for setUpMethod in setUpMethods:309 for setUpMethod in setUpMethods:
310 test = LayeredDocFileSuite('bugtarget-bugcount.txt',310 test = LayeredDocFileSuite('bugtarget-bugcount.txt',
311 setUp=setUpMethod, tearDown=tearDown,311 setUp=setUpMethod, tearDown=tearDown,
312 layer=LaunchpadFunctionalLayer)312 layer=DatabaseFunctionalLayer)
313 suite.addTest(test)313 suite.addTest(test)
314314
315 suite.addTest(unittest.TestLoader().loadTestsFromName(__name__))315 suite.addTest(unittest.TestLoader().loadTestsFromName(__name__))