~launchpad-pqm/launchpad/devel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
Answer Tracker Email Interface
==============================

The Answer Tracker has an email interface, although it's quite limited
at the moment. The only thing you can do is post new messages on the
question. This is an important feature, though, since it ensures that if
a user decides to reply to a question notification, his email won't be
lost, it will be added to the question.

Incoming emails for the Answer Tracker are processed by the
AnswerTrackerHandler.

    # Define a time generator to ensure ordering of the messages. That is
    # necessary because the date of the messages created from an email has
    # only resolution to the second whereas the ones created by the DB API
    # have microseconds resolution. This means that it would be possible
    # for a message created using the DB API before one created by
    # the email interface to sort after.
    >>> from datetime import datetime, timedelta
    >>> from pytz import UTC
    >>> def now_generator(now_ref):
    ...     now = now_ref
    ...     while True:
    ...         yield now
    ...         now += timedelta(seconds=1)

    # We are using a date in the past because MessageSet disallows the
    # creation of email message with a future date.
    >>> now = now_generator(datetime.now(UTC) - timedelta(hours=24))

    # Define a helper function to send email to the Answer Tracker handler.
    >>> from lp.answers.mail.handler import AnswerTrackerHandler
    >>> from email.Utils import formatdate, make_msgid, mktime_tz
    >>> from lp.services.mail.signedmessage import signed_message_from_string
    >>> handler = AnswerTrackerHandler()
    >>> def send_question_email(question_id, from_addr, subject, body):
    ...     login(from_addr)
    ...     lines = ['From: %s' % from_addr]
    ...     to_addr = 'question%s@answers.launchpad.net' % question_id
    ...     lines.append('To: %s' % to_addr)
    ...     date = mktime_tz(now.next().utctimetuple() + (0, ))
    ...     lines.append('Date: %s' % formatdate(date))
    ...     msgid = make_msgid()
    ...     lines.append('Message-Id: %s' % msgid)
    ...     lines.append('Subject: %s' % subject)
    ...     lines.append('')
    ...     lines.append(body)
    ...     raw_msg = '\n'.join(lines)
    ...     msg = signed_message_from_string(raw_msg)
    ...     if handler.process(msg, msg['To']):
    ...         # Ensures that the DB user has the correct permission to \
    ...         # saves the changes.
    ...         flush_database_updates()
    ...         return msgid
    ...     else:
    ...         return None

It only processes emails which are sent to an address of the form
'question<ID>@answers.launchpad.net', where <ID> is the question id. (The
domain is configured through the config.answertracker.email_domain
configuration variable.)

All other email addresses are ignored:

    >>> raw_msg = """From: test@canonical.com
    ... To: foo@support.launchpad.net
    ... Subject: Hello
    ...
    ... Hello there."""
    >>> msg = signed_message_from_string(raw_msg)
    >>> handler.process(msg, msg['To'])
    False


The message will also be ignored if no question with the addressed ID
can be found:

    >>> comment_msgid = send_question_email(
    ...     1234, 'foo.bar@canonical.com', 'Hey', 'This is another comment.')
    >>> comment_msgid is None
    True

Incoming Email and Workflow
---------------------------

With the way the Answer Tracker workflow is modelled (see
answer-tracker-workflow.txt for the details), adding a message will
usually also change the status of the question. But currently, there is
no way to specify the exact workflow action accomplished by a given
message. (That will probably change in the near future when we add the
possibility to embed commands in the message body.) So, a default action
is chosen based on who is sending the message and the current state of
the question. There is the possibility that the default action is wrong,
but we chose the defaults based on what we assume is the common case
and by trying to minimize the impact of that error on future
possibilities for the user.

    # We will use a new question on the Ubuntu distribution in these
    # examples. We also use two actors, No Privileges Person which will
    # be the question owner and Sample Person who will play the role of
    # answer contact. Foo Bar is used to change the status of the
    # question.
    >>> from lp.registry.interfaces.distribution import IDistributionSet
    >>> from lp.registry.interfaces.person import IPersonSet
    >>> login('no-priv@canonical.com')
    >>> personset = getUtility(IPersonSet)
    >>> sample_person = personset.getByEmail('test@canonical.com')
    >>> no_priv = personset.getByEmail('no-priv@canonical.com')
    >>> foo_bar = personset.getByEmail('foo.bar@canonical.com')

    >>> import transaction
    >>> from lp.services.config import config
    >>> from lp.testing.layers import LaunchpadZopelessLayer

    >>> LaunchpadZopelessLayer.switchDbUser('launchpad')
    >>> ubuntu = getUtility(IDistributionSet)['ubuntu']
    >>> question = ubuntu.newQuestion(
    ...     no_priv, 'Unable to boot installer',
    ...     "I've tried installing Ubuntu on a Mac. But the installer never "
    ...     "boots.", datecreated=now.next())
    >>> question_id = question.id
    >>> transaction.commit()
    >>> LaunchpadZopelessLayer.switchDbUser(config.processmail.dbuser)

    # We need to refetch the question, since a new transaction was started.
    >>> from lp.answers.interfaces.questioncollection import IQuestionSet
    >>> question = getUtility(IQuestionSet).get(question_id)

    # Define an helper to change the question status easily.
    >>> def setQuestionStatus(question, new_status):
    ...     login('foo.bar@canonical.com')
    ...     question.setStatus(foo_bar, new_status, 'Status Change',
    ...                            datecreated=now.next())
    ...     login('no-priv@canonical.com')

Message From the Question Owner
-------------------------------

When the owner sends a message on the question, the message is
interpretated in three different manners based on the current question
state.

Open and Needs Information
..........................

In the Open and Needs Information states, we assume the message provides
more information on the problem.

For example, from the Open state:

    >>> msgid = send_question_email(
    ...     question.id, 'no-priv@canonical.com', 'PowerMac 7200',
    ...     "I forgot to specify that I'm installing on a PowerMac 7200.")
    >>> message = question.messages[-1]
    >>> message.rfc822msgid == msgid
    True
    >>> print message.action.title
    Give more information
    >>> print message.subject
    PowerMac 7200
    >>> print message.text_contents
    I forgot to specify that I'm installing on a PowerMac 7200.
    >>> print message.owner.displayname
    No Privileges Person

And from the Needs information state:

    >>> from lp.answers.enums import QuestionStatus
    >>> setQuestionStatus(question, QuestionStatus.NEEDSINFO)

    >>> msgid =  send_question_email(
    ...     question.id, 'no-priv@canonical.com', 'Re: What model?',
    ...     'A PowerMac 7200.')
    >>> message = question.messages[-1]
    >>> message.rfc822msgid == msgid
    True
    >>> print message.action.title
    Give more information

In these states, the other possibility would be that the message is
really stating the owner solved his own problem. This is a less likely
scenario, since it would mean that the owner is replying to one of his
own message. And if that was the case, it is easy for the owner to
correct our bad decision, since the question will stay on his
list of open questions.

Answered and Expired
....................

When the question is in the Answered or Expired states, we assume that
the email is reopening the question with more information.

    >>> setQuestionStatus(question, QuestionStatus.ANSWERED)

    >>> msgid = send_question_email(
    ...     question.id, 'no-priv@canonical.com', 'Re: BootX',
    ...     "I installed BootX, but I must have made a mistake somewhere "
    ...     "because it still doesn't boot. I have a dialog which says "
    ...     "cannot find any kernel images.")
    >>> message = question.messages[-1]
    >>> message.rfc822msgid == msgid
    True
    >>> print message.action.title
    Reopen

From the Open state, the other possibilities for the owner email would
be that it was confirming that the provided answer work. We minimize the
chance of this happening by adding an explanation message in the footer
of the notification containing the answer. The other possibility is that
the user sent a message to explain that he solved his problem. We do
support this use case yet.

From the Expired state:

    >>> setQuestionStatus(question, QuestionStatus.EXPIRED)

    >>> msgid =  send_question_email(
    ...     question.id, 'no-priv@canonical.com', 'Need Help',
    ...     "I still cannot install on my PowerMac.")
    >>> message = question.messages[-1]
    >>> message.rfc822msgid == msgid
    True
    >>> print message.action.title
    Reopen

From the Expired state, the other possibility is the less probable
explaining that the owner solved his problem. Again, to minimize
confusion, the outoing notification contain a footer explaining what
will happen if one reply to the message.

Solved and Invalid
..................

When the question is in the Solved or Invalid state, we interpret the
message as a comment.

    >>> setQuestionStatus(question, QuestionStatus.SOLVED)

    >>> msgid =  send_question_email(
    ...     question.id, 'no-priv@canonical.com', "Thanks",
    ...     "Thanks for helping me make BootX work.")
    >>> message = question.messages[-1]
    >>> message.rfc822msgid == msgid
    True
    >>> print message.action.title
    Comment

The other alternative is that the owner wanted to reopen the question.
But it is more likely that an email after he marked the problem as
solved would come as a reply to another comment, so it is safer to
assume it was a comment.

And from the Invalid:

    >>> setQuestionStatus(question, QuestionStatus.INVALID)

    >>> msgid =  send_question_email(
    ...     question.id, 'no-priv@canonical.com', 'Come on!',
    ...     "Trying to install on an old machine shouldn't be considered "
    ...     "an invalid question!")
    >>> message = question.messages[-1]
    >>> message.rfc822msgid == msgid
    True
    >>> print message.action.title
    Comment

That is the only possibility on an Invalid question. From the 'Invalid'
state, there is no normal transition. The only possibility is that an
admin comes to change the status of the question.

Message From Another User
.........................

It is simpler when a user other than the owner sends an email. When
the question is in the Open or Needs information state, there are only
two choices: either a question for more information or an answer. We
will assume it is an answer because it gives the opportunity for the
owner to confirm that the problem is solved. If it was really a question
for more information, the user can reply and the resulting state will be
fine. So it is the safest thing to assume.

    >>> setQuestionStatus(question, QuestionStatus.OPEN)

    >>> msgid =  send_question_email(
    ...     question.id, 'test@canonical.com', 'BootX',
    ...     "You need to install and configure BootX to boot the installer "
    ...     "CD.")
    >>> message = question.messages[-1]
    >>> message.rfc822msgid == msgid
    True
    >>> print message.action.title
    Answer
    >>> print message.owner.displayname
    Sample Person

Needs information example:

    >>> setQuestionStatus(question, QuestionStatus.NEEDSINFO)

    >>> msgid =  send_question_email(
    ...     question.id, 'test@canonical.com', 'What model?',
    ...     "What Mac model are you trying to install on?")
    >>> message = question.messages[-1]
    >>> message.rfc822msgid == msgid
    True
    >>> print message.action.title
    Answer

Answered example:

    >>> print question.status.title
    Answered

    >>> msgid =  send_question_email(
    ...     question.id, 'test@canonical.com', 'More info on BootX',
    ...     "You can find instructions on BootX installation at that URL: "
    ...     "https://help.ubuntu.com/community/Installation/OldWorldMacs")
    >>> message = question.messages[-1]
    >>> message.rfc822msgid == msgid
    True
    >>> print message.action.title
    Answer


Solved, Invalid and Expired
...........................

When another user than the owner sends a message to a question
in the Solved, Invalid or Expired states, the only possible
interpretation is that it is a comment.

    >>> setQuestionStatus(question, QuestionStatus.SOLVED)

    >>> msgid =  send_question_email(
    ...     question.id, 'test@canonical.com', 'RAM',
    ...     "You will probably need to install some RAM to make this usable "
    ...     "though.")
    >>> message = question.messages[-1]
    >>> message.rfc822msgid == msgid
    True
    >>> print message.action.title
    Comment

    >>> setQuestionStatus(question, QuestionStatus.EXPIRED)

    >>> msgid =  send_question_email(
    ...     question.id, 'test@canonical.com', 'How weird',
    ...     "Is somebody really trying to install Ubuntu on such obsolete "
    ...     "hardware?")
    >>> message = question.messages[-1]
    >>> message.rfc822msgid == msgid
    True
    >>> print message.action.title
    Comment

    >>> setQuestionStatus(question, QuestionStatus.INVALID)

    >>> msgid =  send_question_email(
    ...     question.id, 'test@canonical.com', 'Error?',
    ...     "I think the rejection was an error.")
    >>> message = question.messages[-1]
    >>> message.rfc822msgid == msgid
    True
    >>> print message.action.title
    Comment
    >>> transaction.abort()


Answers linked to FAQ questions
...............................

Answers may also be linked to FAQ questions.

    >>> LaunchpadZopelessLayer.switchDbUser('launchpad')

    >>> from zope.security.proxy import removeSecurityProxy
    >>> login('foo.bar@canonical.com')
    >>> faq = question.target.newFAQ(
    ...     no_priv, 'Why everyone think this is weird.',
    ...     "That's an easy one. It's because it is!")
    >>> removeSecurityProxy(question).faq = faq
    >>> transaction.commit()

    >>> LaunchpadZopelessLayer.switchDbUser(config.processmail.dbuser)
    >>> login('no-priv@canonical.com')

    # Make sure that the database security and permissions are set up
    # correctly for answers that link to FAQs.  If they are not, then
    # this will raise an error; See bug #196661.
    >>> msgid =  send_question_email(
    ...     question.id, 'test@canonical.com', 'Fnord',
    ...     "You will probably need to install some RAM to see the fnords.")
    >>> message = question.messages[-1]
    >>> message.rfc822msgid == msgid
    True
    >>> print message.action.title
    Answer


AnswerTrackerHandler Integration
--------------------------------

The general mail processor delegates all emails to the
config.answertracker.email_domain to the AnswerTrackerHandler.

    >>> raw_msg = """From: test@canonical.com
    ... X-Launchpad-Original-To: question1@answers.launchpad.net
    ... Subject: A new comment
    ... Message-Id: <comment1@localhost>
    ... Date: Mon, 02 Jan 2006 15:42:07 -0000
    ...
    ... This is a new comment.
    ... """
    >>> from lp.services.mail import stub

    # Clear email queue of outgoing notifications.
    >>> stub.test_emails = []
    >>> stub.test_emails.append((
    ...     'test@canonical.com', ['question1@answers.launchpad.net'],
    ...     raw_msg))

    >>> from lp.services.mail.incoming import handleMail
    >>> handleMail()

    >>> question_one = getUtility(IQuestionSet).get(1)
    >>> '<comment1@localhost>' in [
    ...     comment.rfc822msgid for comment in question_one.messages]
    True

For backward compatibility with notifications sent before the support
tracker was renamed to Answer Tracker, we still accept emails sent
to the old ticket<ID>@support.launchpad.net address:

    >>> raw_msg = """From: test@canonical.com
    ... X-Launchpad-Original-To: ticket11@support.launchpad.net
    ... Subject: Another comment
    ... Message-Id: <comment2@localhost>
    ... Date: Mon, 23 Apr 2007 16:00:00 -0000
    ...
    ... This is another comment.
    ... """
    >>> stub.test_emails.append((
    ...     'test@canonical.com', ['ticket11@support.launchpad.net'],
    ...     raw_msg))
    >>> handleMail()

    >>> question_11 = getUtility(IQuestionSet).get(11)
    >>> '<comment2@localhost>' in [
    ...     comment.rfc822msgid for comment in question_11.messages]
    True