~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
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
=======================
Answer tracker workflow
=======================

The state of a question is tracked through its status, which model a
question's lifecycle.  These are defined in the QuestionStatus enumeration.

    >>> from lp.answers.enums import QuestionStatus
    >>> for status in QuestionStatus.items:
    ...     print status.name
    OPEN
    NEEDSINFO
    ANSWERED
    SOLVED
    EXPIRED
    INVALID

Status change occurs as a consequence of a user's action.  The possible
actions are defined in the QuestionAction enumeration.

    >>> from lp.answers.enums import QuestionAction
    >>> for status in QuestionAction.items:
    ...     print status.name
    REQUESTINFO
    GIVEINFO
    COMMENT
    ANSWER
    CONFIRM
    REJECT
    EXPIRE
    REOPEN
    SETSTATUS

Each defined action can be executed.

No Privileges Person is the submitter of questions.  Sample Person is an
answer contact for the Ubuntu distribution.  Marilize Coetze is another user
providing support.  Stub is a Launchpad administrator that isn't also in the
Ubuntu Team owning the distribution.

    >>> login('no-priv@canonical.com')

    >>> from lp.registry.interfaces.distribution import IDistributionSet
    >>> from lp.registry.interfaces.person import IPersonSet
    >>> from lp.services.worlddata.interfaces.language import ILanguageSet

    >>> personset = getUtility(IPersonSet)
    >>> sample_person = personset.getByEmail('test@canonical.com')
    >>> no_priv = personset.getByEmail('no-priv@canonical.com')
    >>> marilize = personset.getByEmail('marilize@hbd.com')
    >>> stub = personset.getByName('stub')

    >>> ubuntu = getUtility(IDistributionSet)['ubuntu']
    >>> english = getUtility(ILanguageSet)['en']
    >>> sample_person.addLanguage(english)
    >>> ubuntu.addAnswerContact(sample_person, sample_person)
    True

    # Sanity check: the admin isn't in the team owning the distribution.
    >>> stub.inTeam(ubuntu.owner)
    False

A question starts its lifecycle in the Open state.

    >>> from datetime import datetime, timedelta
    >>> from pytz import UTC
    >>> now = datetime.now(UTC)
    >>> new_question_args = dict(
    ...     owner=no_priv,
    ...     title='Unable to boot installer',
    ...     description="I've tried installing Ubuntu on a Mac. "
    ...                 "But the installer never boots.",
    ...     datecreated=now,
    ...     )
    >>> question = ubuntu.newQuestion(**new_question_args)
    >>> print question.status.title
    Open

The following scenarios are now possible.


1) Another user helps the submitter with his question
=====================================================

The most common scenario is where another user comes to help the submitter and
answers his question.  This may involve exchanging information with the
submitter to clarify the question.

The requestInfo() method is used to ask the user for more information.  This
method takes two mandatory parameters: the user asking the question and his
question.  It can also takes a 'datecreated' parameter specifying the creation
date of the question (which defaults to 'now').

    >>> question = ubuntu.newQuestion(**new_question_args)
    >>> now_plus_one_hour = now + timedelta(hours=1)
    >>> request_message = question.requestInfo(
    ...     sample_person, 'What is your Mac model?',
    ...     datecreated=now_plus_one_hour)

We now have the IQuestionMessage that was added to the question messages
history.

    >>> from lp.services.webapp.testing import verifyObject
    >>> from lp.answers.interfaces.questionmessage import IQuestionMessage
    >>> verifyObject(IQuestionMessage, request_message)
    True
    >>> request_message == question.messages[-1]
    True
    >>> request_message.datecreated == now_plus_one_hour
    True
    >>> print request_message.owner.displayname
    Sample Person

The question message contains the action that was executed and the status of
the question after the action was executed.

    >>> print request_message.action.name
    REQUESTINFO
    >>> print request_message.new_status.name
    NEEDSINFO

    >>> print request_message.text_contents
    What is your Mac model?

The subject of the message was generated automatically.

    >>> print request_message.subject
    Re: Unable to boot installer

The question is moved to the NEEDSINFO state and the last response date is
updated to the message's timestamp.

    >>> print question.status.name
    NEEDSINFO
    >>> question.datelastresponse == now_plus_one_hour
    True

The question owner can reply to this information by using the giveInfo()
method which adds an IQuestionMessage with action GIVEINFO.

    >>> login('no-priv@canonical.com')
    >>> now_plus_two_hours = now + timedelta(hours=2)
    >>> reply_message = question.giveInfo(
    ...     "I have a PowerMac 7200.", datecreated=now_plus_two_hours)

    >>> print reply_message.action.name
    GIVEINFO
    >>> print reply_message.new_status.name
    OPEN
    >>> reply_message == question.messages[-1]
    True
    >>> print reply_message.owner.displayname
    No Privileges Person

The question is moved back to the OPEN state and the last query date is
updated to the message's creation date.

    >>> print question.status.name
    OPEN
    >>> question.datelastquery == now_plus_two_hours
    True

Now, the other user has enough information to give an answer to the question.
The giveAnswer() method is used for that purpose.  Like the requestInfo()
method, it takes two mandatory parameters: the user providing the answer and
the answer itself.

    >>> login('test@canonical.com')
    >>> now_plus_three_hours = now + timedelta(hours=3)
    >>> answer_message = question.giveAnswer(
    ...     sample_person,
    ...     "You need some configuration on the Mac side "
    ...     "to boot the installer on that model. Consult "
    ...     "https://help.ubuntu.com/community/Installation/OldWorldMacs "
    ...     "for all the details.",
    ...     datecreated=now_plus_three_hours)
    >>> print answer_message.action.name
    ANSWER
    >>> print answer_message.new_status.name
    ANSWERED

The question's status is changed to ANSWERED and the last response date is
updated to contain the date of the message.

    >>> print question.status.name
    ANSWERED
    >>> question.datelastresponse == now_plus_three_hours
    True

At that point, the question is considered answered, but we don't have
feedback from the user on whether it solved his problem or not.  If it
doesn't, the user can reopen the question.

    >>> login('no-priv@canonical.com')
    >>> tomorrow = now + timedelta(days=1)
    >>> reopen_message = question.reopen(
    ...     "I installed BootX and I've progressed somewhat. I now get the "
    ...     "boot screen. But soon after the Ubuntu progress bar appears, I "
    ...     "get a OOM Killer message appearing on the screen.",
    ...      datecreated=tomorrow)
    >>> print reopen_message.action.name
    REOPEN
    >>> print reopen_message.new_status.name
    OPEN
    >>> print reopen_message.owner.displayname
    No Privileges Person

This moves back the question to the OPEN state and the last query date is
updated to the message's creation date.

    >>> print question.status.name
    OPEN
    >>> question.datelastquery == tomorrow
    True

Once again, an answer is given.

    >>> login('test@canonical.com')
    >>> tomorrow_plus_one_hour = tomorrow + timedelta(hours=1)
    >>> answer2_message = question.giveAnswer(
    ...     marilize,
    ...     "You probably do not have enough RAM to use the "
    ...     "graphical installer. You can try the alternate CD with the "
    ...     "text installer.")

The question is moved back to the ANSWERED state.

    >>> print question.status.name
    ANSWERED

The question owner will hopefully come back to confirm that his problem is
solved.  He can specify which answer message helped him solved his problem.

    >>> login('no-priv@canonical.com')
    >>> two_weeks_from_now = now + timedelta(days=14)
    >>> confirm_message = question.confirmAnswer(
    ...     "I upgraded to 512M of RAM (found on eBay) and I've successfully "
    ...     "managed to install Ubuntu. Thanks for all the help.",
    ...     datecreated=two_weeks_from_now, answer=answer_message)
    >>> print confirm_message.action.name
    CONFIRM
    >>> print confirm_message.new_status.name
    SOLVED
    >>> print confirm_message.owner.displayname
    No Privileges Person

The question is moved to the SOLVED state, and the message that solved the
question is saved.  The date the question was solved and answerer are also
updated.

    >>> print question.status.name
    SOLVED
    >>> question.date_solved == two_weeks_from_now
    True
    >>> print question.answerer.displayname
    Sample Person
    >>> question.answer == answer_message
    True


2) Self-answering
=================

In this scenario the user comes back to give the solution to the question
himself.  The question owner can choose a best answer message later on.  The
workflow permits the question owner to choose an answer before or after the
question status is set to SOLVED.

A new question is posed.

    >>> question = ubuntu.newQuestion(**new_question_args)

The answer provides some useful information to the questioner.

    >>> login('test@canonical.com')
    >>> tomorrow_plus_one_hour = tomorrow + timedelta(hours=1)
    >>> alt_answer_message = question.giveAnswer(
    ...     marilize,
    ...     "Are you using a pre-G3 Mac? They are very difficult "
    ...     "to install to. You must mess with the hardware to trick "
    ...     "the core chips to let it install. You may not want to do this.")

The question has researched the problem, and has comes to a solution himself.

    >>> login('no-priv@canonical.com')
    >>> self_answer_message = question.giveAnswer(
    ...     no_priv,
    ...     "I found some instructions on the Wiki on how to "
    ...     "install BootX to boot the installation CD on OldWorld Mac: "
    ...     "https://help.ubuntu.com/community/Installation/OldWorldMacs "
    ...     "This is complicated and since it's a very old machine, not "
    ...     "worth the trouble.",
    ...     datecreated=now_plus_one_hour)

The question owner is considered to have given information that the problem is
solved and the question is moved to the SOLVED state.  The 'answerer'
will be the question owner.

    >>> print self_answer_message.action.name
    CONFIRM
    >>> print self_answer_message.new_status.name
    SOLVED

    >>> print question.status.name
    SOLVED
    >>> print question.answerer.displayname
    No Privileges Person
    >>> question.date_solved == now_plus_one_hour
    True
    >>> print question.answer
    None

The question owner can still specify which message helped him solved his
problem.  The confirmAnswer() method is used when the question owner chooses
another user's answer as a best answer.  The status will remain SOLVED.  The
'answerer' will be the message owner, and the 'answer' will be the message.
The question's solution date will be the date of the answer message.

    >>> confirm_message = question.confirmAnswer(
    ...     "Thanks Marilize for your help. I don't think I'll put Ubuntu "
    ...     "Ubuntu on my Mac.",
    ...     datecreated=now_plus_one_hour,
    ...     answer=alt_answer_message)
    >>> print confirm_message.action.name
    CONFIRM
    >>> print confirm_message.new_status.name
    SOLVED
    >>> print confirm_message.owner.displayname
    No Privileges Person

    >>> print question.status.name
    SOLVED
    >>> print question.answerer.displayname
    Marilize Coetzee
    >>> question.answer == alt_answer_message
    True
    >>> question.date_solved == now_plus_one_hour
    True


3) The question expires
=======================

It is also possible that nobody will answer the question, either because the
question is too complex or too vague.  These questions are expired by using
the expireQuestion() method.

    >>> login('no-priv@canonical.com')
    >>> question = ubuntu.newQuestion(**new_question_args)
    >>> expire_message = question.expireQuestion(
    ...     sample_person,
    ...     "There was no activity on this question for two "
    ...     "weeks and this question was expired. If you are still having "
    ...     "this problem you should reopen the question and provide more "
    ...     "information about your problem.",
    ...     datecreated=two_weeks_from_now)
    >>> print expire_message.action.name
    EXPIRE
    >>> print expire_message.new_status.name
    EXPIRED

The question is moved to the EXPIRED state and the last response date is
updated to the message creation date.

    >>> print question.status.name
    EXPIRED
    >>> question.datelastresponse == two_weeks_from_now
    True

If the user comes back and provide more information, the question will be
reopened.

    >>> much_later = now + timedelta(days=30)
    >>> reopen_message = question.reopen(
    ...     "I'm installing on PowerMac 7200/120 with 32 Megs of RAM. After "
    ...     "I insert the CD and restart the computer, it boots straight "
    ...     "into Mac OS/9 instead of booting the installer.",
    ...     datecreated=much_later)
    >>> print reopen_message.action.name
    REOPEN

The question status is changed back to OPEN and the last query date is
updated.

    >>> print question.status.name
    OPEN
    >>> question.datelastquery == much_later
    True


4) The question is invalid
==========================

In this scenario the user posts an inappropriate message, such as a spam
message or a request for Ubuntu CDs.

    >>> spam_question = ubuntu.newQuestion(
    ...     no_priv, 'CDs', 'Please send 10 Ubuntu Dapper CDs.',
    ...     datecreated=now)

Such questions can be rejected by an answer contact, a product or distribution
owner, or a Launchpad administrator.

The canReject() method can be used to test if a user is allowed to reject the
question.  While neither No Privileges Person nor Marilize are able to reject
questions, Sample Person and the Ubuntu owner can.

    >>> spam_question.canReject(no_priv)
    False
    >>> spam_question.canReject(marilize)
    False

    # Answer contact
    >>> spam_question.canReject(sample_person)
    True
    >>> spam_question.canReject(ubuntu.owner)
    True

As a Launchpad administrator, so can Stub.

    >>> spam_question.canReject(stub)
    True

    >>> login(marilize.preferredemail.email)
    >>> spam_question.reject(
    ...     marilize, "We don't send free CDs any more.")
    Traceback (most recent call last):
      ...
    Unauthorized: ...

When rejecting a question, a comment explaining the reason is given.

    >>> login('test@canonical.com')
    >>> reject_message = spam_question.reject(
    ...     sample_person, "We don't send free CDs any more.",
    ...     datecreated=now_plus_one_hour)
    >>> print reject_message.action.name
    REJECT
    >>> print reject_message.new_status.name
    INVALID

After rejection, the question is marked as invalid and the last response date
is updated.

    >>> print spam_question.status.name
    INVALID
    >>> spam_question.datelastresponse == now_plus_one_hour
    True

The rejection message is also considered as answering the message, so the
solution date, answerer, and answer are also updated.

    >>> spam_question.answer == reject_message
    True
    >>> print spam_question.answerer.displayname
    Sample Person
    >>> spam_question.date_solved == now_plus_one_hour
    True


Other scenarios
===============

Many other scenarios are possible and some are likely more common than others.
For example, it is likely that a user will directly answer a question without
asking for other information first.  Sometimes, the original questioner won't
come back to confirm that an answer solved his problem.

Another likely scenario is where the question will expire in the NEEDSINFO
state because the question owner doesn't reply to the request for more
information.  All of these scenarios are covered by this API, though it is not
necessary to cover all these various possibilities here.  (The
../tests/test_question_workflow.py functional test exercises all the various
possible transitions.)


Changing the question status
============================

It is not possible to change the status attribute directly.

    >>> login('foo.bar@canonical.com')
    >>> question = ubuntu.newQuestion(**new_question_args)
    >>> question.status = QuestionStatus.INVALID
    Traceback (most recent call last):
      ...
    ForbiddenAttribute...

A user having launchpad.Admin permission on the question can set the question
status to an arbitrary value, by giving the new status and a comment
explaining the status change.

    >>> old_datelastquery = question.datelastquery
    >>> login(stub.preferredemail.email)
    >>> status_change_message = question.setStatus(
    ...      stub, QuestionStatus.INVALID, 'Changed status to INVALID',
    ...     datecreated=now_plus_one_hour)

The method returns the IQuestionMessage recording the change.

    >>> print status_change_message.action.name
    SETSTATUS
    >>> print status_change_message.new_status.name
    INVALID
    >>> print question.status.name
    INVALID

The status change updates the last response date.

    >>> question.datelastresponse == now_plus_one_hour
    True
    >>> question.datelastquery == old_datelastquery
    True

If an answer was present on the question, the status change also clears
the answer and solution date.

    >>> msg = question.setStatus(stub, QuestionStatus.OPEN, 'Status change.')
    >>> answer_message = question.giveAnswer(sample_person, 'Install BootX.')

    >>> login('no-priv@canonical.com')
    >>> msg = question.confirmAnswer('This worked.', answer=answer_message)
    >>> question.date_solved is not None
    True
    >>> question.answer == answer_message
    True

    >>> login(stub.preferredemail.email)
    >>> status_change_message = question.setStatus(
    ...     stub, QuestionStatus.OPEN, 'Reopen the question',
    ...     datecreated=now_plus_one_hour)

    >>> print question.date_solved
    None
    >>> print question.answer
    None

When the status is changed by a user who doesn't have the launchpad.Admin
permission, an Unauthorized exception is thrown.

    >>> login('test@canonical.com')
    >>> question.setStatus(sample_person, QuestionStatus.EXPIRED, 'Expire.')
    Traceback (most recent call last):
      ...
    Unauthorized...


Adding Comments Without Changing the Status
===========================================

Comments can be added to questions without changing the question's status.

    >>> login('no-priv@canonical.com')
    >>> old_status = question.status
    >>> old_datelastresponse = question.datelastresponse
    >>> old_datelastquery = question.datelastquery
    >>> comment = question.addComment(
    ...     no_priv, 'This is a comment.',
    ...     datecreated=now_plus_two_hours)

    >>> print comment.action.name
    COMMENT
    >>> comment.new_status == old_status
    True

This method does not update the last response date or last query date.

    >>> question.datelastresponse == old_datelastresponse
    True
    >>> question.datelastquery == old_datelastquery
    True


Setting the question assignee
=============================

Users with launchpad.Moderator privileges, which are answer contacts,
question target owners, and admins, can assign someone to answer a question.

Sample Person is an answer contact for ubuntu, so he can set the assignee.

    >>> login('test@canonical.com')
    >>> question.assignee = stub
    >>> print question.assignee.displayname
    Stuart Bishop

Users without launchpad.Moderator privileges cannot set the assignee.

    >>> login('no-priv@canonical.com')
    >>> question.assignee = sample_person
    Traceback (most recent call last):
      ...
    Unauthorized: (<Question ...>, 'assignee', 'launchpad.Append')


Events
======

Each of the workflow methods will trigger a ObjectCreatedEvent for
the message they create and a ObjectModifiedEvent for the question.

    # Register an event listener that will print events it receives.
    >>> from lazr.lifecycle.interfaces import (
    ...     IObjectCreatedEvent, IObjectModifiedEvent)
    >>> from lp.testing.event import TestEventListener
    >>> from lp.answers.interfaces.question import IQuestion

    >>> def print_event(object, event):
    ...     print "Received %s on %s" % (
    ...         event.__class__.__name__.split('.')[-1],
    ...         object.__class__.__name__.split('.')[-1])
    >>> questionmessage_event_listener = TestEventListener(
    ...     IQuestionMessage, IObjectCreatedEvent, print_event)
    >>> question_event_listener = TestEventListener(
    ...     IQuestion, IObjectModifiedEvent, print_event)

Changing the status triggers the event.

    >>> login(stub.preferredemail.email)
    >>> msg = question.setStatus(
    ...     stub, QuestionStatus.EXPIRED, 'Status change.')
    Received ObjectCreatedEvent on QuestionMessage
    Received ObjectModifiedEvent on Question

Rejecting the question triggers the events.

    >>> msg = question.reject(stub, 'Close this question.')
    Received ObjectCreatedEvent on QuestionMessage
    Received ObjectModifiedEvent on Question

Even only adding a comment without changing the status will send
these events.

    >>> login('test@canonical.com')
    >>> msg = question.addComment(sample_person, 'A comment')
    Received ObjectCreatedEvent on QuestionMessage
    Received ObjectModifiedEvent on Question

    # Cleanup
    >>> questionmessage_event_listener.unregister()
    >>> question_event_listener.unregister()


Reopening the question
======================

Whenever a question considered answered (in the SOLVED or INVALID state)
is reopened, a QuestionReopening is created.

    # Register an event listener to notify us whenever a QuestionReopening is
    # created.
    >>> from lp.answers.interfaces.questionreopening import IQuestionReopening
    >>> reopening_event_listener = TestEventListener(
    ...     IQuestionReopening, IObjectCreatedEvent, print_event)

The most common use case is when a user confirms a solution, and then
comes back to say that it doesn't, in fact, work.

    >>> login('no-priv@canonical.com')
    >>> question = ubuntu.newQuestion(**new_question_args)
    >>> answer_message = question.giveAnswer(
    ...     sample_person,
    ...     "You need some setup on the Mac side. "
    ...     "Follow the instructions at "
    ...     "https://help.ubuntu.com/community/Installation/OldWorldMacs",
    ...     datecreated=now_plus_one_hour)
    >>> confirm_message = question.confirmAnswer(
    ...     "I've installed BootX and the installer now boot properly.",
    ...     answer=answer_message, datecreated=now_plus_two_hours)
    >>> reopen_message = question.reopen(
    ...     "Actually, altough the installer boots properly. I'm not able "
    ...     "to pass beyond the partitioning.",
    ...     datecreated=now_plus_three_hours)
    Received ObjectCreatedEvent on QuestionReopening

The reopening record is available through the reopenings attribute.

    >>> reopenings = list(question.reopenings)
    >>> len(reopenings)
    1
    >>> reopening = reopenings[0]
    >>> verifyObject(IQuestionReopening, reopening)
    True

The reopening contain the date of the reopening, and the person who cause the
reopening to happen.

    >>> reopening.datecreated == now_plus_three_hours
    True
    >>> print reopening.reopener.displayname
    No Privileges Person

It also contains the question's prior answerer, the date created, and the
prior status of the question.

    >>> print reopening.answerer.displayname
    Sample Person
    >>> reopening.date_solved == now_plus_two_hours
    True
    >>> print reopening.priorstate.name
    SOLVED

A reopening also occurs when the question status is set back to OPEN after
having been rejected.

    >>> login('test@canonical.com')
    >>> question = ubuntu.newQuestion(**new_question_args)
    >>> reject_message = question.reject(
    ...     sample_person, 'This is a frivoulous question.',
    ...     datecreated=now_plus_one_hour)

    >>> login(stub.preferredemail.email)
    >>> status_change_message = question.setStatus(
    ...     stub, QuestionStatus.OPEN,
    ...     'Disregard previous rejection. '
    ...     'Sample Person was having a bad day.',
    ...     datecreated=now_plus_two_hours)
    Received ObjectCreatedEvent on QuestionReopening

    >>> reopening = question.reopenings[0]
    >>> print reopening.reopener.name
    stub
    >>> reopening.datecreated == now_plus_two_hours
    True
    >>> print reopening.answerer.displayname
    Sample Person
    >>> reopening.date_solved == now_plus_one_hour
    True
    >>> print reopening.priorstate.name
    INVALID

    # Cleanup
    >>> reopening_event_listener.unregister()


Using an IMessage as an explanation
===================================

In all the workflow methods, it is possible to pass an IMessage instead of
a string.

    >>> from lp.services.messages.interfaces.message import IMessageSet
    >>> login('test@canonical.com')
    >>> messageset = getUtility(IMessageSet)
    >>> question = ubuntu.newQuestion(**new_question_args)
    >>> reject_message = messageset.fromText(
    ...     'Reject', 'Because I feel like it.', sample_person)
    >>> question_message = question.reject(sample_person, reject_message)
    >>> print question_message.subject
    Reject
    >>> print question_message.text_contents
    Because I feel like it.
    >>> question_message.rfc822msgid == reject_message.rfc822msgid
    True

The IMessage owner must be the same as the person passed to the workflow
method.

    >>> login(stub.preferredemail.email)
    >>> question.setStatus(stub, QuestionStatus.OPEN, reject_message)
    Traceback (most recent call last):
      ...
    NotMessageOwnerError...