Merge lp:~barry/launchpad/rnr-techdebt into lp:launchpad/db-devel

Proposed by Curtis Hovey
Status: Merged
Approved by: Curtis Hovey
Approved revision: not available
Merged at revision: not available
Proposed branch: lp:~barry/launchpad/rnr-techdebt
Merge into: lp:launchpad/db-devel
Diff against target: 3054 lines (+986/-884)
7 files modified
lib/lp/answers/doc/person.txt (+155/-138)
lib/lp/answers/doc/projectgroup.txt (+45/-37)
lib/lp/answers/doc/question.txt (+162/-132)
lib/lp/answers/doc/questionsets.txt (+148/-139)
lib/lp/answers/doc/questiontarget.txt (+266/-245)
lib/lp/answers/doc/workflow.txt (+209/-193)
lib/lp/answers/interfaces/questionreopening.py (+1/-0)
To merge this branch: bzr merge lp:~barry/launchpad/rnr-techdebt
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code Approve
Review via email: mp+18696@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Curtis Hovey (sinzui) wrote :

Maybe the diff will be sane if we merge into db-devel

Revision history for this message
Curtis Hovey (sinzui) wrote :
Download full text (7.5 KiB)

Hi Barry.

I really appreciate your attention to this issue. I am happy you asked me
to review this because I am familiar with the i18n Answers issues
in the test suite. Your diff must pass this test to be safe to commit.

    iconv -t ascii ~/Desktop/barry.diff > /dev/null

Launchpad code is not i18n enabled, Answers and my editor is! I see
the Arabic portions of the diff right to left. That is very exciting
to see, but made scanning impossible, and when I look at the doctest...well
wow, I cannot read that. I asked Björn read it and his editor refused to
display the file.

You can use .encode .decode to make the words ascii, or use asciismash()
from somewhere in our code. I used both of these when I had to print the
words out. I got tired of that so I just showed the representation.
You can also use ellipsis if you need to hide an awkward part.

You will see a lot of duplication in these tests. I was young and naive
when I wrote them. You can consider deleting any duplication you see when
you fix the encoding issues. There is a small chance that there is a model
or view test that is the definitive test for many of these stories (At least
most of the answers tests are stories)

I see Spanish and Arabic in the diff, I was sure there was some French in
the tests I wrote, but you may not have touched those tests.

> === modified file 'lib/lp/answers/doc/person.txt'
> --- lib/lp/answers/doc/person.txt 2009-12-24 01:41:54 +0000
> +++ lib/lp/answers/doc/person.txt 2010-02-05 12:29:14 +0000
...
> +Language
> +--------
...
> +But Carlos has one.
>
> >>> carlos_raw = personset.getByName('carlos')
> >>> carlos = IQuestionsPerson(carlos_raw)
> >>> for question in carlos.searchQuestions(
> - ... language=[english, spanish]):
> - ... [question.title, question.language.code]
> - [u'Problema al recompilar kernel con soporte smp (doble-n\xfacleo)',
> - u'es']
> -
> -
> -=== needs_attention ===
> -
> -The method accept a parameter called needs_attention which only selects
> -the questions that needs attention from the person. This includes questions
> -owned by the person in the ANSWERED or NEEDSINFO state. It also includes
> -questions on which the person requested for more information or gave an
> -answer and that are back in the OPEN state.
> + ... language=(english, spanish)):
> + ... print question.title, question.language.code
> + Problema al recompilar kernel con soporte smp (doble-núcleo) es

...

> === renamed file 'lib/lp/answers/doc/utility.txt' => 'lib/lp/answers/doc/questionsets.txt'
> --- lib/lp/answers/doc/utility.txt 2009-03-24 12:43:49 +0000
> +++ lib/lp/answers/doc/questionsets.txt 2010-02-05 12:29:14 +0000...

...

> +Searching questions
> +===================
> +
> +The IQuestionSet interface defines a searchQuestions() method that is used to
> +search for questions defined in any question target.
> +
> +
> +Search text
> +-----------
> +
> +The search_text parameter will return questions matching the query using the
> +regular full text algorithm.
>
> >>> for question in question_set.searchQuestions(search_text='firefox'):
> - ... print repr(question.title)...

Read more...

review: Needs Fixing
Revision history for this message
Barry Warsaw (barry) wrote :
Download full text (8.0 KiB)

Hi Curtis, thanks for the review.

On Feb 05, 2010, at 01:34 PM, Curtis Hovey wrote:

>I really appreciate your attention to this issue. I am happy you asked me
>to review this because I am familiar with the i18n Answers issues
>in the test suite. Your diff must pass this test to be safe to commit.
>
> iconv -t ascii ~/Desktop/barry.diff > /dev/null
>
>Launchpad code is not i18n enabled, Answers and my editor is! I see
>the Arabic portions of the diff right to left. That is very exciting
>to see, but made scanning impossible, and when I look at the doctest...well
>wow, I cannot read that. I asked Björn read it and his editor refused to
>display the file.

Yes, I got too clever because I use a real editor <wink> where all those funny
characters look so beautiful.

>You can use .encode .decode to make the words ascii, or use asciismash()
>from somewhere in our code. I used both of these when I had to print the
>words out. I got tired of that so I just showed the representation.
>You can also use ellipsis if you need to hide an awkward part.

ascii_smash() comes from canonical.encoding and it seems to do a reasonable
job of making iconv happy, while still retaining enough of the original text
for Spanish. I'll have to take ascii_smash()'s word for the Arabic
conversion.

(Aside: I just had to attach a screen shot showing claws-mail's beautiful
handling of the Arabic diff, which right-to-lefts the diff lines. Not sure if
the attachment will come through on the merge-proposal or not. ;)

>You will see a lot of duplication in these tests. I was young and naive
>when I wrote them. You can consider deleting any duplication you see when
>you fix the encoding issues. There is a small chance that there is a model
>or view test that is the definitive test for many of these stories (At least
>most of the answers tests are stories)

I didn't see much duplication among the doctests I touch. I was mostly just
reading through them to get a sense for where I can hack in the R&R stuff. I
didn't touch all the .txt files though (didn't any French for instance). I'll
keep on the lookout for any additional duplication as I work on the R&R stuff.

Here's an incremental diff.

=== modified file 'lib/lp/answers/doc/person.txt'
--- lib/lp/answers/doc/person.txt 2010-02-04 08:24:18 +0000
+++ lib/lp/answers/doc/person.txt 2010-02-05 21:12:48 +0000
@@ -168,12 +168,14 @@

 But Carlos has one.

+ # Because not everyone uses a real editor <wink>
+ >>> from canonical.encoding import ascii_smash
     >>> carlos_raw = personset.getByName('carlos')
     >>> carlos = IQuestionsPerson(carlos_raw)
     >>> for question in carlos.searchQuestions(
     ... language=(english, spanish)):
- ... print question.title, question.language.code
- Problema al recompilar kernel con soporte smp (doble-núcleo) es
+ ... print ascii_smash(question.title), question.language.code
+ Problema al recompilar kernel con soporte smp (doble-nucleo) es

 Questions needing attention

=== modified file 'lib/lp/answers/doc/questionsets.txt'
--- lib/lp/answers/doc/questionsets.txt 2010-02-04 07:27:57 +0000
+++ lib/lp/answers/doc/questionsets.txt 2010-02...

Read more...

Revision history for this message
Curtis Hovey (sinzui) wrote :

Thanks for cleaning up the tests. Sorry for not responding quickly to your reply.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/answers/doc/person.txt'
2--- lib/lp/answers/doc/person.txt 2009-12-24 01:41:54 +0000
3+++ lib/lp/answers/doc/person.txt 2010-02-10 15:24:19 +0000
4@@ -1,22 +1,28 @@
5-= Person and the Answer Tracker =
6-
7-== searchQuestions() ==
8-
9-IQuestionsPerson defines a searchQuestions() method which can be used to
10-select all or a subset of the questions in which the person is
11-involved. This includes questions which the person created, is assigned
12-to, is subscribed to, commented on, or answered. Various subsets can
13-be selected by using the following criteria status, search_text and
14-participation type.
15-
16- >>> from canonical.launchpad.interfaces import IPersonSet
17+=============================
18+People and the answer tracker
19+=============================
20+
21+Sometimes you want to find out what questions a person is involved with.
22+
23+
24+Searching
25+=========
26+
27+IQuestionsPerson defines a searchQuestions() method which is used to select
28+all, or a subset of, the questions in which a person is involved. This
29+includes questions which the person created, is assigned to, is subscribed to,
30+commented on, or answered. Various subsets can be selected by using the
31+various search criteria.
32+
33+ >>> from lp.registry.interfaces.person import IPersonSet
34 >>> from lp.answers.interfaces.questionsperson import IQuestionsPerson
35 >>> personset = getUtility(IPersonSet)
36 >>> foo_bar_raw = personset.getByEmail('foo.bar@canonical.com')
37 >>> foo_bar = IQuestionsPerson(foo_bar_raw)
38
39
40-=== search_text ===
41+Search text
42+-----------
43
44 The search_text parameter will limit the questions to those matching
45 the query using the regular full text algorithm.
46@@ -28,13 +34,14 @@
47 Newly installed plug-in doesn't seem to be used Answered
48
49
50-=== sort ===
51-
52-When using the search_text criteria, the default is to sort the results
53-by relevancy. One can use the sort parameter to change that. It takes
54-one of the constant defined in the QuestionSort enumeration.
55-
56- >>> from canonical.launchpad.interfaces import QuestionSort
57+Sorting
58+-------
59+
60+When using the search_text criteria, the default is to sort the results by
61+relevancy. One can use the sort parameter to change that. It takes one of
62+the constant defined in the QuestionSort enumeration.
63+
64+ >>> from lp.answers.interfaces.questionenums import QuestionSort
65 >>> for question in foo_bar.searchQuestions(
66 ... search_text='firefox', sort=QuestionSort.OLDEST_FIRST):
67 ... print question.id, question.title, question.status.title
68@@ -42,8 +49,7 @@
69 6 Newly installed plug-in doesn't seem to be used Answered
70 9 mailto: problem in webpage Solved
71
72-When no text search is done, the default sort order is
73-QuestionSort.NEWEST_FIRST.
74+When no text search is done, the default sort order is newest first.
75
76 >>> for question in foo_bar.searchQuestions():
77 ... print question.id, question.title, question.status.title
78@@ -56,13 +62,13 @@
79 4 Firefox loses focus and gets stuck Open
80
81
82-=== status ===
83-
84-The last searches showed that by default, not all statuses are searched
85-for by default (they excluded expired and invalid questions). The status
86-parameter can be used to control the list of statuses to select:
87-
88- >>> from canonical.launchpad.interfaces import QuestionStatus
89+Status
90+------
91+
92+As shown above, expired and invalid questions are not returned. The status
93+parameter can be used to control the list of statuses to select.
94+
95+ >>> from lp.answers.interfaces.questionenums import QuestionStatus
96 >>> for question in foo_bar.searchQuestions(status=QuestionStatus.INVALID):
97 ... print question.title, question.status.title
98 Firefox is slow and consumes too much RAM Invalid
99@@ -70,23 +76,23 @@
100 The status parameter can also take a list of statuses.
101
102 >>> for question in foo_bar.searchQuestions(
103- ... status=[QuestionStatus.SOLVED, QuestionStatus.INVALID]):
104+ ... status=(QuestionStatus.SOLVED, QuestionStatus.INVALID)):
105 ... print question.title, question.status.title
106 mailto: problem in webpage Solved
107 Firefox is slow and consumes too much RAM Invalid
108
109
110-=== participation ===
111+Participation
112+-------------
113
114-By default, any types of relationship to a question is considered by
115-searchQuestions. This can customized through the participation
116-parameter. It takes one or a list of constants from the
117-QuestionParticipation enumeration.
118+By default, any relationship between a person and a question is considered by
119+searchQuestions. This can customized through the participation parameter. It
120+takes one or a list of constants from the QuestionParticipation enumeration.
121
122 To select only questions on which the person commented, the
123-QuestionParticipation.COMMENTER is used:
124+QuestionParticipation.COMMENTER is used.
125
126- >>> from canonical.launchpad.interfaces import QuestionParticipation
127+ >>> from lp.answers.interfaces.questionenums import QuestionParticipation
128 >>> for question in foo_bar.searchQuestions(
129 ... participation=QuestionParticipation.COMMENTER, status=None):
130 ... print question.title
131@@ -96,8 +102,8 @@
132 Installation of Java Runtime Environment for Mozilla
133 Newly installed plug-in doesn't seem to be used
134
135-QuestionParticipation.SUBSCRIBER will only select the questions to which
136-the person is subscribed to:
137+QuestionParticipation.SUBSCRIBER will only select the questions to which the
138+person is subscribed.
139
140 >>> for question in foo_bar.searchQuestions(
141 ... participation=QuestionParticipation.SUBSCRIBER, status=None):
142@@ -105,7 +111,7 @@
143 Slow system
144 Firefox is slow and consumes too much RAM
145
146-QuestionParticipation.OWNER selects the questions that the person created:
147+QuestionParticipation.OWNER selects the questions that the person created.
148
149 >>> for question in foo_bar.searchQuestions(
150 ... participation=QuestionParticipation.OWNER, status=None):
151@@ -114,8 +120,8 @@
152 Firefox loses focus and gets stuck
153 Firefox is slow and consumes too much RAM
154
155-QuestionParticipation.ANSWERER selects the questions for which the person
156-was marked as the answerer:
157+QuestionParticipation.ANSWERER selects the questions for which the person gave
158+an answer.
159
160 >>> for question in foo_bar.searchQuestions(
161 ... participation=QuestionParticipation.ANSWERER, status=None):
162@@ -124,19 +130,18 @@
163 Firefox is slow and consumes too much RAM
164
165 QuestionParticipation.ASSIGNEE selects that questions which are assigned to
166-the person:
167+the person.
168
169- >>> for question in foo_bar.searchQuestions(
170- ... participation=QuestionParticipation.ASSIGNEE, status=None):
171- ... print question.title
172+ >>> list(foo_bar.searchQuestions(
173+ ... participation=QuestionParticipation.ASSIGNEE, status=None))
174+ []
175
176 If a list of these constants is used, all of these participation types
177-will be selected:
178+will be selected.
179
180 >>> for question in foo_bar.searchQuestions(
181- ... participation=[
182- ... QuestionParticipation.OWNER,
183- ... QuestionParticipation.ANSWERER],
184+ ... participation=(QuestionParticipation.OWNER,
185+ ... QuestionParticipation.ANSWERER),
186 ... status=None):
187 ... print question.title
188 mailto: problem in webpage
189@@ -145,40 +150,41 @@
190 Firefox is slow and consumes too much RAM
191
192
193-=== language ===
194-
195-By default, questions in all languages are included in the results.
196-It is possible to filter questions by the language they were written
197-in . One or a list of ILanguage object should be passed in the
198-language parameter to specify the language filter.
199-
200- >>> from canonical.launchpad.interfaces import ILanguageSet
201+Language
202+--------
203+
204+By default, questions in all languages are included in the results. It is
205+possible to filter questions by the language they were written in. One or a
206+sequence of ILanguage object can be passed in to specify the language filter.
207+
208+ >>> from lp.services.worlddata.interfaces.language import ILanguageSet
209 >>> spanish = getUtility(ILanguageSet)['es']
210 >>> english = getUtility(ILanguageSet)['en']
211
212 Foo bar doesn't have any questions written in Spanish.
213
214- >>> for question in foo_bar.searchQuestions(language=spanish):
215- ... print question.title
216-
217-But carlos has one.
218-
219+ >>> list(foo_bar.searchQuestions(language=spanish))
220+ []
221+
222+But Carlos has one.
223+
224+ # Because not everyone uses a real editor <wink>
225+ >>> from canonical.encoding import ascii_smash
226 >>> carlos_raw = personset.getByName('carlos')
227 >>> carlos = IQuestionsPerson(carlos_raw)
228 >>> for question in carlos.searchQuestions(
229- ... language=[english, spanish]):
230- ... [question.title, question.language.code]
231- [u'Problema al recompilar kernel con soporte smp (doble-n\xfacleo)',
232- u'es']
233-
234-
235-=== needs_attention ===
236-
237-The method accept a parameter called needs_attention which only selects
238-the questions that needs attention from the person. This includes questions
239-owned by the person in the ANSWERED or NEEDSINFO state. It also includes
240-questions on which the person requested for more information or gave an
241-answer and that are back in the OPEN state.
242+ ... language=(english, spanish)):
243+ ... print ascii_smash(question.title), question.language.code
244+ Problema al recompilar kernel con soporte smp (doble-nucleo) es
245+
246+
247+Questions needing attention
248+---------------------------
249+
250+You can select only the questions that needs attention from a person. This
251+includes questions owned by the person in the ANSWERED or NEEDSINFO state. It
252+also includes questions on which the person requested more information or gave
253+an answer and are back in the OPEN state.
254
255 >>> for question in foo_bar.searchQuestions(needs_attention=True):
256 ... print question.status.title, question.owner.displayname, (
257@@ -187,70 +193,80 @@
258 Needs information Foo Bar Slow system
259
260
261-=== Combination ===
262+Search combinations
263+-------------------
264
265-The returned sets of questions is the intersection of the sets delimited
266-by each criteria:
267+The results are the intersection of the sets delimited by each criteria.
268
269 >>> for question in foo_bar.searchQuestions(
270- ... search_text='firefox OR Java', status=QuestionStatus.ANSWERED,
271+ ... search_text='firefox OR Java',
272+ ... status=QuestionStatus.ANSWERED,
273 ... participation=QuestionParticipation.COMMENTER):
274 ... print question.title, question.status.title
275 Installation of Java Runtime Environment for Mozilla Answered
276 Newly installed plug-in doesn't seem to be used Answered
277
278
279-== getQuestionLanguages() ==
280+Question languages
281+==================
282
283 IQuestionsPerson also defines a getQuestionLanguages() attribute which
284 contains the set of languages used by all of the questions in which this
285 person is involved.
286
287- >>> sorted(language.code for language in foo_bar.getQuestionLanguages())
288- [u'en']
289-
290-This includes questions which the person owns. But also, questions that
291-the user subscribed to.
292-
293- >>> from canonical.launchpad.interfaces import IQuestionSet
294+ >>> print ', '.join(
295+ ... sorted(language.code
296+ ... for language in foo_bar.getQuestionLanguages()))
297+ en
298+
299+This includes questions which the person owns, and questions that the user is
300+subscribed to...
301+
302+ >>> from lp.answers.interfaces.questioncollection import IQuestionSet
303 >>> pt_BR_question = getUtility(IQuestionSet).get(13)
304 >>> login('foo.bar@canonical.com')
305 >>> pt_BR_question.subscribe(foo_bar_raw)
306 <QuestionSubscription...>
307
308- >>> sorted(language.code for language in foo_bar.getQuestionLanguages())
309- [u'en', u'pt_BR']
310+ >>> print ', '.join(
311+ ... sorted(language.code
312+ ... for language in foo_bar.getQuestionLanguages()))
313+ en, pt_BR
314
315-And also questions for which he's the answerer.
316+...and questions for which he's the answerer...
317
318 >>> es_question = getUtility(IQuestionSet).get(12)
319 >>> es_question.reject(foo_bar_raw, 'Reject question.')
320 <QuestionMessage...>
321
322- >>> sorted(language.code for language in foo_bar.getQuestionLanguages())
323- [u'en', u'es', u'pt_BR']
324+ >>> print ', '.join(
325+ ... sorted(language.code
326+ ... for language in foo_bar.getQuestionLanguages()))
327+ en, es, pt_BR
328
329-As well, as question which are assigned to the user.
330+...as well as questions which are assigned to the user...
331
332 >>> pt_BR_question.assignee = carlos_raw
333- >>> from canonical.database.sqlbase import flush_database_updates
334- >>> flush_database_updates()
335-
336- >>> sorted(language.code for language in carlos.getQuestionLanguages())
337- [u'es', u'pt_BR']
338-
339-And questions on which the user commented:
340+ >>> print ', '.join(
341+ ... sorted(language.code
342+ ... for language in carlos.getQuestionLanguages()))
343+ es, pt_BR
344+
345+...and questions on which the user commented.
346
347 >>> en_question = getUtility(IQuestionSet).get(1)
348 >>> login('carlos@canonical.com')
349 >>> en_question.addComment(carlos_raw, 'A simple comment.')
350 <QuestionMessage...>
351
352- >>> sorted(language.code for language in carlos.getQuestionLanguages())
353- [u'en', u'es', u'pt_BR']
354-
355-
356-== getDirectAnswerQuestionTargets() ==
357+ >>> print ', '.join(
358+ ... sorted(language.code
359+ ... for language in carlos.getQuestionLanguages()))
360+ en, es, pt_BR
361+
362+
363+Direct subscriptions
364+====================
365
366 IQuestionsPerson defines getDirectAnswerQuestionTargets that can be used to
367 retrieve a list of IQuestionTargets that a person subscribed himself to as an
368@@ -261,9 +277,10 @@
369 >>> no_priv.getDirectAnswerQuestionTargets()
370 []
371
372- >>> from canonical.launchpad.interfaces import IProductSet
373+ >>> from lp.registry.interfaces.product import IProductSet
374 >>> firefox = getUtility(IProductSet).getByName("firefox")
375- >>> # Answer contacts must speak a language
376+
377+ # Answer contacts must speak a language
378 >>> no_priv_raw.addLanguage(english)
379 >>> firefox.addAnswerContact(no_priv_raw)
380 True
381@@ -272,7 +289,9 @@
382 ... print target.name
383 firefox
384
385-== getTeamAnswerQuestionTargets() ==
386+
387+Indirect subscriptions
388+======================
389
390 IQuestionsPerson defines getTeamAnswerQuestionTargets that retrieves a list of
391 IQuestionTargets that the person is subscribed to indirectly as an answer
392@@ -283,20 +302,21 @@
393 >>> no_priv_raw.inTeam(landscape_team)
394 True
395
396- >>> from canonical.launchpad.interfaces import IDistributionSet
397+ >>> from lp.registry.interfaces.distribution import IDistributionSet
398 >>> ubuntu = getUtility(IDistributionSet).getByName("ubuntu")
399 >>> landscape_team.addLanguage(english)
400 >>> ubuntu.addAnswerContact(landscape_team)
401 True
402
403- >>> sorted(target.name
404- ... for target in no_priv.getTeamAnswerQuestionTargets())
405- [u'ubuntu']
406+ >>> print ', '.join(
407+ ... sorted(target.name
408+ ... for target in no_priv.getTeamAnswerQuestionTargets()))
409+ ubuntu
410
411-Indirect team membership is also taken in consideration. For example,
412-the Landscape Team joins the Translator Team. So targets for which the
413-Translator team is an answer contact will be included in No Privileges
414-Person's supported IQuestionTargets:
415+Indirect team membership is also taken in consideration. For example, when
416+the Landscape Team joins the Translator Team, targets for which the Translator
417+team is an answer contact will be included in No Privileges Person's supported
418+IQuestionTargets.
419
420 >>> translator_team = personset.getByName('ubuntu-translators')
421 >>> no_priv_raw.inTeam(translator_team)
422@@ -315,36 +335,33 @@
423 >>> translator_team.addLanguage(english)
424 >>> evolution_package.addAnswerContact(translator_team)
425 True
426- >>> sorted(target.name
427- ... for target in no_priv.getTeamAnswerQuestionTargets())
428- [u'evolution', u'ubuntu']
429-
430-
431-== Deactivated pillars and *AnswerQuestionTargets() ==
432-
433-getDirectAnswerQuestionTargets() and getTeamAnswerQuestionTargets() use
434-a _getQuestionTargetsFromAnswerContacts() to build a distinct list of
435-valid IQuestionTargets. It ensures that no deactivated pillars are in
436-the list.
437+ >>> print ', '.join(
438+ ... sorted(target.name
439+ ... for target in no_priv.getTeamAnswerQuestionTargets()))
440+ evolution, ubuntu
441+
442+
443+Deactivated pillars
444+===================
445+
446+Only valid IQuestionTargets are returned, ensuring that no deactivated pillars
447+are in the results.
448
449 If the Firefox project is deactivated, it is removed from the list of
450 supported projects.
451
452- >>> from canonical.launchpad.ftests import syncUpdate
453-
454 >>> login('foo.bar@canonical.com')
455 >>> firefox.active = False
456- >>> syncUpdate(firefox)
457 >>> sorted(target.name
458 ... for target in no_priv.getDirectAnswerQuestionTargets())
459 []
460
461-When the Firefox project is reactivated, the answer contact relationship
462-is visible. It is important to preserve the continuity of the project in
463-cases were we only want is deactivated for a short period.
464+When the Firefox project is reactivated, the answer contact relationship is
465+visible. These relationships are persistent for cases where we only want is
466+deactivated for a short period.
467
468 >>> firefox.active = True
469- >>> syncUpdate(firefox)
470- >>> sorted(target.name
471- ... for target in no_priv.getDirectAnswerQuestionTargets())
472- [u'firefox']
473+ >>> print ', '.join(
474+ ... sorted(target.name
475+ ... for target in no_priv.getDirectAnswerQuestionTargets()))
476+ firefox
477
478=== renamed file 'lib/lp/answers/doc/project.txt' => 'lib/lp/answers/doc/projectgroup.txt'
479--- lib/lp/answers/doc/project.txt 2009-03-24 12:43:49 +0000
480+++ lib/lp/answers/doc/projectgroup.txt 2010-02-10 15:24:19 +0000
481@@ -1,12 +1,15 @@
482-= Project and the Answer Tracker =
483+===============================
484+Projects and the answer tracker
485+===============================
486
487-Although question cannot be filed directly against projects, IProject in
488-Launchpad also provides the IQuestionCollection and
489+Although questions cannot be filed directly against project groups (nee
490+'Project'), IProject provides the IQuestionCollection and
491 ISearchableByQuestionOwner interfaces.
492
493 >>> from canonical.launchpad.webapp.testing import verifyObject
494- >>> from canonical.launchpad.interfaces import (
495- ... IProjectSet, ISearchableByQuestionOwner, IQuestionCollection)
496+ >>> from lp.registry.interfaces.project import IProjectSet
497+ >>> from lp.answers.interfaces.questioncollection import (
498+ ... ISearchableByQuestionOwner, IQuestionCollection)
499
500 >>> mozilla_project = getUtility(IProjectSet).getByName('mozilla')
501 >>> verifyObject(IQuestionCollection, mozilla_project)
502@@ -14,37 +17,41 @@
503 >>> verifyObject(ISearchableByQuestionOwner, mozilla_project)
504 True
505
506-== searchQuestions() ==
507-
508-This means that it is possible to search for all questions filed against
509-products in a project using the project searchQuestions() method.
510-
511- # Add a question to thunderbird.
512- >>> from canonical.launchpad.interfaces import ILaunchBag, IProductSet
513+
514+Questions filed against project in a project group
515+==================================================
516+
517+You can search for all questions filed against projects in a project using the
518+project group's searchQuestions() method.
519+
520+ >>> from lp.registry.interfaces.person import IPersonSet
521+ >>> from lp.registry.interfaces.product import IProductSet
522+
523 >>> login('test@canonical.com')
524 >>> thunderbird = getUtility(IProductSet).getByName('thunderbird')
525- >>> sample_person = getUtility(ILaunchBag).user
526+ >>> sample_person = getUtility(IPersonSet).getByName('name12')
527 >>> question = thunderbird.newQuestion(
528- ... sample_person, "SVG attachments aren't displayed",
529+ ... sample_person,
530+ ... "SVG attachments aren't displayed ",
531 ... "It would be a nice feature if SVG attachments could be displayed"
532- ... "inlined.")
533+ ... " inlined.")
534
535 >>> for question in mozilla_project.searchQuestions(search_text='svg'):
536 ... print question.title, question.target.displayname
537 SVG attachments aren't displayed Mozilla Thunderbird
538 Problem showing the SVG demo on W3C site Mozilla Firefox
539
540-In the case were a Project has no Products, then we can expect no
541-possible questions.
542+In the case where a project group has no projects, there are no results.
543
544 >>> aaa_project = getUtility(IProjectSet).getByName('aaa')
545- >>> [q for question in aaa_project.searchQuestions()]
546+ >>> list(aaa_project.searchQuestions())
547 []
548
549-Questions can be searched by all the standard searchQuestions() parameters
550-(consult questiontarget.txt for the full details.)
551+Questions can be searched by all the standard searchQuestions() parameters.
552+See questiontarget.txt for the full details.
553
554- >>> from canonical.launchpad.interfaces import QuestionStatus, QuestionSort
555+ >>> from lp.answers.interfaces.questionenums import (
556+ ... QuestionSort, QuestionStatus)
557 >>> for question in mozilla_project.searchQuestions(
558 ... owner=sample_person, status=QuestionStatus.OPEN,
559 ... sort=QuestionSort.OLDEST_FIRST):
560@@ -52,20 +59,21 @@
561 Problem showing the SVG demo on W3C site Mozilla Firefox
562 SVG attachments aren't displayed Mozilla Thunderbird
563
564-== getQuestionLanguages() ==
565-
566-The getQuestionLanguages() returns the set of languages that is used by
567-all the questions in the project products.
568-
569- >>> sorted(language.code
570- ... for language in mozilla_project.getQuestionLanguages())
571- [u'en', u'pt_BR']
572-
573-(The firefox product has one question created in Brazilian Portuguese.)
574-
575-In the case where a Project has no Products, language questions will
576-still return and empty set.
577-
578- >>> [language.code for language in aaa_project.getQuestionLanguages()]
579+
580+Languages
581+=========
582+
583+getQuestionLanguages() returns the set of languages that is used by all the
584+questions in the project group's projects.
585+
586+ # The Firefox project group has one question created in Brazilian
587+ # Portuguese.
588+ >>> print ', '.join(
589+ ... sorted(language.code
590+ ... for language in mozilla_project.getQuestionLanguages()))
591+ en, pt_BR
592+
593+In the case where a project group has no projects, there are no results.
594+
595+ >>> list(aaa_project.getQuestionLanguages())
596 []
597-
598
599=== modified file 'lib/lp/answers/doc/question.txt'
600--- lib/lp/answers/doc/question.txt 2009-03-24 12:43:49 +0000
601+++ lib/lp/answers/doc/question.txt 2010-02-10 15:24:19 +0000
602@@ -1,21 +1,24 @@
603-= Launchpad Answer Tracker =
604+========================
605+Launchpad Answer Tracker
606+========================
607
608-Launchpad includes an Answer Tracker where users can post questions
609-(usually about problems they encounter with projects) and other can
610-answer them.) Questions are created and accessed using the
611-IQuestionTarget interface. This interface is available on Products,
612-Distributions and DistributionSourcePackages.
613+Launchpad includes an Answer Tracker where users can post questions, usually
614+about problems they encounter with projects, and other people can answer them.
615+Questions are created and accessed using the IQuestionTarget interface. This
616+interface is available on Products, Distributions and
617+DistributionSourcePackages.
618
619 >>> login('test@canonical.com')
620
621 >>> from canonical.launchpad.webapp.testing import verifyObject
622- >>> from canonical.launchpad.interfaces import (
623- ... IDistributionSet, IProductSet, IPersonSet, IQuestionTarget)
624+ >>> from lp.answers.interfaces.questiontarget import IQuestionTarget
625+ >>> from lp.registry.interfaces.product import IProductSet
626
627 >>> firefox = getUtility(IProductSet)['firefox']
628 >>> verifyObject(IQuestionTarget, firefox)
629 True
630
631+ >>> from lp.registry.interfaces.distribution import IDistributionSet
632 >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
633 >>> verifyObject(IQuestionTarget, ubuntu)
634 True
635@@ -24,9 +27,9 @@
636 >>> verifyObject(IQuestionTarget, evolution_in_ubuntu)
637 True
638
639-Although Distribution series do not implement the IQuestionTarget
640-interface, it is possible to adapt one to it. (The adapter is actually
641-the distroseries's distribution.)
642+Although distribution series do not implement the IQuestionTarget interface,
643+it is possible to adapt one to it. The adapter is actually the distroseries's
644+distribution.
645
646 >>> ubuntu_warty = ubuntu.getSeries('warty')
647 >>> IQuestionTarget.providedBy(ubuntu_warty)
648@@ -56,29 +59,33 @@
649 You create a new question by calling the newQuestion() method of an
650 IQuestionTarget attribute.
651
652+ >>> from lp.registry.interfaces.person import IPersonSet
653 >>> sample_person = getUtility(IPersonSet).getByEmail('test@canonical.com')
654 >>> firefox_question = firefox.newQuestion(
655 ... sample_person, "Firefox question", "Unable to use Firefox")
656
657-(The complete IQuestionTarget interface is documented in
658-../interfaces/ftests/questiontarget.txt.)
659-
660-== Official usage ==
661-
662-A product or distribution may be offically supported by the community
663-using the Answer Tracker. This status is set by the official_answers
664-attribute on the IProduct and IDistribution.
665+The complete IQuestionTarget interface is documented in questiontarget.txt.
666+
667+
668+Official usage
669+==============
670+
671+A product or distribution may be officially supported by the community using
672+the Answer Tracker. This status is set by the official_answers attribute on
673+the IProduct and IDistribution.
674
675 >>> ubuntu.official_answers
676 True
677 >>> firefox.official_answers
678 True
679
680-== IQuestion ==
681-
682-Questions are manipulated through the IQuestion interface:
683-
684- >>> from canonical.launchpad.interfaces import IQuestion
685+
686+IQuestion interface
687+===================
688+
689+Questions are manipulated through the IQuestion interface.
690+
691+ >>> from lp.answers.interfaces.question import IQuestion
692 >>> from zope.security.proxy import removeSecurityProxy
693
694 # The complete interface is not necessarily available to the
695@@ -88,21 +95,27 @@
696
697 The person who submitted the question is available in the owner field.
698
699- >>> firefox_question.owner == sample_person
700- True
701+ >>> firefox_question.owner
702+ <Person at ... name12 (Sample Person)>
703
704 When the question is created, the owner is added to the question's
705-subscribers:
706-
707- >>> sample_person in [s.person for s in firefox_question.subscriptions]
708- True
709-
710-The question status is 'Open':
711-
712- >>> firefox_question.status.title
713- 'Open'
714-
715-And the creation time is recorded in the datecreated attribute:
716+subscribers.
717+
718+ >>> from operator import attrgetter
719+ >>> def print_subscribers(question):
720+ ... people = [subscription.person
721+ ... for subscription in question.subscriptions]
722+ ... for person in sorted(people, key=attrgetter('name')):
723+ ... print person.displayname
724+ >>> print_subscribers(firefox_question)
725+ Sample Person
726+
727+The question status is 'Open'.
728+
729+ >>> print firefox_question.status.title
730+ Open
731+
732+The question has a creation time.
733
734 >>> from datetime import datetime, timedelta
735 >>> from pytz import UTC
736@@ -110,11 +123,10 @@
737 >>> now - firefox_question.datecreated < timedelta(seconds=5)
738 True
739
740-The target onto which the question was created is available through the
741-'target' attribute:
742+The target onto which the question was created is also available.
743
744- >>> firefox_question.target == firefox
745- True
746+ >>> print firefox_question.target.displayname
747+ Mozilla Firefox
748
749 It is also possible to adapt a question to its IQuestionTarget.
750
751@@ -163,66 +175,69 @@
752 firefox
753
754
755-== Subscriptions and Notifications ==
756+Subscriptions and notifications
757+===============================
758
759 Whenever a question is created or changed, email notifications will be
760-sent. To receive such notification, one can subscribe to the bug using
761+sent. To receive such notification, one can subscribe to the bug using
762 the subscribe() method.
763
764 >>> no_priv = getUtility(IPersonSet).getByName('no-priv')
765 >>> subscription = firefox_question.subscribe(no_priv)
766
767-The list of subscriptions is available in the subscriptions attribute.
768-In the current case, the subscribers will include the owner
769-('Sample Person') and the newly subscribed person.
770+The subscribers include the owner and the newly subscribed person.
771
772- >>> [s.person.displayname for s in firefox_question.subscriptions]
773- [u'Sample Person', u'No Privileges Person']
774+ >>> print_subscribers(firefox_question)
775+ Sample Person
776+ No Privileges Person
777
778 The getDirectSubscribers() method returns a sorted list of subscribers.
779 This method iterates like the NotificationRecipientSet returned by the
780 getDirectRecipients() method.
781
782- >>> [person.displayname
783- ... for person in firefox_question.getDirectSubscribers()]
784- [u'No Privileges Person', u'Sample Person']
785+ >>> for person in firefox_question.getDirectSubscribers():
786+ ... print person.displayname
787+ No Privileges Person
788+ Sample Person
789
790 To remove a person from the subscriptions list, we use the unsubscribe()
791 method.
792
793 >>> firefox_question.unsubscribe(no_priv)
794- >>> [s.person.displayname for s in firefox_question.subscriptions]
795- [u'Sample Person']
796+ >>> print_subscribers(firefox_question)
797+ Sample Person
798
799-The persons who are on the subscription list are said to be directly
800-subscribed to the question. They explicitly choose to get notifications
801-about that particular question. This list of persons is available through
802-the getDirectRecipients() method.
803+The people on the subscription list are said to be directly subscribed to the
804+question. They explicitly chose to get notifications about that particular
805+question. This list of people is available through the getDirectRecipients()
806+method.
807
808 >>> subscribers = firefox_question.getDirectRecipients()
809
810 That method returns an INotificationRecipientSet, containing the direct
811-subscribers along the rationale for contacting them:
812+subscribers along with the rationale for contacting them.
813
814 >>> from canonical.launchpad.interfaces import INotificationRecipientSet
815 >>> verifyObject(INotificationRecipientSet, subscribers)
816 True
817- >>> [person.displayname for person in subscribers]
818- [u'Sample Person']
819- >>> subscribers.getReason(sample_person)
820- ('You received this question notification because you are a direct
821- subscriber of the question.', 'Subscriber')
822+ >>> def print_reason(subscribers):
823+ ... for person in subscribers:
824+ ... text, header = subscribers.getReason(person)
825+ ... print header, person.displayname, text
826+ >>> print_reason(subscribers)
827+ Subscriber Sample Person You received this question notification
828+ because you are a direct subscriber of the question.
829
830-There is also a list of 'indirect' subscribers to the question. These
831-are persons that didn't explicitly subscribed to the question, but that
832-will receive notifications for other reason. Answer contacts for the
833-question target are part of the indirect subscribers list.
834+There is also a list of 'indirect' subscribers to the question. These are
835+people that didn't explicitly subscribe to the question, but that will receive
836+notifications for other reasons. Answer contacts for the question target are
837+part of the indirect subscribers list.
838
839 # There are no answer contacts on the firefox product.
840- >>> [person.displayname
841- ... for person in firefox_question.getIndirectRecipients()]
842+ >>> list(firefox_question.getIndirectRecipients())
843 []
844- >>> from canonical.launchpad.interfaces import ILanguageSet
845+
846+ >>> from lp.services.worlddata.interfaces.language import ILanguageSet
847 >>> english = getUtility(ILanguageSet)['en']
848 >>> no_priv.addLanguage(english)
849 >>> firefox.addAnswerContact(no_priv)
850@@ -231,16 +246,14 @@
851 >>> indirect_subscribers = firefox_question.getIndirectRecipients()
852 >>> verifyObject(INotificationRecipientSet, indirect_subscribers)
853 True
854- >>> [person.displayname for person in indirect_subscribers]
855- [u'No Privileges Person']
856- >>> indirect_subscribers.getReason(no_priv)
857- (u'You received this question notification because you are an answer
858- contact for Mozilla Firefox.',
859- u'Answer Contact (Mozilla Firefox)')
860+ >>> print_reason(indirect_subscribers)
861+ Answer Contact (Mozilla Firefox) No Privileges Person
862+ You received this question notification because you are an answer
863+ contact for Mozilla Firefox.
864
865-There is a special case for when the question's is associated to a
866-source package. The answer contacts for both the distribution and the
867-source package are part of the indirect subscribers list.
868+There is a special case for when the question is associated with a source
869+package. The answer contacts for both the distribution and the source package
870+are part of the indirect subscribers list.
871
872 # Let's register some answer contacts for the distribution and
873 # the package.
874@@ -257,64 +270,80 @@
875 >>> package_question = evolution_in_ubuntu.newQuestion(
876 ... sample_person, 'Upgrading to Evolution 1.4 breaks plug-ins',
877 ... "The FnordsHighlighter plug-in doesn't work after upgrade.")
878- >>> [s.person.displayname for s in package_question.subscriptions]
879- [u'Sample Person']
880+
881+ >>> print_subscribers(package_question)
882+ Sample Person
883+
884 >>> indirect_subscribers = package_question.getIndirectRecipients()
885- >>> [person.displayname for person in indirect_subscribers]
886- [u'No Privileges Person', u'Ubuntu Team']
887- >>> indirect_subscribers.getReason(ubuntu_team)
888- (u'You received this question notification because you are a member of
889- Ubuntu Team, which is an answer contact for Ubuntu.',
890- u'Answer Contact (ubuntu) @ubuntu-team')
891- >>> indirect_subscribers.getReason(no_priv)
892- (u'You received this question notification because you are an answer
893- contact for evolution in ubuntu.',
894- u'Answer Contact (evolution in ubuntu)')
895+ >>> for person in indirect_subscribers:
896+ ... print person.displayname
897+ No Privileges Person
898+ Ubuntu Team
899+
900+ >>> text, header = indirect_subscribers.getReason(ubuntu_team)
901+ >>> print header, text
902+ Answer Contact (ubuntu) @ubuntu-team
903+ You received this question notification because you are a member of
904+ Ubuntu Team, which is an answer contact for Ubuntu.
905
906 The question's assignee is also part of the indirect subscription list:
907
908- >>> login('foo.bar@canonical.com')
909+ >>> login('admin@canonical.com')
910 >>> package_question.assignee = getUtility(IPersonSet).getByName('name16')
911 >>> indirect_subscribers = package_question.getIndirectRecipients()
912- >>> [person.displayname for person in indirect_subscribers]
913- [u'Foo Bar', u'No Privileges Person', u'Ubuntu Team']
914- >>> indirect_subscribers.getReason(package_question.assignee)
915- ('You received this question notification because you are the assignee
916- for this question.',
917- 'Assignee')
918+ >>> for person in indirect_subscribers:
919+ ... print person.displayname
920+ Foo Bar
921+ No Privileges Person
922+ Ubuntu Team
923+
924+ >>> text, header = indirect_subscribers.getReason(
925+ ... package_question.assignee)
926+ >>> print header, text
927+ Assignee
928+ You received this question notification because you are the assignee for
929+ this question.
930
931 The getIndirectSubscribers() method iterates like the getIndirectRecipients()
932 method, but it returns a sorted list instead of a NotificationRecipientSet.
933 It too contains the question assignee.
934
935 >>> indirect_subscribers = package_question.getIndirectSubscribers()
936- >>> [person.displayname for person in indirect_subscribers]
937- [u'Foo Bar', u'No Privileges Person', u'Ubuntu Team']
938+ >>> for person in indirect_subscribers:
939+ ... print person.displayname
940+ Foo Bar
941+ No Privileges Person
942+ Ubuntu Team
943
944-Notifications are sent to the list of direct and indirect subscribers.
945-The notification recipients list can be obtained by using the
946-getRecipients() method.
947+Notifications are sent to the list of direct and indirect subscribers. The
948+notification recipients list can be obtained by using the getRecipients()
949+method.
950
951 >>> login('no-priv@canonical.com')
952 >>> subscribers = firefox_question.getRecipients()
953 >>> verifyObject(INotificationRecipientSet, subscribers)
954 True
955- >>> [person.displayname for person in subscribers]
956- [u'No Privileges Person', u'Sample Person']
957-
958-(More documentation on the question notifications can be found in
959-'answer-tracker-notifications.txt'.)
960-
961-
962-== Workflow ==
963+ >>> for person in subscribers:
964+ ... print person.displayname
965+ No Privileges Person
966+ Sample Person
967+
968+More documentation on the question notifications can be found in
969+`answer-tracker-notifications.txt`.
970+
971+
972+Workflow
973+========
974
975 A question status should not be manipulated directly but through the
976 workflow methods.
977
978 The complete question workflow is documented in
979-'answer-tracker-workflow.txt'.
980-
981-== Bug Linking ==
982+`answer-tracker-workflow.txt`.
983+
984+
985+Bug linking
986+===========
987
988 Question implements the IBugLinkTarget interface which makes it possible
989 to link bug report to question.
990@@ -323,13 +352,13 @@
991 >>> verifyObject(IBugLinkTarget, firefox_question)
992 True
993
994-(See ../interfaces/ftests/buglinktarget.txt for the documentation and
995-test of the IBugLinkTarget interface.)
996-
997-When a bug is linked to a question, the question's owner is subscribed to
998-the bug.
999-
1000- >>> from canonical.launchpad.interfaces import IBugSet
1001+See ../../bugs/tests/buglinktarget.txt for the documentation and test of the
1002+IBugLinkTarget interface.
1003+
1004+When a bug is linked to a question, the question's owner is subscribed to the
1005+bug.
1006+
1007+ >>> from lp.bugs.interfaces.bug import IBugSet
1008 >>> bug7 = getUtility(IBugSet).get(7)
1009 >>> bug7.isSubscribed(firefox_question.owner)
1010 False
1011@@ -338,33 +367,34 @@
1012 >>> bug7.isSubscribed(firefox_question.owner)
1013 True
1014
1015-When the link is removed, the owner is unsubscribed:
1016+When the link is removed, the owner is unsubscribed.
1017
1018 >>> firefox_question.unlinkBug(bug7)
1019 <QuestionBug...>
1020 >>> bug7.isSubscribed(firefox_question.owner)
1021 False
1022
1023-== Unsupported Questions ==
1024-
1025-While a Person may ask questions in his language of choice, that does
1026-not mean that indirect subscribers (Answer Contacts) to an
1027-IQuestionTarget speak that language. IQuestionTarget can return a list
1028-Questions in languages that are not supported
1029+
1030+Unsupported questions
1031+=====================
1032+
1033+While a Person may ask questions in his language of choice, that does not mean
1034+that indirect subscribers (Answer Contacts) to an IQuestionTarget speak that
1035+language. IQuestionTarget can return a list Questions in languages that are
1036+not supported.
1037
1038 >>> unsupported_questions = firefox.searchQuestions(unsupported=True)
1039- >>> sorted([question.title for question in unsupported_questions])
1040+ >>> sorted(question.title for question in unsupported_questions)
1041 [u'Problemas de Impress\xe3o no Firefox']
1042
1043 >>> unsupported_questions = evolution_in_ubuntu.searchQuestions(
1044 ... unsupported=True)
1045- >>> sorted([question.title for question in unsupported_questions])
1046+ >>> sorted(question.title for question in unsupported_questions)
1047 []
1048
1049 >>> warty_question_target = IQuestionTarget(ubuntu_warty)
1050 >>> unsupported_questions = warty_question_target.searchQuestions(
1051 ... unsupported=True)
1052- >>> sorted([question.title for question in unsupported_questions])
1053+ >>> sorted(question.title for question in unsupported_questions)
1054 [u'Problema al recompilar kernel con soporte smp (doble-n\xfacleo)',
1055 u'\u0639\u0643\u0633 \u0627\u0644\u062a\u063a\u064a\u064a\u0631...]
1056-
1057
1058=== renamed file 'lib/lp/answers/doc/utility.txt' => 'lib/lp/answers/doc/questionsets.txt'
1059--- lib/lp/answers/doc/utility.txt 2009-03-24 12:43:49 +0000
1060+++ lib/lp/answers/doc/questionsets.txt 2010-02-10 15:24:19 +0000
1061@@ -1,69 +1,75 @@
1062-= Answer Tracker Utility: IQuestionSet =
1063+====================
1064+Question collections
1065+====================
1066
1067-There is an IQuestionSet utility that can be use to retrieve and search
1068-for question whatever the target they were created in.
1069+The IQuestionSet utility is used to retrieve and search for questions no
1070+matter which question target they were created for.
1071
1072 >>> from canonical.launchpad.webapp.testing import verifyObject
1073- >>> from canonical.launchpad.interfaces import IQuestionSet
1074+ >>> from lp.answers.interfaces.questioncollection import IQuestionSet
1075 >>> question_set = getUtility(IQuestionSet)
1076 >>> verifyObject(IQuestionSet, question_set)
1077 True
1078
1079
1080-== get() ==
1081+Retrieving questions
1082+====================
1083
1084-The get() method can be used to get a question with a specific id:
1085+The get() method can be used to retrieve a question with a specific id.
1086
1087 >>> question_one = question_set.get(1)
1088- >>> question_one.title
1089- u'Firefox cannot render Bank Site'
1090+ >>> print question_one.title
1091+ Firefox cannot render Bank Site
1092
1093-If no question exists, a default value is returned:
1094+If no question exists, a default value is returned.
1095
1096 >>> default = object()
1097 >>> question_nonexistant = question_set.get(123456, default=default)
1098 >>> question_nonexistant is default
1099 True
1100
1101-If no default value is given, None is returned:
1102-
1103- >>> question_set.get(123456) is None
1104- True
1105-
1106-
1107-== searchQuestions() ==
1108-
1109-IQuestionSet also defines a searchQuestions() method that can be used to
1110-search for questions defined in any products or distributions (in fact,
1111-in any context that allows questions to be defined). Two search criteria
1112-are defined search_text and status.
1113-
1114-
1115-=== search_text ===
1116-
1117-The search_text parameter will limit the questions to those matching
1118-the query using the regular full text algorithm.
1119-
1120+If no default value is given, None is returned.
1121+
1122+ >>> print question_set.get(123456)
1123+ None
1124+
1125+
1126+Searching questions
1127+===================
1128+
1129+The IQuestionSet interface defines a searchQuestions() method that is used to
1130+search for questions defined in any question target.
1131+
1132+
1133+Search text
1134+-----------
1135+
1136+The search_text parameter will return questions matching the query using the
1137+regular full text algorithm.
1138+
1139+ # Because not everyone uses a real editor <wink>
1140+ >>> from canonical.encoding import ascii_smash
1141 >>> for question in question_set.searchQuestions(search_text='firefox'):
1142- ... print repr(question.title), question.target.displayname
1143- u'Problemas de Impress\xe3o no Firefox' Mozilla Firefox
1144- u'Firefox loses focus and gets stuck' Mozilla Firefox
1145- u'Firefox cannot render Bank Site' Mozilla Firefox
1146- u'mailto: problem in webpage' mozilla-firefox in ubuntu
1147- u"Newly installed plug-in doesn't seem to be used" Mozilla Firefox
1148- u'Problem showing the SVG demo on W3C site' Mozilla Firefox
1149- u'\u0639\u0643\u0633 ...' Ubuntu
1150-
1151-
1152-=== status ===
1153-
1154-By default, expired and invalid questions are not searched for. The
1155-status parameter can be used to select the questions in the status
1156-you are interested in.
1157-
1158- >>> from canonical.launchpad.interfaces import QuestionStatus
1159+ ... print ascii_smash(question.title), question.target.displayname
1160+ Problemas de Impressao no Firefox Mozilla Firefox
1161+ Firefox loses focus and gets stuck Mozilla Firefox
1162+ Firefox cannot render Bank Site Mozilla Firefox
1163+ mailto: problem in webpage mozilla-firefox in ubuntu
1164+ Newly installed plug-in doesn't seem to be used Mozilla Firefox
1165+ Problem showing the SVG demo on W3C site Mozilla Firefox
1166+ AINKAFSEEN ALEFLAMTEHGHAINYEHYEHREHALEFTEH ... Ubuntu
1167+
1168+
1169+Status
1170+------
1171+
1172+By default, expired and invalid questions are not searched for. The status
1173+parameter can be used to select the questions in the status you are interested
1174+in.
1175+
1176+ >>> from lp.answers.interfaces.questionenums import QuestionStatus
1177 >>> for question in question_set.searchQuestions(
1178- ... status=QuestionStatus.INVALID):
1179+ ... status=QuestionStatus.INVALID):
1180 ... print question.title, question.status.title, (
1181 ... question.target.displayname)
1182 Firefox is slow and consumes too much RAM Invalid mozilla-firefox in ubuntu
1183@@ -78,111 +84,118 @@
1184 Firefox is slow and consumes too much RAM Invalid mozilla-firefox in ubuntu
1185
1186
1187-=== language ===
1188+Language
1189+--------
1190
1191 The language parameter can be used to select only questions written in a
1192 particular language.
1193
1194- >>> from canonical.launchpad.interfaces import ILanguageSet
1195+ >>> from lp.services.worlddata.interfaces.language import ILanguageSet
1196 >>> spanish = getUtility(ILanguageSet)['es']
1197 >>> for t in question_set.searchQuestions(language=spanish):
1198- ... print t.title.encode('us-ascii', 'backslashreplace')
1199- Problema al recompilar kernel con soporte smp (doble-n\xfacleo)
1200-
1201-=== Combination ===
1202-
1203-The returned sets of questions is the intersection of the sets delimited
1204-by each criteria:
1205+ ... print ascii_smash(t.title)
1206+ Problema al recompilar kernel con soporte smp (doble-nucleo)
1207+
1208+
1209+Combinations
1210+------------
1211+
1212+The returned set of questions is the intersection of the sets delimited by
1213+each criteria.
1214
1215 >>> for question in question_set.searchQuestions(
1216 ... search_text='firefox',
1217- ... status=[QuestionStatus.OPEN, QuestionStatus.INVALID]):
1218- ... print repr(question.title), question.status.title, (
1219+ ... status=(QuestionStatus.OPEN, QuestionStatus.INVALID)):
1220+ ... print ascii_smash(question.title), question.status.title, (
1221 ... question.target.displayname)
1222- u'Problemas de Impress\xe3o no Firefox' Open Mozilla Firefox
1223- u'Firefox is slow and consumes too much RAM' Invalid mozilla-firefox in ubuntu
1224- u'Firefox loses focus and gets stuck' Open Mozilla Firefox
1225- u'Firefox cannot render Bank Site' Open Mozilla Firefox
1226- u'Problem showing the SVG demo on W3C site' Open Mozilla Firefox
1227- u'\u0639\u0643\u0633 ...' Open Ubuntu
1228-
1229-
1230-=== Sort Order ===
1231-
1232-When using the search_text criteria, the default is to sort the results
1233-by relevancy. One can use the sort parameter to change that. It takes
1234-one of the constant defined in the QuestionSort enumeration.
1235-
1236- >>> from canonical.launchpad.interfaces import QuestionSort
1237+ Problemas de Impressao no Firefox Open Mozilla Firefox
1238+ Firefox is slow and consumes too much RAM Invalid mozilla-firefox in ubuntu
1239+ Firefox loses focus and gets stuck Open Mozilla Firefox
1240+ Firefox cannot render Bank Site Open Mozilla Firefox
1241+ Problem showing the SVG demo on W3C site Open Mozilla Firefox
1242+ AINKAFSEEN ALEFLAMTEHGHAINYEHYEHREHALEFTEH ... Ubuntu
1243+
1244+
1245+Sort order
1246+----------
1247+
1248+When using the search_text criteria, the default is to sort the results by
1249+relevancy. One can use the sort parameter to change the order. It takes one
1250+of the constant defined in the QuestionSort enumeration.
1251+
1252+ >>> from lp.answers.interfaces.questionenums import QuestionSort
1253 >>> for question in question_set.searchQuestions(
1254 ... search_text='firefox', sort=QuestionSort.OLDEST_FIRST):
1255- ... print question.id, repr(question.title), (
1256+ ... print question.id, ascii_smash(question.title), (
1257 ... question.target.displayname)
1258- 14 u'\u0639\u0643\u0633 ...' Ubuntu
1259- 1 u'Firefox cannot render Bank Site' Mozilla Firefox
1260- 2 u'Problem showing the SVG demo on W3C site' Mozilla Firefox
1261- 4 u'Firefox loses focus and gets stuck' Mozilla Firefox
1262- 6 u"Newly installed plug-in doesn't seem to be used" Mozilla Firefox
1263- 9 u'mailto: problem in webpage' mozilla-firefox in ubuntu
1264- 13 u'Problemas de Impress\xe3o no Firefox' Mozilla Firefox
1265+ 14 AINKAFSEEN ALEFLAMTEHGHAINYEHYEHREHALEFTEH ... Ubuntu
1266+ 1 Firefox cannot render Bank Site Mozilla Firefox
1267+ 2 Problem showing the SVG demo on W3C site Mozilla Firefox
1268+ 4 Firefox loses focus and gets stuck Mozilla Firefox
1269+ 6 Newly installed plug-in doesn't seem to be used Mozilla Firefox
1270+ 9 mailto: problem in webpage mozilla-firefox in ubuntu
1271+ 13 Problemas de Impressao no Firefox Mozilla Firefox
1272
1273-When no text search is done, the default sort order is
1274-QuestionSort.NEWEST_FIRST.
1275+When no text search is done, the default sort order is by newest first.
1276
1277 >>> for question in question_set.searchQuestions(
1278- ... status=QuestionStatus.OPEN)[:5]:
1279- ... print question.id, repr(question.title), (
1280+ ... status=QuestionStatus.OPEN)[:5]:
1281+ ... print question.id, ascii_smash(question.title), (
1282 ... question.target.displayname)
1283- 13 u'Problemas de Impress\xe3o no Firefox' Mozilla Firefox
1284- 12 u'Problema al recompilar kernel con soporte smp (doble-n\xfacleo)' Ubuntu
1285- 11 u'Continue playing after shutdown' Ubuntu
1286- 5 u'Installation failed' Ubuntu
1287- 4 u'Firefox loses focus and gets stuck' Mozilla Firefox
1288-
1289-
1290-== getQuestionLanguages() ==
1291+ 13 Problemas de Impressao no Firefox Mozilla Firefox
1292+ 12 Problema al recompilar kernel con soporte smp (doble-nucleo) Ubuntu
1293+ 11 Continue playing after shutdown Ubuntu
1294+ 5 Installation failed Ubuntu
1295+ 4 Firefox loses focus and gets stuck Mozilla Firefox
1296+
1297+
1298+Question languages
1299+==================
1300
1301 The getQuestionLanguages() method returns the set of languages in which
1302-questions are written in Launchpad.
1303-
1304- >>> sorted([language.code
1305- ... for language in question_set.getQuestionLanguages()])
1306- [u'ar', u'en', u'es', u'pt_BR']
1307-
1308-
1309-== getActiveProjects() ==
1310-
1311-This method can be used to retrieve the projects that are the most
1312-actively using the Answer Tracker in the last 60 days. By active, we
1313-mean that the project is registered as officially using Answers and
1314-had some questions asked in the period. The projects are ordered
1315-by the number of questions asked during the period.
1316-
1317-Sample data should not contain any questions more recent than
1318-two months, so no projects are initially returned:
1319-
1320- >>> for project in question_set.getMostActiveProjects():
1321- ... print project.displayname
1322-
1323-Create recent questions on a number of projects.
1324-
1325- >>> from lp.answers.testing import (
1326- ... QuestionFactory)
1327- >>> from canonical.launchpad.interfaces import (
1328- ... IDistributionSet, ILaunchBag, IProductSet)
1329- >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
1330+questions are written in launchpad.
1331+
1332+ >>> print ', '.join(
1333+ ... sorted(language.code
1334+ ... for language in question_set.getQuestionLanguages()))
1335+ ar, en, es, pt_BR
1336+
1337+
1338+Active projects
1339+===============
1340+
1341+This method can be used to retrieve the projects that are the most actively
1342+using the Answer Tracker in the last 60 days. By active, we mean that the
1343+project is registered as officially using Answers and had some questions asked
1344+in the period. The projects are ordered by the number of questions asked
1345+during the period.
1346+
1347+Initially, no projects are returned.
1348+
1349+ >>> list(question_set.getMostActiveProjects())
1350+ []
1351+
1352+Then some recent questions are created on a number of projects.
1353+
1354+ >>> from lp.answers.testing import QuestionFactory
1355+ >>> from lp.registry.interfaces.distribution import IDistributionSet
1356+ >>> from lp.registry.interfaces.person import IPersonSet
1357+ >>> from lp.registry.interfaces.product import IProductSet
1358+
1359 >>> firefox = getUtility(IProductSet).getByName('firefox')
1360 >>> landscape = getUtility(IProductSet).getByName('landscape')
1361 >>> launchpad = getUtility(IProductSet).getByName('launchpad')
1362+ >>> no_priv = getUtility(IPersonSet).getByName('no-priv')
1363+ >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
1364
1365 >>> login('no-priv@canonical.com')
1366- >>> no_priv = getUtility(ILaunchBag).user
1367- >>> QuestionFactory.createManyByProject([
1368+ >>> QuestionFactory.createManyByProject((
1369 ... ('ubuntu', 3),
1370 ... ('firefox', 2),
1371- ... ('landscape', 1)])
1372+ ... ('landscape', 1),
1373+ ... ))
1374
1375-Create a question just before the time limit on Launchpad.
1376+A question is created just before the time limit on Launchpad.
1377
1378 >>> from datetime import datetime, timedelta
1379 >>> from pytz import UTC
1380@@ -191,9 +204,9 @@
1381 ... datecreated=datetime.now(UTC) - timedelta(days=61))
1382 >>> login(ANONYMOUS)
1383
1384-The method returns only projects which officially use the Answer
1385-Tracker. The order of the returned projects is based on the number of
1386-questions asked during the period.
1387+The method returns only projects which officially use the Answer Tracker. The
1388+order of the returned projects is based on the number of questions asked
1389+during the period.
1390
1391 >>> ubuntu.official_answers
1392 True
1393@@ -204,14 +217,13 @@
1394 >>> launchpad.official_answers
1395 True
1396
1397+ # Launchpad is not returned because the question was not asked in
1398+ # the last 60 days.
1399 >>> for project in question_set.getMostActiveProjects():
1400 ... print project.displayname
1401 Ubuntu
1402 Mozilla Firefox
1403
1404-(Launchpad is not returned because the question was not asked in
1405-the last 60 days.)
1406-
1407 The method accepts an optional limit parameter limiting the number of
1408 project returned:
1409
1410@@ -220,10 +232,11 @@
1411 Ubuntu
1412
1413
1414-== getOpenQuestionCountByPackages() ==
1415+Counting the open questions
1416+===========================
1417
1418-getOpenQuestionCountByPackages() allow you to get the count of open
1419-questions on a set of IDistributionSourcePackage packages.
1420+getOpenQuestionCountByPackages() allow you to get the count of open questions
1421+on a set of IDistributionSourcePackage packages.
1422
1423 >>> question_set.getOpenQuestionCountByPackages([])
1424 {}
1425@@ -246,12 +259,10 @@
1426 >>> closed_question.setStatus(
1427 ... closed_question.owner, QuestionStatus.SOLVED, 'no comment')
1428 <QuestionMessage at ...>
1429- >>> from canonical.launchpad.ftests import syncUpdate
1430- >>> syncUpdate(closed_question)
1431
1432 >>> from operator import itemgetter
1433- >>> packages = [
1434- ... ubuntu_evolution, ubuntu_pmount, debian_evolution, debian_pmount]
1435+ >>> packages = (
1436+ ... ubuntu_evolution, ubuntu_pmount, debian_evolution, debian_pmount)
1437 >>> package_counts = question_set.getOpenQuestionCountByPackages(packages)
1438 >>> len(packages)
1439 4
1440@@ -263,5 +274,3 @@
1441 pmount (Ubuntu): 4
1442 evolution (Debian): 3
1443 pmount (Debian): 0
1444-
1445-
1446
1447=== modified file 'lib/lp/answers/doc/questiontarget.txt'
1448--- lib/lp/answers/doc/questiontarget.txt 2009-03-24 12:43:49 +0000
1449+++ lib/lp/answers/doc/questiontarget.txt 2010-02-10 15:24:19 +0000
1450@@ -1,38 +1,43 @@
1451-= IQuestionTarget Interface =
1452-
1453-Launchpad includes an answer tracker. Questions are associated to
1454-objects implementing IQuestionTarget. This file documents that interface
1455-and can be used to validate implementation of this interface on a
1456-particular object. (This object is made available through the 'target'
1457-variable which is defined outside of this file, usually by a
1458-LaunchpadFunctionalTestCase. This instance shouldn't have any questions
1459-associated with it at the start of the test.)
1460-
1461+=========================
1462+IQuestionTarget interface
1463+=========================
1464+
1465+Launchpad includes an answer tracker. Questions are associated to objects
1466+implementing IQuestionTarget.
1467+
1468+ # An IQuestionTarget object is made available to this test via the
1469+ # 'target' variable by the test framework. It won't have any questions
1470+ # associated with it at the start of the test. This is done because the
1471+ # exact same test applies to all types of question targets: products,
1472+ # distributions, and distribution source packages.
1473+ #
1474 # Some parts of the IQuestionTarget interface are only accessible
1475 # to a registered user.
1476 >>> login('no-priv@canonical.com')
1477
1478 >>> from zope.component import getUtility
1479 >>> from zope.interface.verify import verifyObject
1480- >>> from canonical.launchpad.interfaces import IQuestionTarget
1481+ >>> from lp.answers.interfaces.questiontarget import IQuestionTarget
1482
1483 >>> verifyObject(IQuestionTarget, target)
1484 True
1485
1486-== newQuestion() ==
1487+
1488+New questions
1489+=============
1490
1491 Questions are always owned by a registered user.
1492
1493- >>> from canonical.launchpad.interfaces import IPersonSet
1494+ >>> from lp.registry.interfaces.person import IPersonSet
1495 >>> sample_person = getUtility(IPersonSet).getByEmail(
1496 ... 'test@canonical.com')
1497
1498-The newQuestion() method is used to create question that will be associated
1499-with the target. It takes as parameters the question's owner, title and
1500-description. It also takes an optional parameter 'datecreated' parameter
1501-which defaults to UTC_NOW.
1502+The newQuestion() method is used to create a question that will be associated
1503+with the target. It takes as parameters the question's owner, title and
1504+description. It also takes an optional parameter 'datecreated' which defaults
1505+to UTC_NOW.
1506
1507- # Let's define now to a know value.
1508+ # Initialize 'now' to a known value.
1509 >>> from datetime import datetime, timedelta
1510 >>> from pytz import UTC
1511 >>> now = datetime.now(UTC)
1512@@ -43,8 +48,8 @@
1513 New question
1514 >>> print question.description
1515 Question description
1516- >>> question.owner == sample_person
1517- True
1518+ >>> print question.owner.displayname
1519+ Sample Person
1520 >>> question.datecreated == now
1521 True
1522 >>> question.datelastquery == now
1523@@ -53,41 +58,44 @@
1524 The created question starts in the 'Open' status and should have the owner
1525 subscribed to the question.
1526
1527- >>> question.status.title
1528- 'Open'
1529-
1530- >>> sample_person in [s.person for s in question.subscriptions]
1531- True
1532-
1533-Question can be written in any languages supported in Launchpad. The
1534-language of the request is available in the 'language' attribute. By
1535-default, requests are assumed to be written in English:
1536+ >>> print question.status.title
1537+ Open
1538+
1539+ >>> for subscription in question.subscriptions:
1540+ ... print subscription.person.displayname
1541+ Sample Person
1542+
1543+Questions can be written in any languages supported in Launchpad. The
1544+language of the request is available in the 'language' attribute. By default,
1545+requests are assumed to be written in English.
1546
1547 >>> print question.language.code
1548 en
1549
1550-It is possible to create question in another language than English. One
1551-just need to pass the language in which the question is written in the
1552-language parameter.
1553+It is possible to create questions in another language than English, by
1554+passing in the language that the question is written in.
1555
1556- >>> from canonical.launchpad.interfaces import ILanguageSet
1557+ >>> from lp.services.worlddata.interfaces.language import ILanguageSet
1558 >>> french = getUtility(ILanguageSet)['fr']
1559- >>> question = target.newQuestion(sample_person, "De l'aide S.V.P.",
1560+ >>> question = target.newQuestion(
1561+ ... sample_person, "De l'aide S.V.P.",
1562 ... "Pouvez-vous m'aider?", language=french,
1563 ... datecreated=now + timedelta(seconds=30))
1564 >>> print question.language.code
1565 fr
1566
1567-Anonymous users cannot use newQuestion():
1568+Anonymous users cannot use newQuestion().
1569
1570 >>> login(ANONYMOUS)
1571- >>> question = target.newQuestion(sample_person, 'This will fail',
1572- ... 'Failed?')
1573+ >>> question = target.newQuestion(
1574+ ... sample_person, 'This will fail', 'Failed?')
1575 Traceback (most recent call last):
1576 ...
1577 Unauthorized...
1578
1579-== getQuestion() ==
1580+
1581+Retrieving questions
1582+====================
1583
1584 The getQuestion() method is used to retrieve a question by id for a
1585 particular target.
1586@@ -96,19 +104,19 @@
1587 True
1588
1589 If you pass in a non-existent id or a question for a different target, the
1590-method must return None.
1591-
1592- >>> target.getQuestion(2) is None
1593- True
1594- >>> target.getQuestion(12345) is None
1595- True
1596-
1597-== Creating some additional questions ==
1598-
1599-For the following methods, we will require some more questions. Create five
1600-new questions. Odd questions will be owned by foo_bar and even questions will be
1601-owned by sample_person.
1602-
1603+method returns None.
1604+
1605+ >>> print target.getQuestion(2)
1606+ None
1607+ >>> print target.getQuestion(12345)
1608+ None
1609+
1610+
1611+Searching for questions
1612+=======================
1613+
1614+ # Create new questions for the following tests. Odd questions will be
1615+ # owned by Foo Bar and even questions will be owned by Sample Person.
1616 >>> login('no-priv@canonical.com')
1617 >>> foo_bar = getUtility(IPersonSet).getByEmail('foo.bar@canonical.com')
1618 >>> questions = []
1619@@ -123,9 +131,8 @@
1620 ... owner, 'Question title%d' % num, description,
1621 ... datecreated=now+timedelta(minutes=num+1)))
1622
1623-For more variety, we will set the status of the last to INVALID and the
1624-fourth one to ANSWERED.
1625-
1626+ # For more variety, we will set the status of the last to INVALID and the
1627+ # fourth one to ANSWERED.
1628 >>> login('foo.bar@canonical.com')
1629 >>> foo_bar = getUtility(IPersonSet).getByEmail('foo.bar@canonical.com')
1630 >>> message = questions[-1].reject(
1631@@ -134,48 +141,44 @@
1632 ... sample_person, 'This is your answer.',
1633 ... datecreated=now+timedelta(hours=1))
1634
1635-Also add a reply from the owner on the first of these.
1636-
1637+ # Also add a reply from the owner on the first of these.
1638 >>> login('test@canonical.com')
1639 >>> message = questions[0].giveInfo(
1640 ... 'I think I forgot something.', datecreated=now+timedelta(hours=4))
1641
1642-And create another one that will also have the word 'new' in its
1643-description.
1644-
1645+ # Create another one that will also have the word 'new' in its
1646+ # description.
1647 >>> question = target.newQuestion(sample_person, 'Another question',
1648 ... 'Another new question that is actually very new.',
1649 ... datecreated=now+timedelta(hours=1))
1650 >>> login(ANONYMOUS)
1651
1652- # Flush those changes to the database.
1653- >>> from canonical.database.sqlbase import flush_database_updates
1654- >>> flush_database_updates()
1655-
1656-== searchQuestions() ==
1657-
1658 The searchQuestions() method is used to search for questions.
1659
1660-=== search_text ===
1661+
1662+Search text
1663+-----------
1664
1665 The search_text parameter will select the questions that contain the
1666-passed in text. (The standard text searching algorithm is used, see
1667-textsearching.txt.)
1668+passed in text. The standard text searching algorithm is used; see
1669+../../../canonical/launchpad/doct/textsearching.txt.
1670
1671 >>> for t in target.searchQuestions(search_text='new'):
1672 ... print t.title
1673 New question
1674 Another question
1675
1676-The results here are sorted by relevancy. (In the last questions, 'New'
1677-appeared in the description which makes it less relevant than when the
1678-word appears in the title.)
1679-
1680-=== status ===
1681-
1682-The searchQuestions() method can also filter questions by status:
1683-
1684- >>> from canonical.launchpad.interfaces import QuestionStatus
1685+The results are sorted by relevancy. In the last questions, 'New' appeared in
1686+the description which makes it less relevant than when the word appears in the
1687+title.
1688+
1689+
1690+Status
1691+------
1692+
1693+The searchQuestions() method can also filter questions by status.
1694+
1695+ >>> from lp.answers.interfaces.questionenums import QuestionStatus
1696 >>> for t in target.searchQuestions(status=QuestionStatus.OPEN):
1697 ... print t.title
1698 Another question
1699@@ -192,9 +195,9 @@
1700 ... print t.title
1701 Question title4
1702
1703-You can also pass in a list of status, and you can also use the
1704-search_text and status parameters at the same time. This will search
1705-OPEN and INVALID questions with the word 'index'
1706+You can pass in a list of statuses, and you can also use the search_text and
1707+status parameters at the same time. This will search OPEN and INVALID
1708+questions with the word 'index'.
1709
1710 >>> for t in target.searchQuestions(search_text='request index',
1711 ... status=(QuestionStatus.OPEN, QuestionStatus.INVALID)):
1712@@ -204,29 +207,30 @@
1713 Question title1
1714 Question title0
1715
1716-=== sort ===
1717-
1718-You can control the sort order by passing one of the constants defined
1719-in QuestionSort. (We already saw the NEWEST_FIRST and RELEVANCY sort
1720-order).
1721-
1722-You can sort also from oldest to newest using the OLDEST_FIRST constant:
1723-
1724- >>> from canonical.launchpad.interfaces import QuestionSort
1725-
1726+
1727+Sorting
1728+-------
1729+
1730+You can control the sort order by passing one of the constants defined in
1731+QuestionSort. Previously, we saw the NEWEST_FIRST and RELEVANCY sort order.
1732+
1733+You can sort also from oldest to newest using the OLDEST_FIRST constant.
1734+
1735+ >>> from lp.answers.interfaces.questionenums import QuestionSort
1736 >>> for t in target.searchQuestions(search_text='new',
1737- ... sort=QuestionSort.OLDEST_FIRST):
1738+ ... sort=QuestionSort.OLDEST_FIRST):
1739 ... print t.title
1740 New question
1741 Another question
1742
1743-You can sort by status, (the status order is OPEN, NEEDSINFO, ANSWERED,
1744-SOLVED, EXPIRED, INVALID), this also sorts from newest to oldest as a
1745-secondary key.
1746+You can sort by status (the status order is OPEN, NEEDSINFO, ANSWERED, SOLVED,
1747+EXPIRED, INVALID). This also sorts from newest to oldest as a secondary key.
1748+Here we use status=None to search for all statuses; by default INVALID and
1749+EXPIRED questions are excluded.
1750
1751 >>> for t in target.searchQuestions(search_text='request index',
1752- ... status=None,
1753- ... sort=QuestionSort.STATUS):
1754+ ... status=None,
1755+ ... sort=QuestionSort.STATUS):
1756 ... print t.status.title, t.title
1757 Open Question title2
1758 Open Question title1
1759@@ -234,12 +238,11 @@
1760 Answered Question title3
1761 Invalid Question title4
1762
1763-(In the previous example, we used status=None to search for all
1764-statuses, by default INVALID and EXPIRED questions are excluded.)
1765-
1766 If there is no search_text and the requested sort order is RELEVANCY,
1767 the questions will be sorted NEWEST_FIRST.
1768
1769+ # 'Question title4' is not shown in this case because it has INVALID as
1770+ # its status.
1771 >>> for t in target.searchQuestions(sort=QuestionSort.RELEVANCY):
1772 ... print t.title
1773 Another question
1774@@ -250,14 +253,14 @@
1775 De l'aide S.V.P.
1776 New question
1777
1778-('Question title4' is not shown in this case because it has INVALID as
1779-its status.)
1780-
1781 The RECENT_OWNER_ACTIVITY sort order sorts first questions which recently
1782-received a new message by their owner. (It effectively sorts
1783-descending on the datelastquery attribute.)
1784+received a new message by their owner. It effectively sorts descending on the
1785+datelastquery attribute.
1786
1787- >>> for t in target.searchQuestions(sort=QuestionSort.RECENT_OWNER_ACTIVITY):
1788+ # Question title0 sorts first because it has a message from its owner
1789+ # after the others were created.
1790+ >>> for t in target.searchQuestions(
1791+ ... sort=QuestionSort.RECENT_OWNER_ACTIVITY):
1792 ... print t.title
1793 Question title0
1794 Another question
1795@@ -267,20 +270,20 @@
1796 De l'aide S.V.P.
1797 New question
1798
1799-(Question title0 sorts first because it had a message from its owner
1800-after the others were created.)
1801-
1802-=== owner ===
1803-
1804-You can also find question owner by a particular user by using the owner
1805-parameter.
1806+
1807+Owner
1808+-----
1809+
1810+You can find question owned by a particular user by using the owner parameter.
1811
1812 >>> for t in target.searchQuestions(owner=foo_bar):
1813 ... print t.title
1814 Question title3
1815 Question title1
1816
1817-=== language ===
1818+
1819+Language
1820+---------
1821
1822 The language criteria can be used to select only questions written in a
1823 particular language.
1824@@ -290,7 +293,7 @@
1825 ... print t.title
1826 De l'aide S.V.P.
1827
1828- >>> for t in target.searchQuestions(language=[english, french]):
1829+ >>> for t in target.searchQuestions(language=(english, french)):
1830 ... print t.title
1831 Another question
1832 Question title3
1833@@ -300,39 +303,40 @@
1834 De l'aide S.V.P.
1835 New question
1836
1837-=== needs_attention_from ===
1838-
1839-You can also search among the questions that needs the attention of
1840-somebody. A question needs the attention of a user if he owns it and that
1841-it is in the NEEDSINFO or ANSWERED state. Questions on which the user gave
1842-an answer or requested for more information and that are back in the
1843-OPEN state are also included.
1844+
1845+Questions needing attention
1846+---------------------------
1847+
1848+You can search among the questions that need attention. A question needs the
1849+attention of a user if he owns it and if it is in the NEEDSINFO or ANSWERED
1850+state. Questions on which the user gave an answer or requested for more
1851+information, and that are back in the OPEN state, are also included.
1852
1853 # One of Sample Person's question gets to need attention from Foo Bar.
1854 >>> login('foo.bar@canonical.com')
1855 >>> message = questions[0].requestInfo(
1856 ... foo_bar, 'Do you have a clue?',
1857 ... datecreated=now+timedelta(hours=1))
1858+
1859 >>> login('test@canonical.com')
1860 >>> message = questions[0].giveInfo(
1861- ... 'I do, now please help me.', datecreated=now+timedelta(hours=2))
1862+ ... 'I do, now please help me.', datecreated=now+timedelta(hours=2))
1863
1864- # Another one of Foo Bar's question needs attention.
1865+ # Another one of Foo Bar's questions needs attention.
1866 >>> message = questions[1].requestInfo(
1867 ... sample_person, 'And you, do you have a clue?',
1868 ... datecreated=now+timedelta(hours=1))
1869
1870- # Flush those changes to the database.
1871- >>> flush_database_updates()
1872 >>> login(ANONYMOUS)
1873-
1874 >>> for t in target.searchQuestions(needs_attention_from=foo_bar):
1875 ... print t.status.title, t.title, t.owner.displayname
1876 Answered Question title3 Foo Bar
1877 Needs information Question title1 Foo Bar
1878 Open Question title0 Sample Person
1879
1880-=== unsupported ===
1881+
1882+Unsupported language
1883+--------------------
1884
1885 The 'unsupported' criteria is used to select questions that are in a
1886 language that is not spoken by any of the Support Contacts.
1887@@ -341,40 +345,42 @@
1888 ... print t.title
1889 De l'aide S.V.P.
1890
1891-== findSimilarQuestions() ==
1892-
1893-The method findSimilarQuestions() can be use to find questions similar to a
1894-sentence. The questions don't have to contain all the words of the sentence,
1895-just some.
1896-
1897+
1898+Finding similar questions
1899+=========================
1900+
1901+The method findSimilarQuestions() can be use to find questions similar to some
1902+target text. The questions don't have to contain all the words of the text.
1903+
1904+ # This returns the same results as with the search 'new' because
1905+ # all other words in the text are either common ('question', 'title') or
1906+ # stop words ('with', 'a').
1907 >>> for t in target.findSimilarQuestions('new questions with a title'):
1908 ... print t.title
1909 New question
1910 Another question
1911
1912-In this case, it returned the same results than with the search 'new' because
1913-all other words in the sentence are either common ('question', 'title') or stop
1914-words ('with', 'a').
1915-
1916-== Answer contacts ==
1917-
1918-Target can have answer contacts. The list of answer contacts for a
1919+
1920+Answer contacts
1921+===============
1922+
1923+Targets can have answer contacts. The list of answer contacts for a
1924 target is available through the answer_contacts attribute.
1925
1926 >>> list(target.answer_contacts)
1927 []
1928
1929-There is also a direct_answer_contacts which includes only the
1930-answer contacts registered explicitly on the question target. (In
1931-general, it will be equal to answer_contacts attribute, but some
1932-IQuestionTarget implementations may inherit answer contacts
1933-from other context. In these cases, that attribute would only contain
1934-the answer contacts defined in the current IQuestionTarget context.)
1935+There is also a direct_answer_contacts which includes only the answer contacts
1936+registered explicitly on the question target. In general, this will be the
1937+same as the answer_contacts attribute, but some IQuestionTarget
1938+implementations may inherit answer contacts from other contexts. In these
1939+cases, the direct_answer_contacts attribute would only contain the answer
1940+contacts defined in the current IQuestionTarget context.
1941
1942 >>> list(target.direct_answer_contacts)
1943 []
1944
1945-You add an answer contact by using the addAnswerContact method. This
1946+You add an answer contact by using the addAnswerContact() method. This
1947 is only available to registered users.
1948
1949 >>> name18 = getUtility(IPersonSet).getByName('name18')
1950@@ -382,22 +388,28 @@
1951 Traceback (most recent call last):
1952 ...
1953 Unauthorized...
1954+
1955+This method returns True when the contact was added the list and False when it
1956+was already on the list.
1957+
1958 >>> login('no-priv@canonical.com')
1959-
1960-This method will return True when the contact was added the list and
1961-False when it was already on the list:
1962-
1963 >>> target.addAnswerContact(name18)
1964 True
1965- >>> [p.name for p in target.answer_contacts]
1966- [u'name18']
1967- >>> [p.name for p in target.direct_answer_contacts]
1968- [u'name18']
1969+ >>> people = [p.name for p in target.answer_contacts]
1970+ >>> len(people)
1971+ 1
1972+ >>> print people[0]
1973+ name18
1974+ >>> people = [p.name for p in target.direct_answer_contacts]
1975+ >>> len(people)
1976+ 1
1977+ >>> print people[0]
1978+ name18
1979 >>> target.addAnswerContact(name18)
1980 False
1981
1982-An answer contact must have at least one language among his
1983-preferred languages.
1984+An answer contact must have at least one language among his preferred
1985+languages.
1986
1987 >>> sample_person = getUtility(IPersonSet).getByName('name12')
1988 >>> len(sample_person.languages)
1989@@ -407,10 +419,9 @@
1990 ...
1991 AssertionError: An Answer Contact must speak a language...
1992
1993-Answer contacts can be removed by using the removeAnswerContact()
1994-method. Like its counterpart, it returns True when the answer contact
1995-was removed and False when the person wasn't on the answer contact
1996-list.
1997+Answer contacts can be removed by using the removeAnswerContact() method.
1998+Like its counterpart, it returns True when the answer contact was removed and
1999+False when the person wasn't on the answer contact list.
2000
2001 >>> target.removeAnswerContact(name18)
2002 True
2003@@ -421,7 +432,7 @@
2004 >>> target.removeAnswerContact(name18)
2005 False
2006
2007-Only registered users can remove an answer contact:
2008+Only registered users can remove an answer contact.
2009
2010 >>> login(ANONYMOUS)
2011 >>> target.removeAnswerContact(name18)
2012@@ -429,85 +440,102 @@
2013 ...
2014 Unauthorized...
2015
2016-== Supported Languages ==
2017+
2018+Supported languages
2019+===================
2020
2021 The supported languages for a given IQuestionTarget are given by
2022-getSupportedLanguages(). The supported languages of a question target
2023-include all languages spoken by at least one of its answer contacts,
2024-with the exception of all English variations. English is the assumed
2025-language for support when there are no answer contacts.
2026-
2027- >>> [lang.code for lang in target.getSupportedLanguages()]
2028- [u'en']
2029-
2030-Let's add some answer contacts which speak different languages.
2031-
2032+getSupportedLanguages(). The supported languages of a question target include
2033+all languages spoken by at least one of its answer contacts, with the
2034+exception of all English variations since English is the assumed language for
2035+support when there are no answer contacts.
2036+
2037+ >>> codes = [lang.code for lang in target.getSupportedLanguages()]
2038+ >>> len(codes)
2039+ 1
2040+ >>> print codes[0]
2041+ en
2042+
2043+ # Let's add some answer contacts which speak different languages.
2044 >>> login('carlos@canonical.com')
2045 >>> carlos = getUtility(IPersonSet).getByName('carlos')
2046- >>> [lang.code for lang in carlos.languages]
2047- [u'ca', u'en', u'es']
2048+ >>> for language in carlos.languages:
2049+ ... print language.code
2050+ ca
2051+ en
2052+ es
2053 >>> target.addAnswerContact(carlos)
2054 True
2055
2056-Note that daf has en_GB as one of his preferred languages...
2057+While daf has en_GB as one of his preferred languages...
2058
2059 >>> login('daf@canonical.com')
2060 >>> daf = getUtility(IPersonSet).getByName('daf')
2061- >>> [lang.code for lang in daf.languages]
2062- [u'en_GB', u'ja', u'cy']
2063+ >>> for language in daf.languages:
2064+ ... print language.code
2065+ en_GB
2066+ ja
2067+ cy
2068 >>> target.addAnswerContact(daf)
2069 True
2070
2071-... but en_GB is not included in the target's supported languages,
2072-because we convert all English variants to English.
2073-
2074- >>> import operator
2075- >>> [lang.code for lang in sorted(target.getSupportedLanguages(),
2076- ... key=operator.attrgetter('code'))]
2077- [u'ca', u'cy', u'en', u'es', u'ja']
2078-
2079-
2080-== getAnswerContactsForLanguage() ==
2081-
2082-Continuing from the previous section with Carlos and Daf, the
2083-getAnswerContactsForLanguage() method returns a list of answer contacts
2084-who support the specified language in their preferred languages. Daf
2085-is in the list because he speaks an English variant, which is treated
2086-as English.
2087+...en_GB is not included in the target's supported languages, because all
2088+English variants are converted to English.
2089+
2090+ >>> from operator import attrgetter
2091+ >>> print ', '.join(
2092+ ... language.code
2093+ ... for language in sorted(target.getSupportedLanguages(),
2094+ ... key=attrgetter('code')))
2095+ ca, cy, en, es, ja
2096+
2097+
2098+Answer contacts for languages
2099+=============================
2100+
2101+getAnswerContactsForLanguage() method returns a list of answer contacts who
2102+support the specified language in their preferred languages. Daf is in the
2103+list because he speaks an English variant, which is treated as English.
2104
2105 >>> spanish = getUtility(ILanguageSet)['es']
2106 >>> answer_contacts = target.getAnswerContactsForLanguage(spanish)
2107- >>> sorted([person.name for person in answer_contacts])
2108- [u'carlos']
2109+ >>> for person in answer_contacts:
2110+ ... print person.name
2111+ carlos
2112
2113 >>> answer_contacts = target.getAnswerContactsForLanguage(english)
2114- >>> sorted([person.name for person in answer_contacts])
2115- [u'carlos', u'daf']
2116-
2117-
2118-== getQuestionLanguages() ==
2119+ >>> for person in answer_contacts:
2120+ ... print person.name
2121+ carlos
2122+ daf
2123+
2124+
2125+A question's languages
2126+======================
2127
2128 The getQuestionLanguages() method returns the set of languages used by all
2129 of the target's questions.
2130
2131- >>> sorted([language.code for language in target.getQuestionLanguages()])
2132- [u'en', u'fr']
2133-
2134-
2135-== createQuestionFromBug() ==
2136-
2137-The target can create a question from a bug, and link that bug to the
2138-new question. The question owner is the same as the bug owner. The
2139-question title and description are taken from the bug. The messages on
2140-the bug are copied to the question.
2141+ >>> print ', '.join(
2142+ ... sorted(language.code
2143+ ... for language in target.getQuestionLanguages()))
2144+ en, fr
2145+
2146+
2147+Creating questions from bugs
2148+============================
2149+
2150+The target can create a question from a bug, and link that bug to the new
2151+question. The question's owner is the same as the bug's owner. The question
2152+title and description are taken from the bug. The comments on the bug are
2153+copied to the question.
2154
2155 >>> from datetime import datetime
2156 >>> from pytz import UTC
2157- >>> from canonical.launchpad.interfaces import (
2158- ... CreateBugParams, IBugSet, IProductSet)
2159+ >>> from lp.bugs.interfaces.bug import CreateBugParams, IBugSet
2160+ >>> from lp.registry.interfaces.product import IProductSet
2161
2162 >>> now = datetime.now(UTC)
2163-
2164 >>> target = getUtility(IProductSet)['jokosher']
2165 >>> bug_params = CreateBugParams(
2166 ... title="Print is broken", comment="blah blah blah",
2167@@ -519,41 +547,34 @@
2168
2169 >>> target_question = target.createQuestionFromBug(target_bug)
2170
2171- >>> target_question.owner == target_bug.owner
2172- True
2173- >>> target_question.title == target_bug.title
2174- True
2175- >>> target_question.description == target_bug.description
2176- True
2177+ >>> print target_question.owner.displayname
2178+ Sample Person
2179+ >>> print target_question.title
2180+ Print is broken
2181+ >>> print target_question.description
2182+ blah blah blah
2183 >>> question_message = target_question.messages[-1]
2184- >>> question_message.text_contents == bug_message.text_contents
2185- True
2186-
2187- >>> target_question.owner.displayname
2188- u'Sample Person'
2189- >>> target_question.title
2190- u'Print is broken'
2191- >>> target_question.description
2192- u'blah blah blah'
2193- >>> [bug_link.bug.title for bug_link in target_question.bug_links]
2194- [u'Print is broken']
2195- >>> target_question.messages[-1].text_contents
2196- u'This is really a question.'
2197-
2198-The question's datecreated attribute is the same as the bug's
2199-datecreated. The question's datelastresponse attribute has a current
2200-datetime stamp to indicate the question is active. The question janitor
2201-would otherwise mistake the questions made from old bugs as old
2202-questions and would expire them.
2203+ >>> print question_message.text_contents
2204+ This is really a question.
2205+
2206+ >>> for bug_link in target_question.bug_links:
2207+ ... print bug_link.bug.title
2208+ Print is broken
2209+ >>> print target_question.messages[-1].text_contents
2210+ This is really a question.
2211+
2212+The question's creation date is the same as the bug's creation date. The
2213+question's last response date has a current datetime stamp to indicate the
2214+question is active. The question janitor would otherwise mistake the
2215+questions made from old bugs as old questions and would expire them.
2216
2217 >>> target_question.datecreated == target_bug.datecreated
2218 True
2219 >>> target_question.datelastresponse > now
2220 True
2221
2222-The question language is always English because all bugs in Launchpad
2223-are written in English.
2224-
2225- >>> target_question.language.code
2226- u'en'
2227-
2228+The question language is always English because all bugs in Launchpad are
2229+written in English.
2230+
2231+ >>> print target_question.language.code
2232+ en
2233
2234=== modified file 'lib/lp/answers/doc/workflow.txt'
2235--- lib/lp/answers/doc/workflow.txt 2009-07-13 05:48:57 +0000
2236+++ lib/lp/answers/doc/workflow.txt 2010-02-10 15:24:19 +0000
2237@@ -1,11 +1,13 @@
2238-= Answer Tracker Workflow =
2239-
2240-The state of a question is tracked through its status attribute.
2241-Six statuses are used to model a question lifecycle. These are defined
2242-in the QuestionStatus enumeration.
2243-
2244- >>> from canonical.launchpad.interfaces import QuestionStatus
2245- >>> print "\n".join([status.name for status in QuestionStatus.items])
2246+=======================
2247+Answer tracker workflow
2248+=======================
2249+
2250+The state of a question is tracked through its status, which model a
2251+question's lifecycle. These are defined in the QuestionStatus enumeration.
2252+
2253+ >>> from lp.answers.interfaces.questionenums import QuestionStatus
2254+ >>> for status in QuestionStatus.items:
2255+ ... print status.name
2256 OPEN
2257 NEEDSINFO
2258 ANSWERED
2259@@ -13,11 +15,12 @@
2260 EXPIRED
2261 INVALID
2262
2263-Status change occurs in consequence of a user action. The possible
2264+Status change occurs as a consequence of a user's action. The possible
2265 actions are defined in the QuestionAction enumeration.
2266
2267- >>> from canonical.launchpad.interfaces import QuestionAction
2268- >>> print "\n".join([status.name for status in QuestionAction.items])
2269+ >>> from lp.answers.interfaces.questionenums import QuestionAction
2270+ >>> for status in QuestionAction.items:
2271+ ... print status.name
2272 REQUESTINFO
2273 GIVEINFO
2274 COMMENT
2275@@ -28,19 +31,18 @@
2276 REOPEN
2277 SETSTATUS
2278
2279-There is a method available to execute each of these defined actions.
2280+Each defined action can be executed.
2281
2282-Let's define the actors that we are going to use to demonstrate the
2283-Answer Tracker workflow. The 'No Privileges Person' will be the
2284-submitter of questions, 'Sample Person' will be an answer contact for
2285-the Ubuntu distribution, and 'Marilize Coetze' will be another user
2286-providing support. Stub is a launchpad administrator that isn't also in
2287-the Ubuntu Team that owns the distribution.
2288+No Privileges Person is the submitter of questions. Sample Person is an
2289+answer contact for the Ubuntu distribution. Marilize Coetze is another user
2290+providing support. Stub is a Launchpad administrator that isn't also in the
2291+Ubuntu Team owning the distribution.
2292
2293 >>> login('no-priv@canonical.com')
2294
2295- >>> from canonical.launchpad.interfaces import (
2296- ... IDistributionSet, ILanguageSet, IPersonSet)
2297+ >>> from lp.registry.interfaces.distribution import IDistributionSet
2298+ >>> from lp.registry.interfaces.person import IPersonSet
2299+ >>> from lp.services.worlddata.interfaces.language import ILanguageSet
2300
2301 >>> personset = getUtility(IPersonSet)
2302 >>> sample_person = personset.getByEmail('test@canonical.com')
2303@@ -63,28 +65,31 @@
2304 >>> from datetime import datetime, timedelta
2305 >>> from pytz import UTC
2306 >>> now = datetime.now(UTC)
2307- >>> new_question_args = {
2308- ... 'owner': no_priv,
2309- ... 'title': 'Unable to boot installer',
2310- ... 'description': "I've tried installing Ubuntu on a Mac. "
2311- ... "But the installer never boots.",
2312- ... 'datecreated': now}
2313+ >>> new_question_args = dict(
2314+ ... owner=no_priv,
2315+ ... title='Unable to boot installer',
2316+ ... description="I've tried installing Ubuntu on a Mac. "
2317+ ... "But the installer never boots.",
2318+ ... datecreated=now,
2319+ ... )
2320 >>> question = ubuntu.newQuestion(**new_question_args)
2321 >>> print question.status.title
2322 Open
2323
2324-From there, we have four representative scenarios.
2325-
2326-== 1) Another user helps the submitter with his question ==
2327-
2328-The most common scenario is where another user comes to help the
2329-submitter and answers his question. This may involve exchanging
2330-information with the submitter to clarify the question.
2331-
2332-The requestInfo() method is used to ask the user for more information.
2333-This method takes two mandatory parameters: the user making the question
2334-and his question. It can also takes a 'datecreated' parameter specifying
2335-the creation date of the question (which defaults to now).
2336+The following scenarios are now possible.
2337+
2338+
2339+1) Another user helps the submitter with his question
2340+=====================================================
2341+
2342+The most common scenario is where another user comes to help the submitter and
2343+answers his question. This may involve exchanging information with the
2344+submitter to clarify the question.
2345+
2346+The requestInfo() method is used to ask the user for more information. This
2347+method takes two mandatory parameters: the user asking the question and his
2348+question. It can also takes a 'datecreated' parameter specifying the creation
2349+date of the question (which defaults to 'now').
2350
2351 >>> question = ubuntu.newQuestion(**new_question_args)
2352 >>> now_plus_one_hour = now + timedelta(hours=1)
2353@@ -92,11 +97,11 @@
2354 ... sample_person, 'What is your Mac model?',
2355 ... datecreated=now_plus_one_hour)
2356
2357-It returns the IQuestionMessage that was added to the question messages
2358-history:
2359+We now have the IQuestionMessage that was added to the question messages
2360+history.
2361
2362 >>> from canonical.launchpad.webapp.testing import verifyObject
2363- >>> from canonical.launchpad.interfaces import IQuestionMessage
2364+ >>> from lp.answers.interfaces.questionmessage import IQuestionMessage
2365 >>> verifyObject(IQuestionMessage, request_message)
2366 True
2367 >>> request_message == question.messages[-1]
2368@@ -106,9 +111,8 @@
2369 >>> print request_message.owner.displayname
2370 Sample Person
2371
2372-The question message contains the action that was executed in the action
2373-attribute and the status of the question after the action was executed in
2374-the new_status attribute:
2375+The question message contains the action that was executed and the status of
2376+the question after the action was executed.
2377
2378 >>> print request_message.action.name
2379 REQUESTINFO
2380@@ -118,13 +122,13 @@
2381 >>> print request_message.text_contents
2382 What is your Mac model?
2383
2384-The subject of the message was generated automatically:
2385+The subject of the message was generated automatically.
2386
2387 >>> print request_message.subject
2388 Re: Unable to boot installer
2389
2390-The question is moved to the NEEDSINFO state and the datelastresponse
2391-attribute is updated to the message timestamp.
2392+The question is moved to the NEEDSINFO state and the last response date is
2393+updated to the message's timestamp.
2394
2395 >>> print question.status.name
2396 NEEDSINFO
2397@@ -148,33 +152,35 @@
2398 >>> print reply_message.owner.displayname
2399 No Privileges Person
2400
2401-The question is moved back to the OPEN state and the 'datelastquery'
2402-attribute is updated to the message's creation date:
2403+The question is moved back to the OPEN state and the last query date is
2404+updated to the message's creation date.
2405
2406 >>> print question.status.name
2407 OPEN
2408 >>> question.datelastquery == now_plus_two_hours
2409 True
2410
2411-The other user has now enough information to give an answer to the
2412-question. The giveAnswer() method is used for that purpose. Like the
2413-requestInfo() method, it takes two mandatory parameters: the user
2414-providing the answer and the answer itself.
2415+Now, the other user has enough information to give an answer to the question.
2416+The giveAnswer() method is used for that purpose. Like the requestInfo()
2417+method, it takes two mandatory parameters: the user providing the answer and
2418+the answer itself.
2419
2420 >>> login('test@canonical.com')
2421 >>> now_plus_three_hours = now + timedelta(hours=3)
2422 >>> answer_message = question.giveAnswer(
2423- ... sample_person, "You need some configuration on the Mac side "
2424+ ... sample_person,
2425+ ... "You need some configuration on the Mac side "
2426 ... "to boot the installer on that model. Consult "
2427 ... "https://help.ubuntu.com/community/Installation/OldWorldMacs "
2428- ... "for all the details.", datecreated=now_plus_three_hours)
2429+ ... "for all the details.",
2430+ ... datecreated=now_plus_three_hours)
2431 >>> print answer_message.action.name
2432 ANSWER
2433 >>> print answer_message.new_status.name
2434 ANSWERED
2435
2436-After that action, the question's status is changed to ANSWERED and the
2437-datelastresponse is updated to contain the date of the message.
2438+The question's status is changed to ANSWERED and the last response date is
2439+updated to contain the date of the message.
2440
2441 >>> print question.status.name
2442 ANSWERED
2443@@ -182,9 +188,8 @@
2444 True
2445
2446 At that point, the question is considered answered, but we don't have
2447-feedback from the user on whether it solved his problem or not. If it
2448-doesn't the user can reopen the question. The reopen() method is used
2449-for that purpose.
2450+feedback from the user on whether it solved his problem or not. If it
2451+doesn't, the user can reopen the question.
2452
2453 >>> login('no-priv@canonical.com')
2454 >>> tomorrow = now + timedelta(days=1)
2455@@ -200,38 +205,37 @@
2456 >>> print reopen_message.owner.displayname
2457 No Privileges Person
2458
2459-This moves back the question to the OPEN state and the datelastquery
2460-attribute is updated to the message creation date.
2461+This moves back the question to the OPEN state and the last query date is
2462+updated to the message's creation date.
2463
2464 >>> print question.status.name
2465 OPEN
2466 >>> question.datelastquery == tomorrow
2467 True
2468
2469-The giveAnswer() will again be used to give an answer.
2470+Once again, an answer is given.
2471
2472 >>> login('test@canonical.com')
2473 >>> tomorrow_plus_one_hour = tomorrow + timedelta(hours=1)
2474 >>> answer2_message = question.giveAnswer(
2475- ... marilize, "You probably do not have enough RAM to use the "
2476+ ... marilize,
2477+ ... "You probably do not have enough RAM to use the "
2478 ... "graphical installer. You can try the alternate CD with the "
2479 ... "text installer.")
2480
2481-This again moves the question to the ANSWERED state.
2482+The question is moved back to the ANSWERED state.
2483
2484 >>> print question.status.name
2485 ANSWERED
2486
2487-The question owner will hopefully come back to confirm that his
2488-problem is solved. He can specify which answer message helped him
2489-solved his problem. The confirmAnswer() method is used for that
2490-purpose.
2491+The question owner will hopefully come back to confirm that his problem is
2492+solved. He can specify which answer message helped him solved his problem.
2493
2494 >>> login('no-priv@canonical.com')
2495 >>> two_weeks_from_now = now + timedelta(days=14)
2496 >>> confirm_message = question.confirmAnswer(
2497 ... "I upgraded to 512M of RAM (found on eBay) and I've "
2498- ... "succesfully managed to install Ubuntu. Thanks for all the help.",
2499+ ... "successfully managed to install Ubuntu. Thanks for all the help.",
2500 ... datecreated=two_weeks_from_now, answer=answer_message)
2501 >>> print confirm_message.action.name
2502 CONFIRM
2503@@ -240,9 +244,9 @@
2504 >>> print confirm_message.owner.displayname
2505 No Privileges Person
2506
2507-The question is moved to the SOLVED state, the message that solved
2508-the question is saved in the answer attribute, the date_solved
2509-and answerer attributes are also updated.
2510+The question is moved to the SOLVED state, and the message that solved the
2511+question is saved. The date the question was solved and answerer are also
2512+updated.
2513
2514 >>> print question.status.name
2515 SOLVED
2516@@ -254,45 +258,43 @@
2517 True
2518
2519
2520-== 2) Self-answer ==
2521-
2522-Another scenario is for the case when the user comes back to give the
2523-solution to the question himself. The giveAnswer() method is also used
2524-for that case. The question owner can choose a best answer message
2525-later on. The workflow permits the question owner to choose an answer
2526-before or after the question status is set to SOLVED.
2527-
2528-The question owner creates a question.
2529+2) Self-answering
2530+=================
2531+
2532+In this scenario the user comes back to give the solution to the question
2533+himself. The question owner can choose a best answer message later on. The
2534+workflow permits the question owner to choose an answer before or after the
2535+question status is set to SOLVED.
2536+
2537+A new question is posed.
2538
2539 >>> question = ubuntu.newQuestion(**new_question_args)
2540
2541-The question answer provides an answer that eludes to a decision
2542-the question owner must make.
2543+The answer provides some useful information to the questioner.
2544
2545 >>> login('test@canonical.com')
2546 >>> tomorrow_plus_one_hour = tomorrow + timedelta(hours=1)
2547 >>> alt_answer_message = question.giveAnswer(
2548- ... marilize, "Are you using a pre-G3 Mac? They are very difficult "
2549+ ... marilize,
2550+ ... "Are you using a pre-G3 Mac? They are very difficult "
2551 ... "to install to. You must mess with the hardware to trick "
2552 ... "the core chips to let it install. You may not want to do this.")
2553
2554-The question owner logs in, and explains that he has researched the
2555-problem, and come to a solution.
2556+The question has researched the problem, and has comes to a solution himself.
2557
2558 >>> login('no-priv@canonical.com')
2559 >>> self_answer_message = question.giveAnswer(
2560- ... no_priv, "I found some instructions on the Wiki on how to "
2561+ ... no_priv,
2562+ ... "I found some instructions on the Wiki on how to "
2563 ... "install BootX to boot the installation CD on OldWorld Mac: "
2564 ... "https://help.ubuntu.com/community/Installation/OldWorldMacs "
2565 ... "This is complicated and since it's a very old machine, not "
2566 ... "worth the trouble.",
2567 ... datecreated=now_plus_one_hour)
2568
2569-In that case, the question owner is considered to have given
2570-information that the problem is solved and the question is moved to
2571-the SOLVED state. The 'answerer' attribute will be the question owner,
2572-the 'date_solved' date of the message, but the 'answer' attribute
2573-will None.
2574+The question owner is considered to have given information that the problem is
2575+solved and the question is moved to the SOLVED state. The 'answerer'
2576+will be the question owner.
2577
2578 >>> print self_answer_message.action.name
2579 CONFIRM
2580@@ -305,20 +307,20 @@
2581 No Privileges Person
2582 >>> question.date_solved == now_plus_one_hour
2583 True
2584- >>> question.answer is None
2585- True
2586+ >>> print question.answer
2587+ None
2588
2589-The question owner can still specify which message helped him solved
2590-his problem. The confirmAnswer() method is used when the question
2591-owner chooses another user's answer as a best answer. The status
2592-will remain SOLVED. The 'answerer' attribute will be the message
2593-owner, and the 'answer' will be the message. The question's
2594-'date_solved' attribute will be the date of the answer message.
2595+The question owner can still specify which message helped him solved his
2596+problem. The confirmAnswer() method is used when the question owner chooses
2597+another user's answer as a best answer. The status will remain SOLVED. The
2598+'answerer' will be the message owner, and the 'answer' will be the message.
2599+The question's solution date will be the date of the answer message.
2600
2601 >>> confirm_message = question.confirmAnswer(
2602 ... "Thanks Marilize for your help. I don't think I'll put Ubuntu "
2603 ... "Ubuntu on my Mac.",
2604- ... datecreated=now_plus_one_hour, answer=alt_answer_message)
2605+ ... datecreated=now_plus_one_hour,
2606+ ... answer=alt_answer_message)
2607 >>> print confirm_message.action.name
2608 CONFIRM
2609 >>> print confirm_message.new_status.name
2610@@ -336,17 +338,18 @@
2611 True
2612
2613
2614-== 3) The question expires ==
2615+3) The question expires
2616+=======================
2617
2618-Another case is when nobody comes to answer the message, either because
2619-the question is too complex or too vague. These questions can be expired
2620-by using the expireQuestion() method. (See answer-tracker-expiration.txt
2621-for the documentation of the cron script handling this task.)
2622+It is also possible that nobody will answer the question, either because the
2623+question is too complex or too vague. These questions are expired by using
2624+the expireQuestion() method.
2625
2626 >>> login('no-priv@canonical.com')
2627 >>> question = ubuntu.newQuestion(**new_question_args)
2628 >>> expire_message = question.expireQuestion(
2629- ... sample_person, "There was no activity on this question for two "
2630+ ... sample_person,
2631+ ... "There was no activity on this question for two "
2632 ... "weeks and this question was expired. If you are still having "
2633 ... "this problem you should reopen the question and provide more "
2634 ... "information about your problem.",
2635@@ -356,8 +359,8 @@
2636 >>> print expire_message.new_status.name
2637 EXPIRED
2638
2639-The question is moved to the EXPIRED state and the 'datelastresponse'
2640-attribute is updated to the message creation date.
2641+The question is moved to the EXPIRED state and the last response date is
2642+updated to the message creation date.
2643
2644 >>> print question.status.name
2645 EXPIRED
2646@@ -376,8 +379,8 @@
2647 >>> print reopen_message.action.name
2648 REOPEN
2649
2650-The question status is changed back to OPEN and the 'datelastquery'
2651-attribute is updated.
2652+The question status is changed back to OPEN and the last query date is
2653+updated.
2654
2655 >>> print question.status.name
2656 OPEN
2657@@ -385,22 +388,22 @@
2658 True
2659
2660
2661-== 4) The question is invalid ==
2662+4) The question is invalid
2663+==========================
2664
2665-Another scenario to handle is the case where the user posts a message
2666-that isn't really appropriate for the Answer Tracker like a SPAM
2667+In this scenario the user posts an inappropriate message, such as a spam
2668 message or a request for Ubuntu CDs.
2669
2670 >>> spam_question = ubuntu.newQuestion(
2671 ... no_priv, 'CDs', 'Please send 10 Ubuntu Dapper CDs.',
2672 ... datecreated=now)
2673
2674-The reject() method is used for such purpose. Only an answer contact,
2675-a product or distribution owner, or an administrator can reject a question.
2676+Such questions can be rejected by an answer contact, a product or distribution
2677+owner, or a Launchpad administrator.
2678
2679-The canReject() method can be used to test if a user is allowed to
2680-reject the question. It takes as parameter the user who would reject the
2681-question:
2682+The canReject() method can be used to test if a user is allowed to reject the
2683+question. While neither No Privileges Person nor Marilize are able to reject
2684+questions, Sample Person and the Ubuntu owner can.
2685
2686 >>> spam_question.canReject(no_priv)
2687 False
2688@@ -413,7 +416,8 @@
2689 >>> spam_question.canReject(ubuntu.owner)
2690 True
2691
2692- # Administrator
2693+As a Launchpad administrator, so can Stub.
2694+
2695 >>> spam_question.canReject(stub)
2696 True
2697
2698@@ -424,8 +428,7 @@
2699 ...
2700 Unauthorized: ...
2701
2702-The reject() method takes a comment explaining the reason behind the
2703-rejection.
2704+When rejecting a question, a comment explaining the reason is given.
2705
2706 >>> login('test@canonical.com')
2707 >>> reject_message = spam_question.reject(
2708@@ -436,8 +439,8 @@
2709 >>> print reject_message.new_status.name
2710 INVALID
2711
2712-After rejection, the question is marked as invalid and the
2713-'datelastresponse' attribute is updated.
2714+After rejection, the question is marked as invalid and the last response date
2715+is updated.
2716
2717 >>> print spam_question.status.name
2718 INVALID
2719@@ -445,7 +448,7 @@
2720 True
2721
2722 The rejection message is also considered as answering the message, so the
2723-date_solved, answerer and answer attributes are also updated.
2724+solution date, answerer, and answer are also updated.
2725
2726 >>> spam_question.answer == reject_message
2727 True
2728@@ -454,23 +457,27 @@
2729 >>> spam_question.date_solved == now_plus_one_hour
2730 True
2731
2732-== Other scenarios ==
2733-
2734-Many other scenarios are possible and some of those are probably more
2735-common than the ones we exposed. For example, it is likely that a user
2736-will answer directly a question (without asking for other
2737-information first). Or that the question user won't come back to confirm
2738-that an answer solved his problem. Another likely scenario is where
2739-the question will expire in the NEEDSINFO state when the question owner
2740-doesn't reply to the request for more information. All of these
2741-scenarios are covered by this API. It is not necessary to cover all
2742-these various possibilities here.
2743-(The ../interfaces/ftests/test_question_workflow.py functional test
2744-exercices all the various possible transitions.)
2745-
2746-== Changing the question status ==
2747-
2748-It is not possible to change the status attribute directly:
2749+
2750+Other scenarios
2751+===============
2752+
2753+Many other scenarios are possible and some are likely more common than others.
2754+For example, it is likely that a user will directly answer a question without
2755+asking for other information first. Sometimes, the original questioner won't
2756+come back to confirm that an answer solved his problem.
2757+
2758+Another likely scenario is where the question will expire in the NEEDSINFO
2759+state because the question owner doesn't reply to the request for more
2760+information. All of these scenarios are covered by this API, though it is not
2761+necessary to cover all these various possibilities here. (The
2762+../tests/test_question_workflow.py functional test exercises all the various
2763+possible transitions.)
2764+
2765+
2766+Changing the question status
2767+============================
2768+
2769+It is not possible to change the status attribute directly.
2770
2771 >>> login('foo.bar@canonical.com')
2772 >>> question = ubuntu.newQuestion(**new_question_args)
2773@@ -479,10 +486,9 @@
2774 ...
2775 ForbiddenAttribute...
2776
2777-A user which has launchpad.Admin permission on the question, can set the
2778-question status to an arbitrary value by using the setStatus() method.
2779-That method takes as parameters the new status and a comment explaining
2780-the status change.
2781+A user having launchpad.Admin permission on the question can set the question
2782+status to an arbitrary value, by giving the new status and a comment
2783+explaining the status change.
2784
2785 >>> old_datelastquery = question.datelastquery
2786 >>> login(stub.preferredemail.email)
2787@@ -490,7 +496,7 @@
2788 ... stub, QuestionStatus.INVALID, 'Changed status to INVALID',
2789 ... datecreated=now_plus_one_hour)
2790
2791-The method returns the IQuestionMessage recording the change:
2792+The method returns the IQuestionMessage recording the change.
2793
2794 >>> print status_change_message.action.name
2795 SETSTATUS
2796@@ -499,7 +505,7 @@
2797 >>> print question.status.name
2798 INVALID
2799
2800-The status change updates the datelastresponse attribute:
2801+The status change updates the last response date.
2802
2803 >>> question.datelastresponse == now_plus_one_hour
2804 True
2805@@ -507,7 +513,7 @@
2806 True
2807
2808 If an answer was present on the question, the status change also clears
2809-the answer and date_solved attributes.
2810+the answer and solution date.
2811
2812 >>> msg = question.setStatus(stub, QuestionStatus.OPEN, 'Status change.')
2813 >>> answer_message = question.giveAnswer(sample_person, 'Install BootX.')
2814@@ -524,13 +530,13 @@
2815 ... stub, QuestionStatus.OPEN, 'Reopen the question',
2816 ... datecreated=now_plus_one_hour)
2817
2818- >>> question.date_solved is None
2819- True
2820- >>> question.answer is None
2821- True
2822+ >>> print question.date_solved
2823+ None
2824+ >>> print question.answer
2825+ None
2826
2827-But when the status is changed by a user who doesn't have the
2828-launchpad.Admin permission, an Unauthorized error is thrown:
2829+When the status is changed by a user who doesn't have the launchpad.Admin
2830+permission, an Unauthorized exception is thrown.
2831
2832 >>> login('test@canonical.com')
2833 >>> question.setStatus(sample_person, QuestionStatus.EXPIRED, 'Expire.')
2834@@ -538,10 +544,11 @@
2835 ...
2836 Unauthorized...
2837
2838-== Adding Comments Without Changing the Status ==
2839-
2840-There is an addComment() method that can be use to add a message to the
2841-question without changing its status.
2842+
2843+Adding Comments Without Changing the Status
2844+===========================================
2845+
2846+Comments can be added to questions without changing the question's status.
2847
2848 >>> login('no-priv@canonical.com')
2849 >>> old_status = question.status
2850@@ -556,8 +563,7 @@
2851 >>> comment.new_status == old_status
2852 True
2853
2854-This method does not update the datelastresponse and datelastquery
2855-attributes.
2856+This method does not update the last response date or last query date.
2857
2858 >>> question.datelastresponse == old_datelastresponse
2859 True
2860@@ -565,19 +571,20 @@
2861 True
2862
2863
2864-== Setting the question assignee ==
2865+Setting the question assignee
2866+=============================
2867
2868 Users with launchpad.Moderator privileges, which are answer contacts,
2869 question target owners, and admins, can assign someone to answer a question.
2870
2871-Sample Person is an answer contact for ubuntu. He can set the assignee.
2872+Sample Person is an answer contact for ubuntu, so he can set the assignee.
2873
2874 >>> login('test@canonical.com')
2875 >>> question.assignee = stub
2876 >>> print question.assignee.displayname
2877 Stuart Bishop
2878
2879-Users without launchpad.Moderator privileges cannot set the assignee
2880+Users without launchpad.Moderator privileges cannot set the assignee.
2881
2882 >>> login('no-priv@canonical.com')
2883 >>> question.assignee = sample_person
2884@@ -586,16 +593,18 @@
2885 Unauthorized: (<Question ...>, 'assignee', 'launchpad.Moderate')
2886
2887
2888-== Events ==
2889+Events
2890+======
2891
2892 Each of the workflow methods will trigger a ObjectCreatedEvent for
2893 the message they create and a ObjectModifiedEvent for the question.
2894
2895- # Register an event listener that will print event it receives.
2896+ # Register an event listener that will print events it receives.
2897 >>> from lazr.lifecycle.interfaces import (
2898 ... IObjectCreatedEvent, IObjectModifiedEvent)
2899- >>> from canonical.launchpad.interfaces import IQuestion
2900- >>> from canonical.launchpad.ftests.event import TestEventListener
2901+ >>> from lp.answers.interfaces.question import IQuestion
2902+ >>> from canonical.lazr.testing.event import TestEventListener
2903+
2904 >>> def print_event(object, event):
2905 ... print "Received %s on %s" % (
2906 ... event.__class__.__name__.split('.')[-1],
2907@@ -605,14 +614,15 @@
2908 >>> question_event_listener = TestEventListener(
2909 ... IQuestion, IObjectModifiedEvent, print_event)
2910
2911-Changing the status triggers the event:
2912+Changing the status triggers the event.
2913
2914 >>> login(stub.preferredemail.email)
2915- >>> msg = question.setStatus(stub, QuestionStatus.EXPIRED, 'Status change.')
2916+ >>> msg = question.setStatus(
2917+ ... stub, QuestionStatus.EXPIRED, 'Status change.')
2918 Received ObjectCreatedEvent on QuestionMessage
2919 Received ObjectModifiedEvent on Question
2920
2921-Example of a workflow method that triggers the events:
2922+Rejecting the question triggers the events.
2923
2924 >>> msg = question.reject(stub, 'Close this question.')
2925 Received ObjectCreatedEvent on QuestionMessage
2926@@ -630,25 +640,28 @@
2927 >>> questionmessage_event_listener.unregister()
2928 >>> question_event_listener.unregister()
2929
2930-== Reopenings ==
2931+
2932+Reopening the question
2933+======================
2934
2935 Whenever a question considered answered (in the SOLVED or INVALID state)
2936 is reopened, a QuestionReopening is created.
2937
2938- # Let's register an event listener to notify us whenever a
2939- # QuestionReopening is created.
2940- >>> from canonical.launchpad.interfaces import IQuestionReopening
2941+ # Register an event listener to notify us whenever a QuestionReopening is
2942+ # created.
2943+ >>> from lp.answers.interfaces.questionreopening import IQuestionReopening
2944 >>> reopening_event_listener = TestEventListener(
2945 ... IQuestionReopening, IObjectCreatedEvent, print_event)
2946
2947 The most common use case is when a user confirms a solution, and then
2948-comes back to say that it doesn't work in fact.
2949+comes back to say that it doesn't, in fact, work.
2950
2951 >>> login('no-priv@canonical.com')
2952 >>> question = ubuntu.newQuestion(**new_question_args)
2953 >>> answer_message = question.giveAnswer(
2954- ... sample_person, "You need some setup on the Mac side. "
2955- ... "Follow the instructions at "
2956+ ... sample_person,
2957+ ... "You need some setup on the Mac side. "
2958+ ... "Follow the instructions at "
2959 ... "https://help.ubuntu.com/community/Installation/OldWorldMacs",
2960 ... datecreated=now_plus_one_hour)
2961 >>> confirm_message = question.confirmAnswer(
2962@@ -662,23 +675,23 @@
2963
2964 The reopening record is available through the reopenings attribute.
2965
2966- >>> list(question.reopenings)
2967- [<QuestionReopening...>]
2968- >>> reopening = question.reopenings[0]
2969+ >>> reopenings = list(question.reopenings)
2970+ >>> len(reopenings)
2971+ 1
2972+ >>> reopening = reopenings[0]
2973 >>> verifyObject(IQuestionReopening, reopening)
2974 True
2975
2976-The reopening contain the date of the reopening in the datecreated
2977-attribute and the person who made the reopening in the reopener
2978-attribute.
2979+The reopening contain the date of the reopening, and the person who cause the
2980+reopening to happen.
2981
2982 >>> reopening.datecreated == now_plus_three_hours
2983 True
2984 >>> print reopening.reopener.displayname
2985 No Privileges Person
2986
2987-It contains the question prior answerer, datecreated, as well as the
2988-prior status in the priorstate attribute:
2989+It also contains the question's prior answerer, the date created, and the
2990+prior status of the question.
2991
2992 >>> print reopening.answerer.displayname
2993 Sample Person
2994@@ -687,8 +700,8 @@
2995 >>> print reopening.priorstate.name
2996 SOLVED
2997
2998-Another example of a reopening, would be when the question status is set
2999-back to OPEN after having been rejected.
3000+A reopening also occurs when the question status is set back to OPEN after
3001+having been rejected.
3002
3003 >>> login('test@canonical.com')
3004 >>> question = ubuntu.newQuestion(**new_question_args)
3005@@ -698,7 +711,8 @@
3006
3007 >>> login(stub.preferredemail.email)
3008 >>> status_change_message = question.setStatus(
3009- ... stub, QuestionStatus.OPEN, 'Disregard previous rejection. '
3010+ ... stub, QuestionStatus.OPEN,
3011+ ... 'Disregard previous rejection. '
3012 ... 'Sample Person was having a bad day.',
3013 ... datecreated=now_plus_two_hours)
3014 Received ObjectCreatedEvent on QuestionReopening
3015@@ -718,7 +732,9 @@
3016 # Cleanup
3017 >>> reopening_event_listener.unregister()
3018
3019-== Using an IMessage as Explanation ==
3020+
3021+Using an IMessage as an explanation
3022+===================================
3023
3024 In all the workflow methods, it is possible to pass an IMessage instead of
3025 a string.
3026@@ -729,7 +745,7 @@
3027 >>> question = ubuntu.newQuestion(**new_question_args)
3028 >>> reject_message = messageset.fromText(
3029 ... 'Reject', 'Because I feel like it.', sample_person)
3030- >>> question_message = question.reject(sample_person,reject_message)
3031+ >>> question_message = question.reject(sample_person, reject_message)
3032 >>> print question_message.subject
3033 Reject
3034 >>> print question_message.text_contents
3035@@ -737,7 +753,7 @@
3036 >>> question_message.rfc822msgid == reject_message.rfc822msgid
3037 True
3038
3039-The IMessage owner must be the same than the person passed to the workflow
3040+The IMessage owner must be the same as the person passed to the workflow
3041 method.
3042
3043 >>> login(stub.preferredemail.email)
3044
3045=== modified file 'lib/lp/answers/interfaces/questionreopening.py'
3046--- lib/lp/answers/interfaces/questionreopening.py 2009-06-24 23:10:46 +0000
3047+++ lib/lp/answers/interfaces/questionreopening.py 2010-02-10 15:24:19 +0000
3048@@ -20,6 +20,7 @@
3049 from lp.answers.interfaces.question import IQuestion
3050 from lp.answers.interfaces.questionenums import QuestionStatus
3051
3052+
3053 class IQuestionReopening(Interface):
3054 """A record of the re-opening of a question.
3055

Subscribers

People subscribed via source and target branches

to status/vote changes: