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
=== 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-10 15:24:19 +0000
@@ -1,22 +1,28 @@
1= Person and the Answer Tracker =1=============================
22People and the answer tracker
3== searchQuestions() ==3=============================
44
5IQuestionsPerson defines a searchQuestions() method which can be used to5Sometimes you want to find out what questions a person is involved with.
6select all or a subset of the questions in which the person is6
7involved. This includes questions which the person created, is assigned7
8to, is subscribed to, commented on, or answered. Various subsets can8Searching
9be selected by using the following criteria status, search_text and9=========
10participation type.10
1111IQuestionsPerson defines a searchQuestions() method which is used to select
12 >>> from canonical.launchpad.interfaces import IPersonSet12all, or a subset of, the questions in which a person is involved. This
13includes questions which the person created, is assigned to, is subscribed to,
14commented on, or answered. Various subsets can be selected by using the
15various search criteria.
16
17 >>> from lp.registry.interfaces.person import IPersonSet
13 >>> from lp.answers.interfaces.questionsperson import IQuestionsPerson18 >>> from lp.answers.interfaces.questionsperson import IQuestionsPerson
14 >>> personset = getUtility(IPersonSet)19 >>> personset = getUtility(IPersonSet)
15 >>> foo_bar_raw = personset.getByEmail('foo.bar@canonical.com')20 >>> foo_bar_raw = personset.getByEmail('foo.bar@canonical.com')
16 >>> foo_bar = IQuestionsPerson(foo_bar_raw)21 >>> foo_bar = IQuestionsPerson(foo_bar_raw)
1722
1823
19=== search_text ===24Search text
25-----------
2026
21The search_text parameter will limit the questions to those matching27The search_text parameter will limit the questions to those matching
22the query using the regular full text algorithm.28the query using the regular full text algorithm.
@@ -28,13 +34,14 @@
28 Newly installed plug-in doesn't seem to be used Answered34 Newly installed plug-in doesn't seem to be used Answered
2935
3036
31=== sort ===37Sorting
3238-------
33When using the search_text criteria, the default is to sort the results39
34by relevancy. One can use the sort parameter to change that. It takes40When using the search_text criteria, the default is to sort the results by
35one of the constant defined in the QuestionSort enumeration.41relevancy. One can use the sort parameter to change that. It takes one of
3642the constant defined in the QuestionSort enumeration.
37 >>> from canonical.launchpad.interfaces import QuestionSort43
44 >>> from lp.answers.interfaces.questionenums import QuestionSort
38 >>> for question in foo_bar.searchQuestions(45 >>> for question in foo_bar.searchQuestions(
39 ... search_text='firefox', sort=QuestionSort.OLDEST_FIRST):46 ... search_text='firefox', sort=QuestionSort.OLDEST_FIRST):
40 ... print question.id, question.title, question.status.title47 ... print question.id, question.title, question.status.title
@@ -42,8 +49,7 @@
42 6 Newly installed plug-in doesn't seem to be used Answered49 6 Newly installed plug-in doesn't seem to be used Answered
43 9 mailto: problem in webpage Solved50 9 mailto: problem in webpage Solved
4451
45When no text search is done, the default sort order is52When no text search is done, the default sort order is newest first.
46QuestionSort.NEWEST_FIRST.
4753
48 >>> for question in foo_bar.searchQuestions():54 >>> for question in foo_bar.searchQuestions():
49 ... print question.id, question.title, question.status.title55 ... print question.id, question.title, question.status.title
@@ -56,13 +62,13 @@
56 4 Firefox loses focus and gets stuck Open62 4 Firefox loses focus and gets stuck Open
5763
5864
59=== status ===65Status
6066------
61The last searches showed that by default, not all statuses are searched67
62for by default (they excluded expired and invalid questions). The status68As shown above, expired and invalid questions are not returned. The status
63parameter can be used to control the list of statuses to select:69parameter can be used to control the list of statuses to select.
6470
65 >>> from canonical.launchpad.interfaces import QuestionStatus71 >>> from lp.answers.interfaces.questionenums import QuestionStatus
66 >>> for question in foo_bar.searchQuestions(status=QuestionStatus.INVALID):72 >>> for question in foo_bar.searchQuestions(status=QuestionStatus.INVALID):
67 ... print question.title, question.status.title73 ... print question.title, question.status.title
68 Firefox is slow and consumes too much RAM Invalid74 Firefox is slow and consumes too much RAM Invalid
@@ -70,23 +76,23 @@
70The status parameter can also take a list of statuses.76The status parameter can also take a list of statuses.
7177
72 >>> for question in foo_bar.searchQuestions(78 >>> for question in foo_bar.searchQuestions(
73 ... status=[QuestionStatus.SOLVED, QuestionStatus.INVALID]):79 ... status=(QuestionStatus.SOLVED, QuestionStatus.INVALID)):
74 ... print question.title, question.status.title80 ... print question.title, question.status.title
75 mailto: problem in webpage Solved81 mailto: problem in webpage Solved
76 Firefox is slow and consumes too much RAM Invalid82 Firefox is slow and consumes too much RAM Invalid
7783
7884
79=== participation ===85Participation
86-------------
8087
81By default, any types of relationship to a question is considered by88By default, any relationship between a person and a question is considered by
82searchQuestions. This can customized through the participation89searchQuestions. This can customized through the participation parameter. It
83parameter. It takes one or a list of constants from the90takes one or a list of constants from the QuestionParticipation enumeration.
84QuestionParticipation enumeration.
8591
86To select only questions on which the person commented, the92To select only questions on which the person commented, the
87QuestionParticipation.COMMENTER is used:93QuestionParticipation.COMMENTER is used.
8894
89 >>> from canonical.launchpad.interfaces import QuestionParticipation95 >>> from lp.answers.interfaces.questionenums import QuestionParticipation
90 >>> for question in foo_bar.searchQuestions(96 >>> for question in foo_bar.searchQuestions(
91 ... participation=QuestionParticipation.COMMENTER, status=None):97 ... participation=QuestionParticipation.COMMENTER, status=None):
92 ... print question.title98 ... print question.title
@@ -96,8 +102,8 @@
96 Installation of Java Runtime Environment for Mozilla102 Installation of Java Runtime Environment for Mozilla
97 Newly installed plug-in doesn't seem to be used103 Newly installed plug-in doesn't seem to be used
98104
99QuestionParticipation.SUBSCRIBER will only select the questions to which105QuestionParticipation.SUBSCRIBER will only select the questions to which the
100the person is subscribed to:106person is subscribed.
101107
102 >>> for question in foo_bar.searchQuestions(108 >>> for question in foo_bar.searchQuestions(
103 ... participation=QuestionParticipation.SUBSCRIBER, status=None):109 ... participation=QuestionParticipation.SUBSCRIBER, status=None):
@@ -105,7 +111,7 @@
105 Slow system111 Slow system
106 Firefox is slow and consumes too much RAM112 Firefox is slow and consumes too much RAM
107113
108QuestionParticipation.OWNER selects the questions that the person created:114QuestionParticipation.OWNER selects the questions that the person created.
109115
110 >>> for question in foo_bar.searchQuestions(116 >>> for question in foo_bar.searchQuestions(
111 ... participation=QuestionParticipation.OWNER, status=None):117 ... participation=QuestionParticipation.OWNER, status=None):
@@ -114,8 +120,8 @@
114 Firefox loses focus and gets stuck120 Firefox loses focus and gets stuck
115 Firefox is slow and consumes too much RAM121 Firefox is slow and consumes too much RAM
116122
117QuestionParticipation.ANSWERER selects the questions for which the person123QuestionParticipation.ANSWERER selects the questions for which the person gave
118was marked as the answerer:124an answer.
119125
120 >>> for question in foo_bar.searchQuestions(126 >>> for question in foo_bar.searchQuestions(
121 ... participation=QuestionParticipation.ANSWERER, status=None):127 ... participation=QuestionParticipation.ANSWERER, status=None):
@@ -124,19 +130,18 @@
124 Firefox is slow and consumes too much RAM130 Firefox is slow and consumes too much RAM
125131
126QuestionParticipation.ASSIGNEE selects that questions which are assigned to132QuestionParticipation.ASSIGNEE selects that questions which are assigned to
127the person:133the person.
128134
129 >>> for question in foo_bar.searchQuestions(135 >>> list(foo_bar.searchQuestions(
130 ... participation=QuestionParticipation.ASSIGNEE, status=None):136 ... participation=QuestionParticipation.ASSIGNEE, status=None))
131 ... print question.title137 []
132138
133If a list of these constants is used, all of these participation types139If a list of these constants is used, all of these participation types
134will be selected:140will be selected.
135141
136 >>> for question in foo_bar.searchQuestions(142 >>> for question in foo_bar.searchQuestions(
137 ... participation=[143 ... participation=(QuestionParticipation.OWNER,
138 ... QuestionParticipation.OWNER,144 ... QuestionParticipation.ANSWERER),
139 ... QuestionParticipation.ANSWERER],
140 ... status=None):145 ... status=None):
141 ... print question.title146 ... print question.title
142 mailto: problem in webpage147 mailto: problem in webpage
@@ -145,40 +150,41 @@
145 Firefox is slow and consumes too much RAM150 Firefox is slow and consumes too much RAM
146151
147152
148=== language ===153Language
149154--------
150By default, questions in all languages are included in the results.155
151It is possible to filter questions by the language they were written156By default, questions in all languages are included in the results. It is
152in . One or a list of ILanguage object should be passed in the157possible to filter questions by the language they were written in. One or a
153language parameter to specify the language filter.158sequence of ILanguage object can be passed in to specify the language filter.
154159
155 >>> from canonical.launchpad.interfaces import ILanguageSet160 >>> from lp.services.worlddata.interfaces.language import ILanguageSet
156 >>> spanish = getUtility(ILanguageSet)['es']161 >>> spanish = getUtility(ILanguageSet)['es']
157 >>> english = getUtility(ILanguageSet)['en']162 >>> english = getUtility(ILanguageSet)['en']
158163
159Foo bar doesn't have any questions written in Spanish.164Foo bar doesn't have any questions written in Spanish.
160165
161 >>> for question in foo_bar.searchQuestions(language=spanish):166 >>> list(foo_bar.searchQuestions(language=spanish))
162 ... print question.title167 []
163168
164But carlos has one.169But Carlos has one.
165170
171 # Because not everyone uses a real editor <wink>
172 >>> from canonical.encoding import ascii_smash
166 >>> carlos_raw = personset.getByName('carlos')173 >>> carlos_raw = personset.getByName('carlos')
167 >>> carlos = IQuestionsPerson(carlos_raw)174 >>> carlos = IQuestionsPerson(carlos_raw)
168 >>> for question in carlos.searchQuestions(175 >>> for question in carlos.searchQuestions(
169 ... language=[english, spanish]):176 ... language=(english, spanish)):
170 ... [question.title, question.language.code]177 ... print ascii_smash(question.title), question.language.code
171 [u'Problema al recompilar kernel con soporte smp (doble-n\xfacleo)',178 Problema al recompilar kernel con soporte smp (doble-nucleo) es
172 u'es']179
173180
174181Questions needing attention
175=== needs_attention ===182---------------------------
176183
177The method accept a parameter called needs_attention which only selects184You can select only the questions that needs attention from a person. This
178the questions that needs attention from the person. This includes questions185includes questions owned by the person in the ANSWERED or NEEDSINFO state. It
179owned by the person in the ANSWERED or NEEDSINFO state. It also includes186also includes questions on which the person requested more information or gave
180questions on which the person requested for more information or gave an187an answer and are back in the OPEN state.
181answer and that are back in the OPEN state.
182188
183 >>> for question in foo_bar.searchQuestions(needs_attention=True):189 >>> for question in foo_bar.searchQuestions(needs_attention=True):
184 ... print question.status.title, question.owner.displayname, (190 ... print question.status.title, question.owner.displayname, (
@@ -187,70 +193,80 @@
187 Needs information Foo Bar Slow system193 Needs information Foo Bar Slow system
188194
189195
190=== Combination ===196Search combinations
197-------------------
191198
192The returned sets of questions is the intersection of the sets delimited199The results are the intersection of the sets delimited by each criteria.
193by each criteria:
194200
195 >>> for question in foo_bar.searchQuestions(201 >>> for question in foo_bar.searchQuestions(
196 ... search_text='firefox OR Java', status=QuestionStatus.ANSWERED,202 ... search_text='firefox OR Java',
203 ... status=QuestionStatus.ANSWERED,
197 ... participation=QuestionParticipation.COMMENTER):204 ... participation=QuestionParticipation.COMMENTER):
198 ... print question.title, question.status.title205 ... print question.title, question.status.title
199 Installation of Java Runtime Environment for Mozilla Answered206 Installation of Java Runtime Environment for Mozilla Answered
200 Newly installed plug-in doesn't seem to be used Answered207 Newly installed plug-in doesn't seem to be used Answered
201208
202209
203== getQuestionLanguages() ==210Question languages
211==================
204212
205IQuestionsPerson also defines a getQuestionLanguages() attribute which213IQuestionsPerson also defines a getQuestionLanguages() attribute which
206contains the set of languages used by all of the questions in which this214contains the set of languages used by all of the questions in which this
207person is involved.215person is involved.
208216
209 >>> sorted(language.code for language in foo_bar.getQuestionLanguages())217 >>> print ', '.join(
210 [u'en']218 ... sorted(language.code
211219 ... for language in foo_bar.getQuestionLanguages()))
212This includes questions which the person owns. But also, questions that220 en
213the user subscribed to.221
214222This includes questions which the person owns, and questions that the user is
215 >>> from canonical.launchpad.interfaces import IQuestionSet223subscribed to...
224
225 >>> from lp.answers.interfaces.questioncollection import IQuestionSet
216 >>> pt_BR_question = getUtility(IQuestionSet).get(13)226 >>> pt_BR_question = getUtility(IQuestionSet).get(13)
217 >>> login('foo.bar@canonical.com')227 >>> login('foo.bar@canonical.com')
218 >>> pt_BR_question.subscribe(foo_bar_raw)228 >>> pt_BR_question.subscribe(foo_bar_raw)
219 <QuestionSubscription...>229 <QuestionSubscription...>
220230
221 >>> sorted(language.code for language in foo_bar.getQuestionLanguages())231 >>> print ', '.join(
222 [u'en', u'pt_BR']232 ... sorted(language.code
233 ... for language in foo_bar.getQuestionLanguages()))
234 en, pt_BR
223235
224And also questions for which he's the answerer.236...and questions for which he's the answerer...
225237
226 >>> es_question = getUtility(IQuestionSet).get(12)238 >>> es_question = getUtility(IQuestionSet).get(12)
227 >>> es_question.reject(foo_bar_raw, 'Reject question.')239 >>> es_question.reject(foo_bar_raw, 'Reject question.')
228 <QuestionMessage...>240 <QuestionMessage...>
229241
230 >>> sorted(language.code for language in foo_bar.getQuestionLanguages())242 >>> print ', '.join(
231 [u'en', u'es', u'pt_BR']243 ... sorted(language.code
244 ... for language in foo_bar.getQuestionLanguages()))
245 en, es, pt_BR
232246
233As well, as question which are assigned to the user.247...as well as questions which are assigned to the user...
234248
235 >>> pt_BR_question.assignee = carlos_raw249 >>> pt_BR_question.assignee = carlos_raw
236 >>> from canonical.database.sqlbase import flush_database_updates250 >>> print ', '.join(
237 >>> flush_database_updates()251 ... sorted(language.code
238252 ... for language in carlos.getQuestionLanguages()))
239 >>> sorted(language.code for language in carlos.getQuestionLanguages())253 es, pt_BR
240 [u'es', u'pt_BR']254
241255...and questions on which the user commented.
242And questions on which the user commented:
243256
244 >>> en_question = getUtility(IQuestionSet).get(1)257 >>> en_question = getUtility(IQuestionSet).get(1)
245 >>> login('carlos@canonical.com')258 >>> login('carlos@canonical.com')
246 >>> en_question.addComment(carlos_raw, 'A simple comment.')259 >>> en_question.addComment(carlos_raw, 'A simple comment.')
247 <QuestionMessage...>260 <QuestionMessage...>
248261
249 >>> sorted(language.code for language in carlos.getQuestionLanguages())262 >>> print ', '.join(
250 [u'en', u'es', u'pt_BR']263 ... sorted(language.code
251264 ... for language in carlos.getQuestionLanguages()))
252265 en, es, pt_BR
253== getDirectAnswerQuestionTargets() ==266
267
268Direct subscriptions
269====================
254270
255IQuestionsPerson defines getDirectAnswerQuestionTargets that can be used to271IQuestionsPerson defines getDirectAnswerQuestionTargets that can be used to
256retrieve a list of IQuestionTargets that a person subscribed himself to as an272retrieve a list of IQuestionTargets that a person subscribed himself to as an
@@ -261,9 +277,10 @@
261 >>> no_priv.getDirectAnswerQuestionTargets()277 >>> no_priv.getDirectAnswerQuestionTargets()
262 []278 []
263279
264 >>> from canonical.launchpad.interfaces import IProductSet280 >>> from lp.registry.interfaces.product import IProductSet
265 >>> firefox = getUtility(IProductSet).getByName("firefox")281 >>> firefox = getUtility(IProductSet).getByName("firefox")
266 >>> # Answer contacts must speak a language282
283 # Answer contacts must speak a language
267 >>> no_priv_raw.addLanguage(english)284 >>> no_priv_raw.addLanguage(english)
268 >>> firefox.addAnswerContact(no_priv_raw)285 >>> firefox.addAnswerContact(no_priv_raw)
269 True286 True
@@ -272,7 +289,9 @@
272 ... print target.name289 ... print target.name
273 firefox290 firefox
274291
275== getTeamAnswerQuestionTargets() ==292
293Indirect subscriptions
294======================
276295
277IQuestionsPerson defines getTeamAnswerQuestionTargets that retrieves a list of296IQuestionsPerson defines getTeamAnswerQuestionTargets that retrieves a list of
278IQuestionTargets that the person is subscribed to indirectly as an answer297IQuestionTargets that the person is subscribed to indirectly as an answer
@@ -283,20 +302,21 @@
283 >>> no_priv_raw.inTeam(landscape_team)302 >>> no_priv_raw.inTeam(landscape_team)
284 True303 True
285304
286 >>> from canonical.launchpad.interfaces import IDistributionSet305 >>> from lp.registry.interfaces.distribution import IDistributionSet
287 >>> ubuntu = getUtility(IDistributionSet).getByName("ubuntu")306 >>> ubuntu = getUtility(IDistributionSet).getByName("ubuntu")
288 >>> landscape_team.addLanguage(english)307 >>> landscape_team.addLanguage(english)
289 >>> ubuntu.addAnswerContact(landscape_team)308 >>> ubuntu.addAnswerContact(landscape_team)
290 True309 True
291310
292 >>> sorted(target.name311 >>> print ', '.join(
293 ... for target in no_priv.getTeamAnswerQuestionTargets())312 ... sorted(target.name
294 [u'ubuntu']313 ... for target in no_priv.getTeamAnswerQuestionTargets()))
314 ubuntu
295315
296Indirect team membership is also taken in consideration. For example,316Indirect team membership is also taken in consideration. For example, when
297the Landscape Team joins the Translator Team. So targets for which the317the Landscape Team joins the Translator Team, targets for which the Translator
298Translator team is an answer contact will be included in No Privileges318team is an answer contact will be included in No Privileges Person's supported
299Person's supported IQuestionTargets:319IQuestionTargets.
300320
301 >>> translator_team = personset.getByName('ubuntu-translators')321 >>> translator_team = personset.getByName('ubuntu-translators')
302 >>> no_priv_raw.inTeam(translator_team)322 >>> no_priv_raw.inTeam(translator_team)
@@ -315,36 +335,33 @@
315 >>> translator_team.addLanguage(english)335 >>> translator_team.addLanguage(english)
316 >>> evolution_package.addAnswerContact(translator_team)336 >>> evolution_package.addAnswerContact(translator_team)
317 True337 True
318 >>> sorted(target.name338 >>> print ', '.join(
319 ... for target in no_priv.getTeamAnswerQuestionTargets())339 ... sorted(target.name
320 [u'evolution', u'ubuntu']340 ... for target in no_priv.getTeamAnswerQuestionTargets()))
321341 evolution, ubuntu
322342
323== Deactivated pillars and *AnswerQuestionTargets() ==343
324344Deactivated pillars
325getDirectAnswerQuestionTargets() and getTeamAnswerQuestionTargets() use345===================
326a _getQuestionTargetsFromAnswerContacts() to build a distinct list of346
327valid IQuestionTargets. It ensures that no deactivated pillars are in347Only valid IQuestionTargets are returned, ensuring that no deactivated pillars
328the list.348are in the results.
329349
330If the Firefox project is deactivated, it is removed from the list of350If the Firefox project is deactivated, it is removed from the list of
331supported projects.351supported projects.
332352
333 >>> from canonical.launchpad.ftests import syncUpdate
334
335 >>> login('foo.bar@canonical.com')353 >>> login('foo.bar@canonical.com')
336 >>> firefox.active = False354 >>> firefox.active = False
337 >>> syncUpdate(firefox)
338 >>> sorted(target.name355 >>> sorted(target.name
339 ... for target in no_priv.getDirectAnswerQuestionTargets())356 ... for target in no_priv.getDirectAnswerQuestionTargets())
340 []357 []
341358
342When the Firefox project is reactivated, the answer contact relationship359When the Firefox project is reactivated, the answer contact relationship is
343is visible. It is important to preserve the continuity of the project in360visible. These relationships are persistent for cases where we only want is
344cases were we only want is deactivated for a short period.361deactivated for a short period.
345362
346 >>> firefox.active = True363 >>> firefox.active = True
347 >>> syncUpdate(firefox)364 >>> print ', '.join(
348 >>> sorted(target.name365 ... sorted(target.name
349 ... for target in no_priv.getDirectAnswerQuestionTargets())366 ... for target in no_priv.getDirectAnswerQuestionTargets()))
350 [u'firefox']367 firefox
351368
=== renamed file 'lib/lp/answers/doc/project.txt' => 'lib/lp/answers/doc/projectgroup.txt'
--- lib/lp/answers/doc/project.txt 2009-03-24 12:43:49 +0000
+++ lib/lp/answers/doc/projectgroup.txt 2010-02-10 15:24:19 +0000
@@ -1,12 +1,15 @@
1= Project and the Answer Tracker =1===============================
2Projects and the answer tracker
3===============================
24
3Although question cannot be filed directly against projects, IProject in5Although questions cannot be filed directly against project groups (nee
4Launchpad also provides the IQuestionCollection and6'Project'), IProject provides the IQuestionCollection and
5ISearchableByQuestionOwner interfaces.7ISearchableByQuestionOwner interfaces.
68
7 >>> from canonical.launchpad.webapp.testing import verifyObject9 >>> from canonical.launchpad.webapp.testing import verifyObject
8 >>> from canonical.launchpad.interfaces import (10 >>> from lp.registry.interfaces.project import IProjectSet
9 ... IProjectSet, ISearchableByQuestionOwner, IQuestionCollection)11 >>> from lp.answers.interfaces.questioncollection import (
12 ... ISearchableByQuestionOwner, IQuestionCollection)
1013
11 >>> mozilla_project = getUtility(IProjectSet).getByName('mozilla')14 >>> mozilla_project = getUtility(IProjectSet).getByName('mozilla')
12 >>> verifyObject(IQuestionCollection, mozilla_project)15 >>> verifyObject(IQuestionCollection, mozilla_project)
@@ -14,37 +17,41 @@
14 >>> verifyObject(ISearchableByQuestionOwner, mozilla_project)17 >>> verifyObject(ISearchableByQuestionOwner, mozilla_project)
15 True18 True
1619
17== searchQuestions() ==20
1821Questions filed against project in a project group
19This means that it is possible to search for all questions filed against22==================================================
20products in a project using the project searchQuestions() method.23
2124You can search for all questions filed against projects in a project using the
22 # Add a question to thunderbird.25project group's searchQuestions() method.
23 >>> from canonical.launchpad.interfaces import ILaunchBag, IProductSet26
27 >>> from lp.registry.interfaces.person import IPersonSet
28 >>> from lp.registry.interfaces.product import IProductSet
29
24 >>> login('test@canonical.com')30 >>> login('test@canonical.com')
25 >>> thunderbird = getUtility(IProductSet).getByName('thunderbird')31 >>> thunderbird = getUtility(IProductSet).getByName('thunderbird')
26 >>> sample_person = getUtility(ILaunchBag).user32 >>> sample_person = getUtility(IPersonSet).getByName('name12')
27 >>> question = thunderbird.newQuestion(33 >>> question = thunderbird.newQuestion(
28 ... sample_person, "SVG attachments aren't displayed",34 ... sample_person,
35 ... "SVG attachments aren't displayed ",
29 ... "It would be a nice feature if SVG attachments could be displayed"36 ... "It would be a nice feature if SVG attachments could be displayed"
30 ... "inlined.")37 ... " inlined.")
3138
32 >>> for question in mozilla_project.searchQuestions(search_text='svg'):39 >>> for question in mozilla_project.searchQuestions(search_text='svg'):
33 ... print question.title, question.target.displayname40 ... print question.title, question.target.displayname
34 SVG attachments aren't displayed Mozilla Thunderbird41 SVG attachments aren't displayed Mozilla Thunderbird
35 Problem showing the SVG demo on W3C site Mozilla Firefox42 Problem showing the SVG demo on W3C site Mozilla Firefox
3643
37In the case were a Project has no Products, then we can expect no 44In the case where a project group has no projects, there are no results.
38possible questions.
3945
40 >>> aaa_project = getUtility(IProjectSet).getByName('aaa')46 >>> aaa_project = getUtility(IProjectSet).getByName('aaa')
41 >>> [q for question in aaa_project.searchQuestions()]47 >>> list(aaa_project.searchQuestions())
42 []48 []
4349
44Questions can be searched by all the standard searchQuestions() parameters50Questions can be searched by all the standard searchQuestions() parameters.
45(consult questiontarget.txt for the full details.)51See questiontarget.txt for the full details.
4652
47 >>> from canonical.launchpad.interfaces import QuestionStatus, QuestionSort53 >>> from lp.answers.interfaces.questionenums import (
54 ... QuestionSort, QuestionStatus)
48 >>> for question in mozilla_project.searchQuestions(55 >>> for question in mozilla_project.searchQuestions(
49 ... owner=sample_person, status=QuestionStatus.OPEN,56 ... owner=sample_person, status=QuestionStatus.OPEN,
50 ... sort=QuestionSort.OLDEST_FIRST):57 ... sort=QuestionSort.OLDEST_FIRST):
@@ -52,20 +59,21 @@
52 Problem showing the SVG demo on W3C site Mozilla Firefox59 Problem showing the SVG demo on W3C site Mozilla Firefox
53 SVG attachments aren't displayed Mozilla Thunderbird60 SVG attachments aren't displayed Mozilla Thunderbird
5461
55== getQuestionLanguages() ==62
5663Languages
57The getQuestionLanguages() returns the set of languages that is used by64=========
58all the questions in the project products.65
5966getQuestionLanguages() returns the set of languages that is used by all the
60 >>> sorted(language.code67questions in the project group's projects.
61 ... for language in mozilla_project.getQuestionLanguages())68
62 [u'en', u'pt_BR']69 # The Firefox project group has one question created in Brazilian
6370 # Portuguese.
64(The firefox product has one question created in Brazilian Portuguese.)71 >>> print ', '.join(
6572 ... sorted(language.code
66In the case where a Project has no Products, language questions will 73 ... for language in mozilla_project.getQuestionLanguages()))
67still return and empty set.74 en, pt_BR
6875
69 >>> [language.code for language in aaa_project.getQuestionLanguages()]76In the case where a project group has no projects, there are no results.
77
78 >>> list(aaa_project.getQuestionLanguages())
70 []79 []
71
7280
=== modified file 'lib/lp/answers/doc/question.txt'
--- lib/lp/answers/doc/question.txt 2009-03-24 12:43:49 +0000
+++ lib/lp/answers/doc/question.txt 2010-02-10 15:24:19 +0000
@@ -1,21 +1,24 @@
1= Launchpad Answer Tracker =1========================
2Launchpad Answer Tracker
3========================
24
3Launchpad includes an Answer Tracker where users can post questions5Launchpad includes an Answer Tracker where users can post questions, usually
4(usually about problems they encounter with projects) and other can6about problems they encounter with projects, and other people can answer them.
5answer them.) Questions are created and accessed using the7Questions are created and accessed using the IQuestionTarget interface. This
6IQuestionTarget interface. This interface is available on Products,8interface is available on Products, Distributions and
7Distributions and DistributionSourcePackages.9DistributionSourcePackages.
810
9 >>> login('test@canonical.com')11 >>> login('test@canonical.com')
1012
11 >>> from canonical.launchpad.webapp.testing import verifyObject13 >>> from canonical.launchpad.webapp.testing import verifyObject
12 >>> from canonical.launchpad.interfaces import (14 >>> from lp.answers.interfaces.questiontarget import IQuestionTarget
13 ... IDistributionSet, IProductSet, IPersonSet, IQuestionTarget)15 >>> from lp.registry.interfaces.product import IProductSet
1416
15 >>> firefox = getUtility(IProductSet)['firefox']17 >>> firefox = getUtility(IProductSet)['firefox']
16 >>> verifyObject(IQuestionTarget, firefox)18 >>> verifyObject(IQuestionTarget, firefox)
17 True19 True
1820
21 >>> from lp.registry.interfaces.distribution import IDistributionSet
19 >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')22 >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
20 >>> verifyObject(IQuestionTarget, ubuntu)23 >>> verifyObject(IQuestionTarget, ubuntu)
21 True24 True
@@ -24,9 +27,9 @@
24 >>> verifyObject(IQuestionTarget, evolution_in_ubuntu)27 >>> verifyObject(IQuestionTarget, evolution_in_ubuntu)
25 True28 True
2629
27Although Distribution series do not implement the IQuestionTarget30Although distribution series do not implement the IQuestionTarget interface,
28interface, it is possible to adapt one to it. (The adapter is actually31it is possible to adapt one to it. The adapter is actually the distroseries's
29the distroseries's distribution.)32distribution.
3033
31 >>> ubuntu_warty = ubuntu.getSeries('warty')34 >>> ubuntu_warty = ubuntu.getSeries('warty')
32 >>> IQuestionTarget.providedBy(ubuntu_warty)35 >>> IQuestionTarget.providedBy(ubuntu_warty)
@@ -56,29 +59,33 @@
56You create a new question by calling the newQuestion() method of an59You create a new question by calling the newQuestion() method of an
57IQuestionTarget attribute.60IQuestionTarget attribute.
5861
62 >>> from lp.registry.interfaces.person import IPersonSet
59 >>> sample_person = getUtility(IPersonSet).getByEmail('test@canonical.com')63 >>> sample_person = getUtility(IPersonSet).getByEmail('test@canonical.com')
60 >>> firefox_question = firefox.newQuestion(64 >>> firefox_question = firefox.newQuestion(
61 ... sample_person, "Firefox question", "Unable to use Firefox")65 ... sample_person, "Firefox question", "Unable to use Firefox")
6266
63(The complete IQuestionTarget interface is documented in67The complete IQuestionTarget interface is documented in questiontarget.txt.
64../interfaces/ftests/questiontarget.txt.)68
6569
66== Official usage ==70Official usage
6771==============
68A product or distribution may be offically supported by the community72
69using the Answer Tracker. This status is set by the official_answers73A product or distribution may be officially supported by the community using
70attribute on the IProduct and IDistribution.74the Answer Tracker. This status is set by the official_answers attribute on
75the IProduct and IDistribution.
7176
72 >>> ubuntu.official_answers77 >>> ubuntu.official_answers
73 True78 True
74 >>> firefox.official_answers79 >>> firefox.official_answers
75 True80 True
7681
77== IQuestion ==82
7883IQuestion interface
79Questions are manipulated through the IQuestion interface:84===================
8085
81 >>> from canonical.launchpad.interfaces import IQuestion86Questions are manipulated through the IQuestion interface.
87
88 >>> from lp.answers.interfaces.question import IQuestion
82 >>> from zope.security.proxy import removeSecurityProxy89 >>> from zope.security.proxy import removeSecurityProxy
8390
84 # The complete interface is not necessarily available to the91 # The complete interface is not necessarily available to the
@@ -88,21 +95,27 @@
8895
89The person who submitted the question is available in the owner field.96The person who submitted the question is available in the owner field.
9097
91 >>> firefox_question.owner == sample_person98 >>> firefox_question.owner
92 True99 <Person at ... name12 (Sample Person)>
93100
94When the question is created, the owner is added to the question's101When the question is created, the owner is added to the question's
95subscribers:102subscribers.
96103
97 >>> sample_person in [s.person for s in firefox_question.subscriptions]104 >>> from operator import attrgetter
98 True105 >>> def print_subscribers(question):
99106 ... people = [subscription.person
100The question status is 'Open':107 ... for subscription in question.subscriptions]
101108 ... for person in sorted(people, key=attrgetter('name')):
102 >>> firefox_question.status.title109 ... print person.displayname
103 'Open'110 >>> print_subscribers(firefox_question)
104111 Sample Person
105And the creation time is recorded in the datecreated attribute:112
113The question status is 'Open'.
114
115 >>> print firefox_question.status.title
116 Open
117
118The question has a creation time.
106119
107 >>> from datetime import datetime, timedelta120 >>> from datetime import datetime, timedelta
108 >>> from pytz import UTC121 >>> from pytz import UTC
@@ -110,11 +123,10 @@
110 >>> now - firefox_question.datecreated < timedelta(seconds=5)123 >>> now - firefox_question.datecreated < timedelta(seconds=5)
111 True124 True
112125
113The target onto which the question was created is available through the126The target onto which the question was created is also available.
114'target' attribute:
115127
116 >>> firefox_question.target == firefox128 >>> print firefox_question.target.displayname
117 True129 Mozilla Firefox
118130
119It is also possible to adapt a question to its IQuestionTarget.131It is also possible to adapt a question to its IQuestionTarget.
120132
@@ -163,66 +175,69 @@
163 firefox175 firefox
164176
165177
166== Subscriptions and Notifications ==178Subscriptions and notifications
179===============================
167180
168Whenever a question is created or changed, email notifications will be181Whenever a question is created or changed, email notifications will be
169sent. To receive such notification, one can subscribe to the bug using182sent. To receive such notification, one can subscribe to the bug using
170the subscribe() method.183the subscribe() method.
171184
172 >>> no_priv = getUtility(IPersonSet).getByName('no-priv')185 >>> no_priv = getUtility(IPersonSet).getByName('no-priv')
173 >>> subscription = firefox_question.subscribe(no_priv)186 >>> subscription = firefox_question.subscribe(no_priv)
174187
175The list of subscriptions is available in the subscriptions attribute.188The subscribers include the owner and the newly subscribed person.
176In the current case, the subscribers will include the owner
177('Sample Person') and the newly subscribed person.
178189
179 >>> [s.person.displayname for s in firefox_question.subscriptions]190 >>> print_subscribers(firefox_question)
180 [u'Sample Person', u'No Privileges Person']191 Sample Person
192 No Privileges Person
181193
182The getDirectSubscribers() method returns a sorted list of subscribers.194The getDirectSubscribers() method returns a sorted list of subscribers.
183This method iterates like the NotificationRecipientSet returned by the195This method iterates like the NotificationRecipientSet returned by the
184getDirectRecipients() method.196getDirectRecipients() method.
185197
186 >>> [person.displayname198 >>> for person in firefox_question.getDirectSubscribers():
187 ... for person in firefox_question.getDirectSubscribers()]199 ... print person.displayname
188 [u'No Privileges Person', u'Sample Person']200 No Privileges Person
201 Sample Person
189202
190To remove a person from the subscriptions list, we use the unsubscribe()203To remove a person from the subscriptions list, we use the unsubscribe()
191method.204method.
192205
193 >>> firefox_question.unsubscribe(no_priv)206 >>> firefox_question.unsubscribe(no_priv)
194 >>> [s.person.displayname for s in firefox_question.subscriptions]207 >>> print_subscribers(firefox_question)
195 [u'Sample Person']208 Sample Person
196209
197The persons who are on the subscription list are said to be directly210The people on the subscription list are said to be directly subscribed to the
198subscribed to the question. They explicitly choose to get notifications211question. They explicitly chose to get notifications about that particular
199about that particular question. This list of persons is available through212question. This list of people is available through the getDirectRecipients()
200the getDirectRecipients() method.213method.
201214
202 >>> subscribers = firefox_question.getDirectRecipients()215 >>> subscribers = firefox_question.getDirectRecipients()
203216
204That method returns an INotificationRecipientSet, containing the direct217That method returns an INotificationRecipientSet, containing the direct
205subscribers along the rationale for contacting them:218subscribers along with the rationale for contacting them.
206219
207 >>> from canonical.launchpad.interfaces import INotificationRecipientSet220 >>> from canonical.launchpad.interfaces import INotificationRecipientSet
208 >>> verifyObject(INotificationRecipientSet, subscribers)221 >>> verifyObject(INotificationRecipientSet, subscribers)
209 True222 True
210 >>> [person.displayname for person in subscribers]223 >>> def print_reason(subscribers):
211 [u'Sample Person']224 ... for person in subscribers:
212 >>> subscribers.getReason(sample_person)225 ... text, header = subscribers.getReason(person)
213 ('You received this question notification because you are a direct226 ... print header, person.displayname, text
214 subscriber of the question.', 'Subscriber')227 >>> print_reason(subscribers)
228 Subscriber Sample Person You received this question notification
229 because you are a direct subscriber of the question.
215230
216There is also a list of 'indirect' subscribers to the question. These231There is also a list of 'indirect' subscribers to the question. These are
217are persons that didn't explicitly subscribed to the question, but that232people that didn't explicitly subscribe to the question, but that will receive
218will receive notifications for other reason. Answer contacts for the233notifications for other reasons. Answer contacts for the question target are
219question target are part of the indirect subscribers list.234part of the indirect subscribers list.
220235
221 # There are no answer contacts on the firefox product.236 # There are no answer contacts on the firefox product.
222 >>> [person.displayname237 >>> list(firefox_question.getIndirectRecipients())
223 ... for person in firefox_question.getIndirectRecipients()]
224 []238 []
225 >>> from canonical.launchpad.interfaces import ILanguageSet239
240 >>> from lp.services.worlddata.interfaces.language import ILanguageSet
226 >>> english = getUtility(ILanguageSet)['en']241 >>> english = getUtility(ILanguageSet)['en']
227 >>> no_priv.addLanguage(english)242 >>> no_priv.addLanguage(english)
228 >>> firefox.addAnswerContact(no_priv)243 >>> firefox.addAnswerContact(no_priv)
@@ -231,16 +246,14 @@
231 >>> indirect_subscribers = firefox_question.getIndirectRecipients()246 >>> indirect_subscribers = firefox_question.getIndirectRecipients()
232 >>> verifyObject(INotificationRecipientSet, indirect_subscribers)247 >>> verifyObject(INotificationRecipientSet, indirect_subscribers)
233 True248 True
234 >>> [person.displayname for person in indirect_subscribers]249 >>> print_reason(indirect_subscribers)
235 [u'No Privileges Person']250 Answer Contact (Mozilla Firefox) No Privileges Person
236 >>> indirect_subscribers.getReason(no_priv)251 You received this question notification because you are an answer
237 (u'You received this question notification because you are an answer252 contact for Mozilla Firefox.
238 contact for Mozilla Firefox.',
239 u'Answer Contact (Mozilla Firefox)')
240253
241There is a special case for when the question's is associated to a254There is a special case for when the question is associated with a source
242source package. The answer contacts for both the distribution and the255package. The answer contacts for both the distribution and the source package
243source package are part of the indirect subscribers list.256are part of the indirect subscribers list.
244257
245 # Let's register some answer contacts for the distribution and258 # Let's register some answer contacts for the distribution and
246 # the package.259 # the package.
@@ -257,64 +270,80 @@
257 >>> package_question = evolution_in_ubuntu.newQuestion(270 >>> package_question = evolution_in_ubuntu.newQuestion(
258 ... sample_person, 'Upgrading to Evolution 1.4 breaks plug-ins',271 ... sample_person, 'Upgrading to Evolution 1.4 breaks plug-ins',
259 ... "The FnordsHighlighter plug-in doesn't work after upgrade.")272 ... "The FnordsHighlighter plug-in doesn't work after upgrade.")
260 >>> [s.person.displayname for s in package_question.subscriptions]273
261 [u'Sample Person']274 >>> print_subscribers(package_question)
275 Sample Person
276
262 >>> indirect_subscribers = package_question.getIndirectRecipients()277 >>> indirect_subscribers = package_question.getIndirectRecipients()
263 >>> [person.displayname for person in indirect_subscribers]278 >>> for person in indirect_subscribers:
264 [u'No Privileges Person', u'Ubuntu Team']279 ... print person.displayname
265 >>> indirect_subscribers.getReason(ubuntu_team)280 No Privileges Person
266 (u'You received this question notification because you are a member of281 Ubuntu Team
267 Ubuntu Team, which is an answer contact for Ubuntu.',282
268 u'Answer Contact (ubuntu) @ubuntu-team')283 >>> text, header = indirect_subscribers.getReason(ubuntu_team)
269 >>> indirect_subscribers.getReason(no_priv)284 >>> print header, text
270 (u'You received this question notification because you are an answer285 Answer Contact (ubuntu) @ubuntu-team
271 contact for evolution in ubuntu.',286 You received this question notification because you are a member of
272 u'Answer Contact (evolution in ubuntu)')287 Ubuntu Team, which is an answer contact for Ubuntu.
273288
274The question's assignee is also part of the indirect subscription list:289The question's assignee is also part of the indirect subscription list:
275290
276 >>> login('foo.bar@canonical.com')291 >>> login('admin@canonical.com')
277 >>> package_question.assignee = getUtility(IPersonSet).getByName('name16')292 >>> package_question.assignee = getUtility(IPersonSet).getByName('name16')
278 >>> indirect_subscribers = package_question.getIndirectRecipients()293 >>> indirect_subscribers = package_question.getIndirectRecipients()
279 >>> [person.displayname for person in indirect_subscribers]294 >>> for person in indirect_subscribers:
280 [u'Foo Bar', u'No Privileges Person', u'Ubuntu Team']295 ... print person.displayname
281 >>> indirect_subscribers.getReason(package_question.assignee)296 Foo Bar
282 ('You received this question notification because you are the assignee297 No Privileges Person
283 for this question.',298 Ubuntu Team
284 'Assignee')299
300 >>> text, header = indirect_subscribers.getReason(
301 ... package_question.assignee)
302 >>> print header, text
303 Assignee
304 You received this question notification because you are the assignee for
305 this question.
285306
286The getIndirectSubscribers() method iterates like the getIndirectRecipients()307The getIndirectSubscribers() method iterates like the getIndirectRecipients()
287method, but it returns a sorted list instead of a NotificationRecipientSet.308method, but it returns a sorted list instead of a NotificationRecipientSet.
288It too contains the question assignee.309It too contains the question assignee.
289310
290 >>> indirect_subscribers = package_question.getIndirectSubscribers()311 >>> indirect_subscribers = package_question.getIndirectSubscribers()
291 >>> [person.displayname for person in indirect_subscribers]312 >>> for person in indirect_subscribers:
292 [u'Foo Bar', u'No Privileges Person', u'Ubuntu Team']313 ... print person.displayname
314 Foo Bar
315 No Privileges Person
316 Ubuntu Team
293317
294Notifications are sent to the list of direct and indirect subscribers.318Notifications are sent to the list of direct and indirect subscribers. The
295The notification recipients list can be obtained by using the319notification recipients list can be obtained by using the getRecipients()
296getRecipients() method.320method.
297321
298 >>> login('no-priv@canonical.com')322 >>> login('no-priv@canonical.com')
299 >>> subscribers = firefox_question.getRecipients()323 >>> subscribers = firefox_question.getRecipients()
300 >>> verifyObject(INotificationRecipientSet, subscribers)324 >>> verifyObject(INotificationRecipientSet, subscribers)
301 True325 True
302 >>> [person.displayname for person in subscribers]326 >>> for person in subscribers:
303 [u'No Privileges Person', u'Sample Person']327 ... print person.displayname
304328 No Privileges Person
305(More documentation on the question notifications can be found in329 Sample Person
306'answer-tracker-notifications.txt'.)330
307331More documentation on the question notifications can be found in
308332`answer-tracker-notifications.txt`.
309== Workflow ==333
334
335Workflow
336========
310337
311A question status should not be manipulated directly but through the338A question status should not be manipulated directly but through the
312workflow methods.339workflow methods.
313340
314The complete question workflow is documented in341The complete question workflow is documented in
315'answer-tracker-workflow.txt'.342`answer-tracker-workflow.txt`.
316343
317== Bug Linking ==344
345Bug linking
346===========
318347
319Question implements the IBugLinkTarget interface which makes it possible348Question implements the IBugLinkTarget interface which makes it possible
320to link bug report to question.349to link bug report to question.
@@ -323,13 +352,13 @@
323 >>> verifyObject(IBugLinkTarget, firefox_question)352 >>> verifyObject(IBugLinkTarget, firefox_question)
324 True353 True
325354
326(See ../interfaces/ftests/buglinktarget.txt for the documentation and355See ../../bugs/tests/buglinktarget.txt for the documentation and test of the
327test of the IBugLinkTarget interface.)356IBugLinkTarget interface.
328357
329When a bug is linked to a question, the question's owner is subscribed to358When a bug is linked to a question, the question's owner is subscribed to the
330the bug.359bug.
331360
332 >>> from canonical.launchpad.interfaces import IBugSet361 >>> from lp.bugs.interfaces.bug import IBugSet
333 >>> bug7 = getUtility(IBugSet).get(7)362 >>> bug7 = getUtility(IBugSet).get(7)
334 >>> bug7.isSubscribed(firefox_question.owner)363 >>> bug7.isSubscribed(firefox_question.owner)
335 False364 False
@@ -338,33 +367,34 @@
338 >>> bug7.isSubscribed(firefox_question.owner)367 >>> bug7.isSubscribed(firefox_question.owner)
339 True368 True
340369
341When the link is removed, the owner is unsubscribed:370When the link is removed, the owner is unsubscribed.
342371
343 >>> firefox_question.unlinkBug(bug7)372 >>> firefox_question.unlinkBug(bug7)
344 <QuestionBug...>373 <QuestionBug...>
345 >>> bug7.isSubscribed(firefox_question.owner)374 >>> bug7.isSubscribed(firefox_question.owner)
346 False375 False
347376
348== Unsupported Questions ==377
349378Unsupported questions
350While a Person may ask questions in his language of choice, that does379=====================
351not mean that indirect subscribers (Answer Contacts) to an380
352IQuestionTarget speak that language. IQuestionTarget can return a list381While a Person may ask questions in his language of choice, that does not mean
353Questions in languages that are not supported382that indirect subscribers (Answer Contacts) to an IQuestionTarget speak that
383language. IQuestionTarget can return a list Questions in languages that are
384not supported.
354385
355 >>> unsupported_questions = firefox.searchQuestions(unsupported=True)386 >>> unsupported_questions = firefox.searchQuestions(unsupported=True)
356 >>> sorted([question.title for question in unsupported_questions])387 >>> sorted(question.title for question in unsupported_questions)
357 [u'Problemas de Impress\xe3o no Firefox']388 [u'Problemas de Impress\xe3o no Firefox']
358389
359 >>> unsupported_questions = evolution_in_ubuntu.searchQuestions(390 >>> unsupported_questions = evolution_in_ubuntu.searchQuestions(
360 ... unsupported=True)391 ... unsupported=True)
361 >>> sorted([question.title for question in unsupported_questions])392 >>> sorted(question.title for question in unsupported_questions)
362 []393 []
363394
364 >>> warty_question_target = IQuestionTarget(ubuntu_warty)395 >>> warty_question_target = IQuestionTarget(ubuntu_warty)
365 >>> unsupported_questions = warty_question_target.searchQuestions(396 >>> unsupported_questions = warty_question_target.searchQuestions(
366 ... unsupported=True)397 ... unsupported=True)
367 >>> sorted([question.title for question in unsupported_questions])398 >>> sorted(question.title for question in unsupported_questions)
368 [u'Problema al recompilar kernel con soporte smp (doble-n\xfacleo)',399 [u'Problema al recompilar kernel con soporte smp (doble-n\xfacleo)',
369 u'\u0639\u0643\u0633 \u0627\u0644\u062a\u063a\u064a\u064a\u0631...]400 u'\u0639\u0643\u0633 \u0627\u0644\u062a\u063a\u064a\u064a\u0631...]
370
371401
=== 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-10 15:24:19 +0000
@@ -1,69 +1,75 @@
1= Answer Tracker Utility: IQuestionSet =1====================
2Question collections
3====================
24
3There is an IQuestionSet utility that can be use to retrieve and search5The IQuestionSet utility is used to retrieve and search for questions no
4for question whatever the target they were created in.6matter which question target they were created for.
57
6 >>> from canonical.launchpad.webapp.testing import verifyObject8 >>> from canonical.launchpad.webapp.testing import verifyObject
7 >>> from canonical.launchpad.interfaces import IQuestionSet9 >>> from lp.answers.interfaces.questioncollection import IQuestionSet
8 >>> question_set = getUtility(IQuestionSet)10 >>> question_set = getUtility(IQuestionSet)
9 >>> verifyObject(IQuestionSet, question_set)11 >>> verifyObject(IQuestionSet, question_set)
10 True12 True
1113
1214
13== get() ==15Retrieving questions
16====================
1417
15The get() method can be used to get a question with a specific id:18The get() method can be used to retrieve a question with a specific id.
1619
17 >>> question_one = question_set.get(1)20 >>> question_one = question_set.get(1)
18 >>> question_one.title21 >>> print question_one.title
19 u'Firefox cannot render Bank Site'22 Firefox cannot render Bank Site
2023
21If no question exists, a default value is returned:24If no question exists, a default value is returned.
2225
23 >>> default = object()26 >>> default = object()
24 >>> question_nonexistant = question_set.get(123456, default=default)27 >>> question_nonexistant = question_set.get(123456, default=default)
25 >>> question_nonexistant is default28 >>> question_nonexistant is default
26 True29 True
2730
28If no default value is given, None is returned:31If no default value is given, None is returned.
2932
30 >>> question_set.get(123456) is None33 >>> print question_set.get(123456)
31 True34 None
3235
3336
34== searchQuestions() ==37Searching questions
3538===================
36IQuestionSet also defines a searchQuestions() method that can be used to39
37search for questions defined in any products or distributions (in fact,40The IQuestionSet interface defines a searchQuestions() method that is used to
38in any context that allows questions to be defined). Two search criteria41search for questions defined in any question target.
39are defined search_text and status.42
4043
4144Search text
42=== search_text ===45-----------
4346
44The search_text parameter will limit the questions to those matching47The search_text parameter will return questions matching the query using the
45the query using the regular full text algorithm.48regular full text algorithm.
4649
50 # Because not everyone uses a real editor <wink>
51 >>> from canonical.encoding import ascii_smash
47 >>> for question in question_set.searchQuestions(search_text='firefox'):52 >>> for question in question_set.searchQuestions(search_text='firefox'):
48 ... print repr(question.title), question.target.displayname53 ... print ascii_smash(question.title), question.target.displayname
49 u'Problemas de Impress\xe3o no Firefox' Mozilla Firefox54 Problemas de Impressao no Firefox Mozilla Firefox
50 u'Firefox loses focus and gets stuck' Mozilla Firefox55 Firefox loses focus and gets stuck Mozilla Firefox
51 u'Firefox cannot render Bank Site' Mozilla Firefox56 Firefox cannot render Bank Site Mozilla Firefox
52 u'mailto: problem in webpage' mozilla-firefox in ubuntu57 mailto: problem in webpage mozilla-firefox in ubuntu
53 u"Newly installed plug-in doesn't seem to be used" Mozilla Firefox58 Newly installed plug-in doesn't seem to be used Mozilla Firefox
54 u'Problem showing the SVG demo on W3C site' Mozilla Firefox59 Problem showing the SVG demo on W3C site Mozilla Firefox
55 u'\u0639\u0643\u0633 ...' Ubuntu60 AINKAFSEEN ALEFLAMTEHGHAINYEHYEHREHALEFTEH ... Ubuntu
5661
5762
58=== status ===63Status
5964------
60By default, expired and invalid questions are not searched for. The65
61status parameter can be used to select the questions in the status66By default, expired and invalid questions are not searched for. The status
62you are interested in.67parameter can be used to select the questions in the status you are interested
6368in.
64 >>> from canonical.launchpad.interfaces import QuestionStatus69
70 >>> from lp.answers.interfaces.questionenums import QuestionStatus
65 >>> for question in question_set.searchQuestions(71 >>> for question in question_set.searchQuestions(
66 ... status=QuestionStatus.INVALID):72 ... status=QuestionStatus.INVALID):
67 ... print question.title, question.status.title, (73 ... print question.title, question.status.title, (
68 ... question.target.displayname)74 ... question.target.displayname)
69 Firefox is slow and consumes too much RAM Invalid mozilla-firefox in ubuntu75 Firefox is slow and consumes too much RAM Invalid mozilla-firefox in ubuntu
@@ -78,111 +84,118 @@
78 Firefox is slow and consumes too much RAM Invalid mozilla-firefox in ubuntu84 Firefox is slow and consumes too much RAM Invalid mozilla-firefox in ubuntu
7985
8086
81=== language ===87Language
88--------
8289
83The language parameter can be used to select only questions written in a90The language parameter can be used to select only questions written in a
84particular language.91particular language.
8592
86 >>> from canonical.launchpad.interfaces import ILanguageSet93 >>> from lp.services.worlddata.interfaces.language import ILanguageSet
87 >>> spanish = getUtility(ILanguageSet)['es']94 >>> spanish = getUtility(ILanguageSet)['es']
88 >>> for t in question_set.searchQuestions(language=spanish):95 >>> for t in question_set.searchQuestions(language=spanish):
89 ... print t.title.encode('us-ascii', 'backslashreplace')96 ... print ascii_smash(t.title)
90 Problema al recompilar kernel con soporte smp (doble-n\xfacleo)97 Problema al recompilar kernel con soporte smp (doble-nucleo)
9198
92=== Combination ===99
93100Combinations
94The returned sets of questions is the intersection of the sets delimited101------------
95by each criteria:102
103The returned set of questions is the intersection of the sets delimited by
104each criteria.
96105
97 >>> for question in question_set.searchQuestions(106 >>> for question in question_set.searchQuestions(
98 ... search_text='firefox',107 ... search_text='firefox',
99 ... status=[QuestionStatus.OPEN, QuestionStatus.INVALID]):108 ... status=(QuestionStatus.OPEN, QuestionStatus.INVALID)):
100 ... print repr(question.title), question.status.title, (109 ... print ascii_smash(question.title), question.status.title, (
101 ... question.target.displayname)110 ... question.target.displayname)
102 u'Problemas de Impress\xe3o no Firefox' Open Mozilla Firefox111 Problemas de Impressao no Firefox Open Mozilla Firefox
103 u'Firefox is slow and consumes too much RAM' Invalid mozilla-firefox in ubuntu112 Firefox is slow and consumes too much RAM Invalid mozilla-firefox in ubuntu
104 u'Firefox loses focus and gets stuck' Open Mozilla Firefox113 Firefox loses focus and gets stuck Open Mozilla Firefox
105 u'Firefox cannot render Bank Site' Open Mozilla Firefox114 Firefox cannot render Bank Site Open Mozilla Firefox
106 u'Problem showing the SVG demo on W3C site' Open Mozilla Firefox115 Problem showing the SVG demo on W3C site Open Mozilla Firefox
107 u'\u0639\u0643\u0633 ...' Open Ubuntu116 AINKAFSEEN ALEFLAMTEHGHAINYEHYEHREHALEFTEH ... Ubuntu
108117
109118
110=== Sort Order ===119Sort order
111120----------
112When using the search_text criteria, the default is to sort the results121
113by relevancy. One can use the sort parameter to change that. It takes122When using the search_text criteria, the default is to sort the results by
114one of the constant defined in the QuestionSort enumeration.123relevancy. One can use the sort parameter to change the order. It takes one
115124of the constant defined in the QuestionSort enumeration.
116 >>> from canonical.launchpad.interfaces import QuestionSort125
126 >>> from lp.answers.interfaces.questionenums import QuestionSort
117 >>> for question in question_set.searchQuestions(127 >>> for question in question_set.searchQuestions(
118 ... search_text='firefox', sort=QuestionSort.OLDEST_FIRST):128 ... search_text='firefox', sort=QuestionSort.OLDEST_FIRST):
119 ... print question.id, repr(question.title), (129 ... print question.id, ascii_smash(question.title), (
120 ... question.target.displayname)130 ... question.target.displayname)
121 14 u'\u0639\u0643\u0633 ...' Ubuntu131 14 AINKAFSEEN ALEFLAMTEHGHAINYEHYEHREHALEFTEH ... Ubuntu
122 1 u'Firefox cannot render Bank Site' Mozilla Firefox132 1 Firefox cannot render Bank Site Mozilla Firefox
123 2 u'Problem showing the SVG demo on W3C site' Mozilla Firefox133 2 Problem showing the SVG demo on W3C site Mozilla Firefox
124 4 u'Firefox loses focus and gets stuck' Mozilla Firefox134 4 Firefox loses focus and gets stuck Mozilla Firefox
125 6 u"Newly installed plug-in doesn't seem to be used" Mozilla Firefox135 6 Newly installed plug-in doesn't seem to be used Mozilla Firefox
126 9 u'mailto: problem in webpage' mozilla-firefox in ubuntu136 9 mailto: problem in webpage mozilla-firefox in ubuntu
127 13 u'Problemas de Impress\xe3o no Firefox' Mozilla Firefox137 13 Problemas de Impressao no Firefox Mozilla Firefox
128138
129When no text search is done, the default sort order is139When no text search is done, the default sort order is by newest first.
130QuestionSort.NEWEST_FIRST.
131140
132 >>> for question in question_set.searchQuestions(141 >>> for question in question_set.searchQuestions(
133 ... status=QuestionStatus.OPEN)[:5]:142 ... status=QuestionStatus.OPEN)[:5]:
134 ... print question.id, repr(question.title), (143 ... print question.id, ascii_smash(question.title), (
135 ... question.target.displayname)144 ... question.target.displayname)
136 13 u'Problemas de Impress\xe3o no Firefox' Mozilla Firefox145 13 Problemas de Impressao no Firefox Mozilla Firefox
137 12 u'Problema al recompilar kernel con soporte smp (doble-n\xfacleo)' Ubuntu146 12 Problema al recompilar kernel con soporte smp (doble-nucleo) Ubuntu
138 11 u'Continue playing after shutdown' Ubuntu147 11 Continue playing after shutdown Ubuntu
139 5 u'Installation failed' Ubuntu148 5 Installation failed Ubuntu
140 4 u'Firefox loses focus and gets stuck' Mozilla Firefox149 4 Firefox loses focus and gets stuck Mozilla Firefox
141150
142151
143== getQuestionLanguages() ==152Question languages
153==================
144154
145The getQuestionLanguages() method returns the set of languages in which155The getQuestionLanguages() method returns the set of languages in which
146questions are written in Launchpad.156questions are written in launchpad.
147157
148 >>> sorted([language.code158 >>> print ', '.join(
149 ... for language in question_set.getQuestionLanguages()])159 ... sorted(language.code
150 [u'ar', u'en', u'es', u'pt_BR']160 ... for language in question_set.getQuestionLanguages()))
151161 ar, en, es, pt_BR
152162
153== getActiveProjects() ==163
154164Active projects
155This method can be used to retrieve the projects that are the most165===============
156actively using the Answer Tracker in the last 60 days. By active, we166
157mean that the project is registered as officially using Answers and167This method can be used to retrieve the projects that are the most actively
158had some questions asked in the period. The projects are ordered168using the Answer Tracker in the last 60 days. By active, we mean that the
159by the number of questions asked during the period.169project is registered as officially using Answers and had some questions asked
160170in the period. The projects are ordered by the number of questions asked
161Sample data should not contain any questions more recent than171during the period.
162two months, so no projects are initially returned:172
163173Initially, no projects are returned.
164 >>> for project in question_set.getMostActiveProjects():174
165 ... print project.displayname175 >>> list(question_set.getMostActiveProjects())
166176 []
167Create recent questions on a number of projects.177
168178Then some recent questions are created on a number of projects.
169 >>> from lp.answers.testing import (179
170 ... QuestionFactory)180 >>> from lp.answers.testing import QuestionFactory
171 >>> from canonical.launchpad.interfaces import (181 >>> from lp.registry.interfaces.distribution import IDistributionSet
172 ... IDistributionSet, ILaunchBag, IProductSet)182 >>> from lp.registry.interfaces.person import IPersonSet
173 >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')183 >>> from lp.registry.interfaces.product import IProductSet
184
174 >>> firefox = getUtility(IProductSet).getByName('firefox')185 >>> firefox = getUtility(IProductSet).getByName('firefox')
175 >>> landscape = getUtility(IProductSet).getByName('landscape')186 >>> landscape = getUtility(IProductSet).getByName('landscape')
176 >>> launchpad = getUtility(IProductSet).getByName('launchpad')187 >>> launchpad = getUtility(IProductSet).getByName('launchpad')
188 >>> no_priv = getUtility(IPersonSet).getByName('no-priv')
189 >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
177190
178 >>> login('no-priv@canonical.com')191 >>> login('no-priv@canonical.com')
179 >>> no_priv = getUtility(ILaunchBag).user192 >>> QuestionFactory.createManyByProject((
180 >>> QuestionFactory.createManyByProject([
181 ... ('ubuntu', 3),193 ... ('ubuntu', 3),
182 ... ('firefox', 2),194 ... ('firefox', 2),
183 ... ('landscape', 1)])195 ... ('landscape', 1),
196 ... ))
184197
185Create a question just before the time limit on Launchpad.198A question is created just before the time limit on Launchpad.
186199
187 >>> from datetime import datetime, timedelta200 >>> from datetime import datetime, timedelta
188 >>> from pytz import UTC201 >>> from pytz import UTC
@@ -191,9 +204,9 @@
191 ... datecreated=datetime.now(UTC) - timedelta(days=61))204 ... datecreated=datetime.now(UTC) - timedelta(days=61))
192 >>> login(ANONYMOUS)205 >>> login(ANONYMOUS)
193206
194The method returns only projects which officially use the Answer207The method returns only projects which officially use the Answer Tracker. The
195Tracker. The order of the returned projects is based on the number of208order of the returned projects is based on the number of questions asked
196questions asked during the period.209during the period.
197210
198 >>> ubuntu.official_answers211 >>> ubuntu.official_answers
199 True212 True
@@ -204,14 +217,13 @@
204 >>> launchpad.official_answers217 >>> launchpad.official_answers
205 True218 True
206219
220 # Launchpad is not returned because the question was not asked in
221 # the last 60 days.
207 >>> for project in question_set.getMostActiveProjects():222 >>> for project in question_set.getMostActiveProjects():
208 ... print project.displayname223 ... print project.displayname
209 Ubuntu224 Ubuntu
210 Mozilla Firefox225 Mozilla Firefox
211226
212(Launchpad is not returned because the question was not asked in
213the last 60 days.)
214
215The method accepts an optional limit parameter limiting the number of227The method accepts an optional limit parameter limiting the number of
216project returned:228project returned:
217229
@@ -220,10 +232,11 @@
220 Ubuntu232 Ubuntu
221233
222234
223== getOpenQuestionCountByPackages() ==235Counting the open questions
236===========================
224237
225getOpenQuestionCountByPackages() allow you to get the count of open238getOpenQuestionCountByPackages() allow you to get the count of open questions
226questions on a set of IDistributionSourcePackage packages.239on a set of IDistributionSourcePackage packages.
227240
228 >>> question_set.getOpenQuestionCountByPackages([])241 >>> question_set.getOpenQuestionCountByPackages([])
229 {}242 {}
@@ -246,12 +259,10 @@
246 >>> closed_question.setStatus(259 >>> closed_question.setStatus(
247 ... closed_question.owner, QuestionStatus.SOLVED, 'no comment')260 ... closed_question.owner, QuestionStatus.SOLVED, 'no comment')
248 <QuestionMessage at ...>261 <QuestionMessage at ...>
249 >>> from canonical.launchpad.ftests import syncUpdate
250 >>> syncUpdate(closed_question)
251262
252 >>> from operator import itemgetter263 >>> from operator import itemgetter
253 >>> packages = [264 >>> packages = (
254 ... ubuntu_evolution, ubuntu_pmount, debian_evolution, debian_pmount]265 ... ubuntu_evolution, ubuntu_pmount, debian_evolution, debian_pmount)
255 >>> package_counts = question_set.getOpenQuestionCountByPackages(packages)266 >>> package_counts = question_set.getOpenQuestionCountByPackages(packages)
256 >>> len(packages)267 >>> len(packages)
257 4268 4
@@ -263,5 +274,3 @@
263 pmount (Ubuntu): 4274 pmount (Ubuntu): 4
264 evolution (Debian): 3275 evolution (Debian): 3
265 pmount (Debian): 0276 pmount (Debian): 0
266
267
268277
=== modified file 'lib/lp/answers/doc/questiontarget.txt'
--- lib/lp/answers/doc/questiontarget.txt 2009-03-24 12:43:49 +0000
+++ lib/lp/answers/doc/questiontarget.txt 2010-02-10 15:24:19 +0000
@@ -1,38 +1,43 @@
1= IQuestionTarget Interface =1=========================
22IQuestionTarget interface
3Launchpad includes an answer tracker. Questions are associated to3=========================
4objects implementing IQuestionTarget. This file documents that interface4
5and can be used to validate implementation of this interface on a5Launchpad includes an answer tracker. Questions are associated to objects
6particular object. (This object is made available through the 'target'6implementing IQuestionTarget.
7variable which is defined outside of this file, usually by a7
8LaunchpadFunctionalTestCase. This instance shouldn't have any questions8 # An IQuestionTarget object is made available to this test via the
9associated with it at the start of the test.)9 # 'target' variable by the test framework. It won't have any questions
1010 # associated with it at the start of the test. This is done because the
11 # exact same test applies to all types of question targets: products,
12 # distributions, and distribution source packages.
13 #
11 # Some parts of the IQuestionTarget interface are only accessible14 # Some parts of the IQuestionTarget interface are only accessible
12 # to a registered user.15 # to a registered user.
13 >>> login('no-priv@canonical.com')16 >>> login('no-priv@canonical.com')
1417
15 >>> from zope.component import getUtility18 >>> from zope.component import getUtility
16 >>> from zope.interface.verify import verifyObject19 >>> from zope.interface.verify import verifyObject
17 >>> from canonical.launchpad.interfaces import IQuestionTarget20 >>> from lp.answers.interfaces.questiontarget import IQuestionTarget
1821
19 >>> verifyObject(IQuestionTarget, target)22 >>> verifyObject(IQuestionTarget, target)
20 True23 True
2124
22== newQuestion() ==25
26New questions
27=============
2328
24Questions are always owned by a registered user.29Questions are always owned by a registered user.
2530
26 >>> from canonical.launchpad.interfaces import IPersonSet31 >>> from lp.registry.interfaces.person import IPersonSet
27 >>> sample_person = getUtility(IPersonSet).getByEmail(32 >>> sample_person = getUtility(IPersonSet).getByEmail(
28 ... 'test@canonical.com')33 ... 'test@canonical.com')
2934
30The newQuestion() method is used to create question that will be associated35The newQuestion() method is used to create a question that will be associated
31with the target. It takes as parameters the question's owner, title and36with the target. It takes as parameters the question's owner, title and
32description. It also takes an optional parameter 'datecreated' parameter37description. It also takes an optional parameter 'datecreated' which defaults
33which defaults to UTC_NOW.38to UTC_NOW.
3439
35 # Let's define now to a know value.40 # Initialize 'now' to a known value.
36 >>> from datetime import datetime, timedelta41 >>> from datetime import datetime, timedelta
37 >>> from pytz import UTC42 >>> from pytz import UTC
38 >>> now = datetime.now(UTC)43 >>> now = datetime.now(UTC)
@@ -43,8 +48,8 @@
43 New question48 New question
44 >>> print question.description49 >>> print question.description
45 Question description50 Question description
46 >>> question.owner == sample_person51 >>> print question.owner.displayname
47 True52 Sample Person
48 >>> question.datecreated == now53 >>> question.datecreated == now
49 True54 True
50 >>> question.datelastquery == now55 >>> question.datelastquery == now
@@ -53,41 +58,44 @@
53The created question starts in the 'Open' status and should have the owner58The created question starts in the 'Open' status and should have the owner
54subscribed to the question.59subscribed to the question.
5560
56 >>> question.status.title61 >>> print question.status.title
57 'Open'62 Open
5863
59 >>> sample_person in [s.person for s in question.subscriptions]64 >>> for subscription in question.subscriptions:
60 True65 ... print subscription.person.displayname
6166 Sample Person
62Question can be written in any languages supported in Launchpad. The67
63language of the request is available in the 'language' attribute. By68Questions can be written in any languages supported in Launchpad. The
64default, requests are assumed to be written in English:69language of the request is available in the 'language' attribute. By default,
70requests are assumed to be written in English.
6571
66 >>> print question.language.code72 >>> print question.language.code
67 en73 en
6874
69It is possible to create question in another language than English. One75It is possible to create questions in another language than English, by
70just need to pass the language in which the question is written in the76passing in the language that the question is written in.
71language parameter.
7277
73 >>> from canonical.launchpad.interfaces import ILanguageSet78 >>> from lp.services.worlddata.interfaces.language import ILanguageSet
74 >>> french = getUtility(ILanguageSet)['fr']79 >>> french = getUtility(ILanguageSet)['fr']
75 >>> question = target.newQuestion(sample_person, "De l'aide S.V.P.",80 >>> question = target.newQuestion(
81 ... sample_person, "De l'aide S.V.P.",
76 ... "Pouvez-vous m'aider?", language=french,82 ... "Pouvez-vous m'aider?", language=french,
77 ... datecreated=now + timedelta(seconds=30))83 ... datecreated=now + timedelta(seconds=30))
78 >>> print question.language.code84 >>> print question.language.code
79 fr85 fr
8086
81Anonymous users cannot use newQuestion():87Anonymous users cannot use newQuestion().
8288
83 >>> login(ANONYMOUS)89 >>> login(ANONYMOUS)
84 >>> question = target.newQuestion(sample_person, 'This will fail',90 >>> question = target.newQuestion(
85 ... 'Failed?')91 ... sample_person, 'This will fail', 'Failed?')
86 Traceback (most recent call last):92 Traceback (most recent call last):
87 ...93 ...
88 Unauthorized...94 Unauthorized...
8995
90== getQuestion() ==96
97Retrieving questions
98====================
9199
92The getQuestion() method is used to retrieve a question by id for a100The getQuestion() method is used to retrieve a question by id for a
93particular target.101particular target.
@@ -96,19 +104,19 @@
96 True104 True
97105
98If you pass in a non-existent id or a question for a different target, the106If you pass in a non-existent id or a question for a different target, the
99method must return None.107method returns None.
100108
101 >>> target.getQuestion(2) is None109 >>> print target.getQuestion(2)
102 True110 None
103 >>> target.getQuestion(12345) is None111 >>> print target.getQuestion(12345)
104 True112 None
105113
106== Creating some additional questions ==114
107115Searching for questions
108For the following methods, we will require some more questions. Create five116=======================
109new questions. Odd questions will be owned by foo_bar and even questions will be117
110owned by sample_person.118 # Create new questions for the following tests. Odd questions will be
111119 # owned by Foo Bar and even questions will be owned by Sample Person.
112 >>> login('no-priv@canonical.com')120 >>> login('no-priv@canonical.com')
113 >>> foo_bar = getUtility(IPersonSet).getByEmail('foo.bar@canonical.com')121 >>> foo_bar = getUtility(IPersonSet).getByEmail('foo.bar@canonical.com')
114 >>> questions = []122 >>> questions = []
@@ -123,9 +131,8 @@
123 ... owner, 'Question title%d' % num, description,131 ... owner, 'Question title%d' % num, description,
124 ... datecreated=now+timedelta(minutes=num+1)))132 ... datecreated=now+timedelta(minutes=num+1)))
125133
126For more variety, we will set the status of the last to INVALID and the134 # For more variety, we will set the status of the last to INVALID and the
127fourth one to ANSWERED.135 # fourth one to ANSWERED.
128
129 >>> login('foo.bar@canonical.com')136 >>> login('foo.bar@canonical.com')
130 >>> foo_bar = getUtility(IPersonSet).getByEmail('foo.bar@canonical.com')137 >>> foo_bar = getUtility(IPersonSet).getByEmail('foo.bar@canonical.com')
131 >>> message = questions[-1].reject(138 >>> message = questions[-1].reject(
@@ -134,48 +141,44 @@
134 ... sample_person, 'This is your answer.',141 ... sample_person, 'This is your answer.',
135 ... datecreated=now+timedelta(hours=1))142 ... datecreated=now+timedelta(hours=1))
136143
137Also add a reply from the owner on the first of these.144 # Also add a reply from the owner on the first of these.
138
139 >>> login('test@canonical.com')145 >>> login('test@canonical.com')
140 >>> message = questions[0].giveInfo(146 >>> message = questions[0].giveInfo(
141 ... 'I think I forgot something.', datecreated=now+timedelta(hours=4))147 ... 'I think I forgot something.', datecreated=now+timedelta(hours=4))
142148
143And create another one that will also have the word 'new' in its149 # Create another one that will also have the word 'new' in its
144description.150 # description.
145
146 >>> question = target.newQuestion(sample_person, 'Another question',151 >>> question = target.newQuestion(sample_person, 'Another question',
147 ... 'Another new question that is actually very new.',152 ... 'Another new question that is actually very new.',
148 ... datecreated=now+timedelta(hours=1))153 ... datecreated=now+timedelta(hours=1))
149 >>> login(ANONYMOUS)154 >>> login(ANONYMOUS)
150155
151 # Flush those changes to the database.
152 >>> from canonical.database.sqlbase import flush_database_updates
153 >>> flush_database_updates()
154
155== searchQuestions() ==
156
157The searchQuestions() method is used to search for questions.156The searchQuestions() method is used to search for questions.
158157
159=== search_text ===158
159Search text
160-----------
160161
161The search_text parameter will select the questions that contain the162The search_text parameter will select the questions that contain the
162passed in text. (The standard text searching algorithm is used, see163passed in text. The standard text searching algorithm is used; see
163textsearching.txt.)164../../../canonical/launchpad/doct/textsearching.txt.
164165
165 >>> for t in target.searchQuestions(search_text='new'):166 >>> for t in target.searchQuestions(search_text='new'):
166 ... print t.title167 ... print t.title
167 New question168 New question
168 Another question169 Another question
169170
170The results here are sorted by relevancy. (In the last questions, 'New'171The results are sorted by relevancy. In the last questions, 'New' appeared in
171appeared in the description which makes it less relevant than when the172the description which makes it less relevant than when the word appears in the
172word appears in the title.)173title.
173174
174=== status ===175
175176Status
176The searchQuestions() method can also filter questions by status:177------
177178
178 >>> from canonical.launchpad.interfaces import QuestionStatus179The searchQuestions() method can also filter questions by status.
180
181 >>> from lp.answers.interfaces.questionenums import QuestionStatus
179 >>> for t in target.searchQuestions(status=QuestionStatus.OPEN):182 >>> for t in target.searchQuestions(status=QuestionStatus.OPEN):
180 ... print t.title183 ... print t.title
181 Another question184 Another question
@@ -192,9 +195,9 @@
192 ... print t.title195 ... print t.title
193 Question title4196 Question title4
194197
195You can also pass in a list of status, and you can also use the198You can pass in a list of statuses, and you can also use the search_text and
196search_text and status parameters at the same time. This will search199status parameters at the same time. This will search OPEN and INVALID
197OPEN and INVALID questions with the word 'index'200questions with the word 'index'.
198201
199 >>> for t in target.searchQuestions(search_text='request index',202 >>> for t in target.searchQuestions(search_text='request index',
200 ... status=(QuestionStatus.OPEN, QuestionStatus.INVALID)):203 ... status=(QuestionStatus.OPEN, QuestionStatus.INVALID)):
@@ -204,29 +207,30 @@
204 Question title1207 Question title1
205 Question title0208 Question title0
206209
207=== sort ===210
208211Sorting
209You can control the sort order by passing one of the constants defined212-------
210in QuestionSort. (We already saw the NEWEST_FIRST and RELEVANCY sort213
211order).214You can control the sort order by passing one of the constants defined in
212215QuestionSort. Previously, we saw the NEWEST_FIRST and RELEVANCY sort order.
213You can sort also from oldest to newest using the OLDEST_FIRST constant:216
214217You can sort also from oldest to newest using the OLDEST_FIRST constant.
215 >>> from canonical.launchpad.interfaces import QuestionSort218
216219 >>> from lp.answers.interfaces.questionenums import QuestionSort
217 >>> for t in target.searchQuestions(search_text='new',220 >>> for t in target.searchQuestions(search_text='new',
218 ... sort=QuestionSort.OLDEST_FIRST):221 ... sort=QuestionSort.OLDEST_FIRST):
219 ... print t.title222 ... print t.title
220 New question223 New question
221 Another question224 Another question
222225
223You can sort by status, (the status order is OPEN, NEEDSINFO, ANSWERED,226You can sort by status (the status order is OPEN, NEEDSINFO, ANSWERED, SOLVED,
224SOLVED, EXPIRED, INVALID), this also sorts from newest to oldest as a227EXPIRED, INVALID). This also sorts from newest to oldest as a secondary key.
225secondary key.228Here we use status=None to search for all statuses; by default INVALID and
229EXPIRED questions are excluded.
226230
227 >>> for t in target.searchQuestions(search_text='request index',231 >>> for t in target.searchQuestions(search_text='request index',
228 ... status=None,232 ... status=None,
229 ... sort=QuestionSort.STATUS):233 ... sort=QuestionSort.STATUS):
230 ... print t.status.title, t.title234 ... print t.status.title, t.title
231 Open Question title2235 Open Question title2
232 Open Question title1236 Open Question title1
@@ -234,12 +238,11 @@
234 Answered Question title3238 Answered Question title3
235 Invalid Question title4239 Invalid Question title4
236240
237(In the previous example, we used status=None to search for all
238statuses, by default INVALID and EXPIRED questions are excluded.)
239
240If there is no search_text and the requested sort order is RELEVANCY,241If there is no search_text and the requested sort order is RELEVANCY,
241the questions will be sorted NEWEST_FIRST.242the questions will be sorted NEWEST_FIRST.
242243
244 # 'Question title4' is not shown in this case because it has INVALID as
245 # its status.
243 >>> for t in target.searchQuestions(sort=QuestionSort.RELEVANCY):246 >>> for t in target.searchQuestions(sort=QuestionSort.RELEVANCY):
244 ... print t.title247 ... print t.title
245 Another question248 Another question
@@ -250,14 +253,14 @@
250 De l'aide S.V.P.253 De l'aide S.V.P.
251 New question254 New question
252255
253('Question title4' is not shown in this case because it has INVALID as
254its status.)
255
256The RECENT_OWNER_ACTIVITY sort order sorts first questions which recently256The RECENT_OWNER_ACTIVITY sort order sorts first questions which recently
257received a new message by their owner. (It effectively sorts257received a new message by their owner. It effectively sorts descending on the
258descending on the datelastquery attribute.)258datelastquery attribute.
259259
260 >>> for t in target.searchQuestions(sort=QuestionSort.RECENT_OWNER_ACTIVITY):260 # Question title0 sorts first because it has a message from its owner
261 # after the others were created.
262 >>> for t in target.searchQuestions(
263 ... sort=QuestionSort.RECENT_OWNER_ACTIVITY):
261 ... print t.title264 ... print t.title
262 Question title0265 Question title0
263 Another question266 Another question
@@ -267,20 +270,20 @@
267 De l'aide S.V.P.270 De l'aide S.V.P.
268 New question271 New question
269272
270(Question title0 sorts first because it had a message from its owner273
271after the others were created.)274Owner
272275-----
273=== owner ===276
274277You can find question owned by a particular user by using the owner parameter.
275You can also find question owner by a particular user by using the owner
276parameter.
277278
278 >>> for t in target.searchQuestions(owner=foo_bar):279 >>> for t in target.searchQuestions(owner=foo_bar):
279 ... print t.title280 ... print t.title
280 Question title3281 Question title3
281 Question title1282 Question title1
282283
283=== language ===284
285Language
286---------
284287
285The language criteria can be used to select only questions written in a288The language criteria can be used to select only questions written in a
286particular language.289particular language.
@@ -290,7 +293,7 @@
290 ... print t.title293 ... print t.title
291 De l'aide S.V.P.294 De l'aide S.V.P.
292295
293 >>> for t in target.searchQuestions(language=[english, french]):296 >>> for t in target.searchQuestions(language=(english, french)):
294 ... print t.title297 ... print t.title
295 Another question298 Another question
296 Question title3299 Question title3
@@ -300,39 +303,40 @@
300 De l'aide S.V.P.303 De l'aide S.V.P.
301 New question304 New question
302305
303=== needs_attention_from ===306
304307Questions needing attention
305You can also search among the questions that needs the attention of308---------------------------
306somebody. A question needs the attention of a user if he owns it and that309
307it is in the NEEDSINFO or ANSWERED state. Questions on which the user gave310You can search among the questions that need attention. A question needs the
308an answer or requested for more information and that are back in the311attention of a user if he owns it and if it is in the NEEDSINFO or ANSWERED
309OPEN state are also included.312state. Questions on which the user gave an answer or requested for more
313information, and that are back in the OPEN state, are also included.
310314
311 # One of Sample Person's question gets to need attention from Foo Bar.315 # One of Sample Person's question gets to need attention from Foo Bar.
312 >>> login('foo.bar@canonical.com')316 >>> login('foo.bar@canonical.com')
313 >>> message = questions[0].requestInfo(317 >>> message = questions[0].requestInfo(
314 ... foo_bar, 'Do you have a clue?',318 ... foo_bar, 'Do you have a clue?',
315 ... datecreated=now+timedelta(hours=1))319 ... datecreated=now+timedelta(hours=1))
320
316 >>> login('test@canonical.com')321 >>> login('test@canonical.com')
317 >>> message = questions[0].giveInfo(322 >>> message = questions[0].giveInfo(
318 ... 'I do, now please help me.', datecreated=now+timedelta(hours=2))323 ... 'I do, now please help me.', datecreated=now+timedelta(hours=2))
319324
320 # Another one of Foo Bar's question needs attention.325 # Another one of Foo Bar's questions needs attention.
321 >>> message = questions[1].requestInfo(326 >>> message = questions[1].requestInfo(
322 ... sample_person, 'And you, do you have a clue?',327 ... sample_person, 'And you, do you have a clue?',
323 ... datecreated=now+timedelta(hours=1))328 ... datecreated=now+timedelta(hours=1))
324329
325 # Flush those changes to the database.
326 >>> flush_database_updates()
327 >>> login(ANONYMOUS)330 >>> login(ANONYMOUS)
328
329 >>> for t in target.searchQuestions(needs_attention_from=foo_bar):331 >>> for t in target.searchQuestions(needs_attention_from=foo_bar):
330 ... print t.status.title, t.title, t.owner.displayname332 ... print t.status.title, t.title, t.owner.displayname
331 Answered Question title3 Foo Bar333 Answered Question title3 Foo Bar
332 Needs information Question title1 Foo Bar334 Needs information Question title1 Foo Bar
333 Open Question title0 Sample Person335 Open Question title0 Sample Person
334336
335=== unsupported ===337
338Unsupported language
339--------------------
336340
337The 'unsupported' criteria is used to select questions that are in a341The 'unsupported' criteria is used to select questions that are in a
338language that is not spoken by any of the Support Contacts.342language that is not spoken by any of the Support Contacts.
@@ -341,40 +345,42 @@
341 ... print t.title345 ... print t.title
342 De l'aide S.V.P.346 De l'aide S.V.P.
343347
344== findSimilarQuestions() ==348
345349Finding similar questions
346The method findSimilarQuestions() can be use to find questions similar to a350=========================
347sentence. The questions don't have to contain all the words of the sentence,351
348just some.352The method findSimilarQuestions() can be use to find questions similar to some
349353target text. The questions don't have to contain all the words of the text.
354
355 # This returns the same results as with the search 'new' because
356 # all other words in the text are either common ('question', 'title') or
357 # stop words ('with', 'a').
350 >>> for t in target.findSimilarQuestions('new questions with a title'):358 >>> for t in target.findSimilarQuestions('new questions with a title'):
351 ... print t.title359 ... print t.title
352 New question360 New question
353 Another question361 Another question
354362
355In this case, it returned the same results than with the search 'new' because363
356all other words in the sentence are either common ('question', 'title') or stop364Answer contacts
357words ('with', 'a').365===============
358366
359== Answer contacts ==367Targets can have answer contacts. The list of answer contacts for a
360
361Target can have answer contacts. The list of answer contacts for a
362target is available through the answer_contacts attribute.368target is available through the answer_contacts attribute.
363369
364 >>> list(target.answer_contacts)370 >>> list(target.answer_contacts)
365 []371 []
366372
367There is also a direct_answer_contacts which includes only the373There is also a direct_answer_contacts which includes only the answer contacts
368answer contacts registered explicitly on the question target. (In374registered explicitly on the question target. In general, this will be the
369general, it will be equal to answer_contacts attribute, but some375same as the answer_contacts attribute, but some IQuestionTarget
370IQuestionTarget implementations may inherit answer contacts376implementations may inherit answer contacts from other contexts. In these
371from other context. In these cases, that attribute would only contain377cases, the direct_answer_contacts attribute would only contain the answer
372the answer contacts defined in the current IQuestionTarget context.)378contacts defined in the current IQuestionTarget context.
373379
374 >>> list(target.direct_answer_contacts)380 >>> list(target.direct_answer_contacts)
375 []381 []
376382
377You add an answer contact by using the addAnswerContact method. This383You add an answer contact by using the addAnswerContact() method. This
378is only available to registered users.384is only available to registered users.
379385
380 >>> name18 = getUtility(IPersonSet).getByName('name18')386 >>> name18 = getUtility(IPersonSet).getByName('name18')
@@ -382,22 +388,28 @@
382 Traceback (most recent call last):388 Traceback (most recent call last):
383 ...389 ...
384 Unauthorized...390 Unauthorized...
391
392This method returns True when the contact was added the list and False when it
393was already on the list.
394
385 >>> login('no-priv@canonical.com')395 >>> login('no-priv@canonical.com')
386
387This method will return True when the contact was added the list and
388False when it was already on the list:
389
390 >>> target.addAnswerContact(name18)396 >>> target.addAnswerContact(name18)
391 True397 True
392 >>> [p.name for p in target.answer_contacts]398 >>> people = [p.name for p in target.answer_contacts]
393 [u'name18']399 >>> len(people)
394 >>> [p.name for p in target.direct_answer_contacts]400 1
395 [u'name18']401 >>> print people[0]
402 name18
403 >>> people = [p.name for p in target.direct_answer_contacts]
404 >>> len(people)
405 1
406 >>> print people[0]
407 name18
396 >>> target.addAnswerContact(name18)408 >>> target.addAnswerContact(name18)
397 False409 False
398410
399An answer contact must have at least one language among his411An answer contact must have at least one language among his preferred
400preferred languages.412languages.
401413
402 >>> sample_person = getUtility(IPersonSet).getByName('name12')414 >>> sample_person = getUtility(IPersonSet).getByName('name12')
403 >>> len(sample_person.languages)415 >>> len(sample_person.languages)
@@ -407,10 +419,9 @@
407 ...419 ...
408 AssertionError: An Answer Contact must speak a language...420 AssertionError: An Answer Contact must speak a language...
409421
410Answer contacts can be removed by using the removeAnswerContact()422Answer contacts can be removed by using the removeAnswerContact() method.
411method. Like its counterpart, it returns True when the answer contact423Like its counterpart, it returns True when the answer contact was removed and
412was removed and False when the person wasn't on the answer contact424False when the person wasn't on the answer contact list.
413list.
414425
415 >>> target.removeAnswerContact(name18)426 >>> target.removeAnswerContact(name18)
416 True427 True
@@ -421,7 +432,7 @@
421 >>> target.removeAnswerContact(name18)432 >>> target.removeAnswerContact(name18)
422 False433 False
423434
424Only registered users can remove an answer contact:435Only registered users can remove an answer contact.
425436
426 >>> login(ANONYMOUS)437 >>> login(ANONYMOUS)
427 >>> target.removeAnswerContact(name18)438 >>> target.removeAnswerContact(name18)
@@ -429,85 +440,102 @@
429 ...440 ...
430 Unauthorized...441 Unauthorized...
431442
432== Supported Languages ==443
444Supported languages
445===================
433446
434The supported languages for a given IQuestionTarget are given by447The supported languages for a given IQuestionTarget are given by
435getSupportedLanguages(). The supported languages of a question target448getSupportedLanguages(). The supported languages of a question target include
436include all languages spoken by at least one of its answer contacts,449all languages spoken by at least one of its answer contacts, with the
437with the exception of all English variations. English is the assumed450exception of all English variations since English is the assumed language for
438language for support when there are no answer contacts.451support when there are no answer contacts.
439452
440 >>> [lang.code for lang in target.getSupportedLanguages()]453 >>> codes = [lang.code for lang in target.getSupportedLanguages()]
441 [u'en']454 >>> len(codes)
442455 1
443Let's add some answer contacts which speak different languages.456 >>> print codes[0]
444457 en
458
459 # Let's add some answer contacts which speak different languages.
445 >>> login('carlos@canonical.com')460 >>> login('carlos@canonical.com')
446 >>> carlos = getUtility(IPersonSet).getByName('carlos')461 >>> carlos = getUtility(IPersonSet).getByName('carlos')
447 >>> [lang.code for lang in carlos.languages]462 >>> for language in carlos.languages:
448 [u'ca', u'en', u'es']463 ... print language.code
464 ca
465 en
466 es
449 >>> target.addAnswerContact(carlos)467 >>> target.addAnswerContact(carlos)
450 True468 True
451469
452Note that daf has en_GB as one of his preferred languages...470While daf has en_GB as one of his preferred languages...
453471
454 >>> login('daf@canonical.com')472 >>> login('daf@canonical.com')
455 >>> daf = getUtility(IPersonSet).getByName('daf')473 >>> daf = getUtility(IPersonSet).getByName('daf')
456 >>> [lang.code for lang in daf.languages]474 >>> for language in daf.languages:
457 [u'en_GB', u'ja', u'cy']475 ... print language.code
476 en_GB
477 ja
478 cy
458 >>> target.addAnswerContact(daf)479 >>> target.addAnswerContact(daf)
459 True480 True
460481
461... but en_GB is not included in the target's supported languages,482...en_GB is not included in the target's supported languages, because all
462because we convert all English variants to English.483English variants are converted to English.
463484
464 >>> import operator485 >>> from operator import attrgetter
465 >>> [lang.code for lang in sorted(target.getSupportedLanguages(),486 >>> print ', '.join(
466 ... key=operator.attrgetter('code'))]487 ... language.code
467 [u'ca', u'cy', u'en', u'es', u'ja']488 ... for language in sorted(target.getSupportedLanguages(),
468489 ... key=attrgetter('code')))
469490 ca, cy, en, es, ja
470== getAnswerContactsForLanguage() ==491
471492
472Continuing from the previous section with Carlos and Daf, the493Answer contacts for languages
473getAnswerContactsForLanguage() method returns a list of answer contacts494=============================
474who support the specified language in their preferred languages. Daf495
475is in the list because he speaks an English variant, which is treated496getAnswerContactsForLanguage() method returns a list of answer contacts who
476as English.497support the specified language in their preferred languages. Daf is in the
498list because he speaks an English variant, which is treated as English.
477499
478 >>> spanish = getUtility(ILanguageSet)['es']500 >>> spanish = getUtility(ILanguageSet)['es']
479 >>> answer_contacts = target.getAnswerContactsForLanguage(spanish)501 >>> answer_contacts = target.getAnswerContactsForLanguage(spanish)
480 >>> sorted([person.name for person in answer_contacts])502 >>> for person in answer_contacts:
481 [u'carlos']503 ... print person.name
504 carlos
482505
483 >>> answer_contacts = target.getAnswerContactsForLanguage(english)506 >>> answer_contacts = target.getAnswerContactsForLanguage(english)
484 >>> sorted([person.name for person in answer_contacts])507 >>> for person in answer_contacts:
485 [u'carlos', u'daf']508 ... print person.name
486509 carlos
487510 daf
488== getQuestionLanguages() ==511
512
513A question's languages
514======================
489515
490The getQuestionLanguages() method returns the set of languages used by all516The getQuestionLanguages() method returns the set of languages used by all
491of the target's questions.517of the target's questions.
492518
493 >>> sorted([language.code for language in target.getQuestionLanguages()])519 >>> print ', '.join(
494 [u'en', u'fr']520 ... sorted(language.code
495521 ... for language in target.getQuestionLanguages()))
496522 en, fr
497== createQuestionFromBug() ==523
498524
499The target can create a question from a bug, and link that bug to the525Creating questions from bugs
500new question. The question owner is the same as the bug owner. The526============================
501question title and description are taken from the bug. The messages on527
502the bug are copied to the question.528The target can create a question from a bug, and link that bug to the new
529question. The question's owner is the same as the bug's owner. The question
530title and description are taken from the bug. The comments on the bug are
531copied to the question.
503532
504 >>> from datetime import datetime533 >>> from datetime import datetime
505 >>> from pytz import UTC534 >>> from pytz import UTC
506 >>> from canonical.launchpad.interfaces import (535 >>> from lp.bugs.interfaces.bug import CreateBugParams, IBugSet
507 ... CreateBugParams, IBugSet, IProductSet)536 >>> from lp.registry.interfaces.product import IProductSet
508537
509 >>> now = datetime.now(UTC)538 >>> now = datetime.now(UTC)
510
511 >>> target = getUtility(IProductSet)['jokosher']539 >>> target = getUtility(IProductSet)['jokosher']
512 >>> bug_params = CreateBugParams(540 >>> bug_params = CreateBugParams(
513 ... title="Print is broken", comment="blah blah blah",541 ... title="Print is broken", comment="blah blah blah",
@@ -519,41 +547,34 @@
519547
520 >>> target_question = target.createQuestionFromBug(target_bug)548 >>> target_question = target.createQuestionFromBug(target_bug)
521549
522 >>> target_question.owner == target_bug.owner550 >>> print target_question.owner.displayname
523 True551 Sample Person
524 >>> target_question.title == target_bug.title552 >>> print target_question.title
525 True553 Print is broken
526 >>> target_question.description == target_bug.description554 >>> print target_question.description
527 True555 blah blah blah
528 >>> question_message = target_question.messages[-1]556 >>> question_message = target_question.messages[-1]
529 >>> question_message.text_contents == bug_message.text_contents557 >>> print question_message.text_contents
530 True558 This is really a question.
531559
532 >>> target_question.owner.displayname560 >>> for bug_link in target_question.bug_links:
533 u'Sample Person'561 ... print bug_link.bug.title
534 >>> target_question.title562 Print is broken
535 u'Print is broken'563 >>> print target_question.messages[-1].text_contents
536 >>> target_question.description564 This is really a question.
537 u'blah blah blah'565
538 >>> [bug_link.bug.title for bug_link in target_question.bug_links]566The question's creation date is the same as the bug's creation date. The
539 [u'Print is broken']567question's last response date has a current datetime stamp to indicate the
540 >>> target_question.messages[-1].text_contents568question is active. The question janitor would otherwise mistake the
541 u'This is really a question.'569questions made from old bugs as old questions and would expire them.
542
543The question's datecreated attribute is the same as the bug's
544datecreated. The question's datelastresponse attribute has a current
545datetime stamp to indicate the question is active. The question janitor
546would otherwise mistake the questions made from old bugs as old
547questions and would expire them.
548570
549 >>> target_question.datecreated == target_bug.datecreated571 >>> target_question.datecreated == target_bug.datecreated
550 True572 True
551 >>> target_question.datelastresponse > now573 >>> target_question.datelastresponse > now
552 True574 True
553575
554The question language is always English because all bugs in Launchpad576The question language is always English because all bugs in Launchpad are
555are written in English.577written in English.
556578
557 >>> target_question.language.code579 >>> print target_question.language.code
558 u'en'580 en
559
560581
=== modified file 'lib/lp/answers/doc/workflow.txt'
--- lib/lp/answers/doc/workflow.txt 2009-07-13 05:48:57 +0000
+++ lib/lp/answers/doc/workflow.txt 2010-02-10 15:24:19 +0000
@@ -1,11 +1,13 @@
1= Answer Tracker Workflow =1=======================
22Answer tracker workflow
3The state of a question is tracked through its status attribute.3=======================
4Six statuses are used to model a question lifecycle. These are defined4
5in the QuestionStatus enumeration.5The state of a question is tracked through its status, which model a
66question's lifecycle. These are defined in the QuestionStatus enumeration.
7 >>> from canonical.launchpad.interfaces import QuestionStatus7
8 >>> print "\n".join([status.name for status in QuestionStatus.items])8 >>> from lp.answers.interfaces.questionenums import QuestionStatus
9 >>> for status in QuestionStatus.items:
10 ... print status.name
9 OPEN11 OPEN
10 NEEDSINFO12 NEEDSINFO
11 ANSWERED13 ANSWERED
@@ -13,11 +15,12 @@
13 EXPIRED15 EXPIRED
14 INVALID16 INVALID
1517
16Status change occurs in consequence of a user action. The possible18Status change occurs as a consequence of a user's action. The possible
17actions are defined in the QuestionAction enumeration.19actions are defined in the QuestionAction enumeration.
1820
19 >>> from canonical.launchpad.interfaces import QuestionAction21 >>> from lp.answers.interfaces.questionenums import QuestionAction
20 >>> print "\n".join([status.name for status in QuestionAction.items])22 >>> for status in QuestionAction.items:
23 ... print status.name
21 REQUESTINFO24 REQUESTINFO
22 GIVEINFO25 GIVEINFO
23 COMMENT26 COMMENT
@@ -28,19 +31,18 @@
28 REOPEN31 REOPEN
29 SETSTATUS32 SETSTATUS
3033
31There is a method available to execute each of these defined actions.34Each defined action can be executed.
3235
33Let's define the actors that we are going to use to demonstrate the36No Privileges Person is the submitter of questions. Sample Person is an
34Answer Tracker workflow. The 'No Privileges Person' will be the37answer contact for the Ubuntu distribution. Marilize Coetze is another user
35submitter of questions, 'Sample Person' will be an answer contact for38providing support. Stub is a Launchpad administrator that isn't also in the
36the Ubuntu distribution, and 'Marilize Coetze' will be another user39Ubuntu Team owning the distribution.
37providing support. Stub is a launchpad administrator that isn't also in
38the Ubuntu Team that owns the distribution.
3940
40 >>> login('no-priv@canonical.com')41 >>> login('no-priv@canonical.com')
4142
42 >>> from canonical.launchpad.interfaces import (43 >>> from lp.registry.interfaces.distribution import IDistributionSet
43 ... IDistributionSet, ILanguageSet, IPersonSet)44 >>> from lp.registry.interfaces.person import IPersonSet
45 >>> from lp.services.worlddata.interfaces.language import ILanguageSet
4446
45 >>> personset = getUtility(IPersonSet)47 >>> personset = getUtility(IPersonSet)
46 >>> sample_person = personset.getByEmail('test@canonical.com')48 >>> sample_person = personset.getByEmail('test@canonical.com')
@@ -63,28 +65,31 @@
63 >>> from datetime import datetime, timedelta65 >>> from datetime import datetime, timedelta
64 >>> from pytz import UTC66 >>> from pytz import UTC
65 >>> now = datetime.now(UTC)67 >>> now = datetime.now(UTC)
66 >>> new_question_args = {68 >>> new_question_args = dict(
67 ... 'owner': no_priv,69 ... owner=no_priv,
68 ... 'title': 'Unable to boot installer',70 ... title='Unable to boot installer',
69 ... 'description': "I've tried installing Ubuntu on a Mac. "71 ... description="I've tried installing Ubuntu on a Mac. "
70 ... "But the installer never boots.",72 ... "But the installer never boots.",
71 ... 'datecreated': now}73 ... datecreated=now,
74 ... )
72 >>> question = ubuntu.newQuestion(**new_question_args)75 >>> question = ubuntu.newQuestion(**new_question_args)
73 >>> print question.status.title76 >>> print question.status.title
74 Open77 Open
7578
76From there, we have four representative scenarios.79The following scenarios are now possible.
7780
78== 1) Another user helps the submitter with his question ==81
79821) Another user helps the submitter with his question
80The most common scenario is where another user comes to help the83=====================================================
81submitter and answers his question. This may involve exchanging84
82information with the submitter to clarify the question.85The most common scenario is where another user comes to help the submitter and
8386answers his question. This may involve exchanging information with the
84The requestInfo() method is used to ask the user for more information.87submitter to clarify the question.
85This method takes two mandatory parameters: the user making the question88
86and his question. It can also takes a 'datecreated' parameter specifying89The requestInfo() method is used to ask the user for more information. This
87the creation date of the question (which defaults to now).90method takes two mandatory parameters: the user asking the question and his
91question. It can also takes a 'datecreated' parameter specifying the creation
92date of the question (which defaults to 'now').
8893
89 >>> question = ubuntu.newQuestion(**new_question_args)94 >>> question = ubuntu.newQuestion(**new_question_args)
90 >>> now_plus_one_hour = now + timedelta(hours=1)95 >>> now_plus_one_hour = now + timedelta(hours=1)
@@ -92,11 +97,11 @@
92 ... sample_person, 'What is your Mac model?',97 ... sample_person, 'What is your Mac model?',
93 ... datecreated=now_plus_one_hour)98 ... datecreated=now_plus_one_hour)
9499
95It returns the IQuestionMessage that was added to the question messages100We now have the IQuestionMessage that was added to the question messages
96history:101history.
97102
98 >>> from canonical.launchpad.webapp.testing import verifyObject103 >>> from canonical.launchpad.webapp.testing import verifyObject
99 >>> from canonical.launchpad.interfaces import IQuestionMessage104 >>> from lp.answers.interfaces.questionmessage import IQuestionMessage
100 >>> verifyObject(IQuestionMessage, request_message)105 >>> verifyObject(IQuestionMessage, request_message)
101 True106 True
102 >>> request_message == question.messages[-1]107 >>> request_message == question.messages[-1]
@@ -106,9 +111,8 @@
106 >>> print request_message.owner.displayname111 >>> print request_message.owner.displayname
107 Sample Person112 Sample Person
108113
109The question message contains the action that was executed in the action114The question message contains the action that was executed and the status of
110attribute and the status of the question after the action was executed in115the question after the action was executed.
111the new_status attribute:
112116
113 >>> print request_message.action.name117 >>> print request_message.action.name
114 REQUESTINFO118 REQUESTINFO
@@ -118,13 +122,13 @@
118 >>> print request_message.text_contents122 >>> print request_message.text_contents
119 What is your Mac model?123 What is your Mac model?
120124
121The subject of the message was generated automatically:125The subject of the message was generated automatically.
122126
123 >>> print request_message.subject127 >>> print request_message.subject
124 Re: Unable to boot installer128 Re: Unable to boot installer
125129
126The question is moved to the NEEDSINFO state and the datelastresponse130The question is moved to the NEEDSINFO state and the last response date is
127attribute is updated to the message timestamp.131updated to the message's timestamp.
128132
129 >>> print question.status.name133 >>> print question.status.name
130 NEEDSINFO134 NEEDSINFO
@@ -148,33 +152,35 @@
148 >>> print reply_message.owner.displayname152 >>> print reply_message.owner.displayname
149 No Privileges Person153 No Privileges Person
150154
151The question is moved back to the OPEN state and the 'datelastquery'155The question is moved back to the OPEN state and the last query date is
152attribute is updated to the message's creation date:156updated to the message's creation date.
153157
154 >>> print question.status.name158 >>> print question.status.name
155 OPEN159 OPEN
156 >>> question.datelastquery == now_plus_two_hours160 >>> question.datelastquery == now_plus_two_hours
157 True161 True
158162
159The other user has now enough information to give an answer to the163Now, the other user has enough information to give an answer to the question.
160question. The giveAnswer() method is used for that purpose. Like the164The giveAnswer() method is used for that purpose. Like the requestInfo()
161requestInfo() method, it takes two mandatory parameters: the user165method, it takes two mandatory parameters: the user providing the answer and
162providing the answer and the answer itself.166the answer itself.
163167
164 >>> login('test@canonical.com')168 >>> login('test@canonical.com')
165 >>> now_plus_three_hours = now + timedelta(hours=3)169 >>> now_plus_three_hours = now + timedelta(hours=3)
166 >>> answer_message = question.giveAnswer(170 >>> answer_message = question.giveAnswer(
167 ... sample_person, "You need some configuration on the Mac side "171 ... sample_person,
172 ... "You need some configuration on the Mac side "
168 ... "to boot the installer on that model. Consult "173 ... "to boot the installer on that model. Consult "
169 ... "https://help.ubuntu.com/community/Installation/OldWorldMacs "174 ... "https://help.ubuntu.com/community/Installation/OldWorldMacs "
170 ... "for all the details.", datecreated=now_plus_three_hours)175 ... "for all the details.",
176 ... datecreated=now_plus_three_hours)
171 >>> print answer_message.action.name177 >>> print answer_message.action.name
172 ANSWER178 ANSWER
173 >>> print answer_message.new_status.name179 >>> print answer_message.new_status.name
174 ANSWERED180 ANSWERED
175181
176After that action, the question's status is changed to ANSWERED and the182The question's status is changed to ANSWERED and the last response date is
177datelastresponse is updated to contain the date of the message.183updated to contain the date of the message.
178184
179 >>> print question.status.name185 >>> print question.status.name
180 ANSWERED186 ANSWERED
@@ -182,9 +188,8 @@
182 True188 True
183189
184At that point, the question is considered answered, but we don't have190At that point, the question is considered answered, but we don't have
185feedback from the user on whether it solved his problem or not. If it191feedback from the user on whether it solved his problem or not. If it
186doesn't the user can reopen the question. The reopen() method is used192doesn't, the user can reopen the question.
187for that purpose.
188193
189 >>> login('no-priv@canonical.com')194 >>> login('no-priv@canonical.com')
190 >>> tomorrow = now + timedelta(days=1)195 >>> tomorrow = now + timedelta(days=1)
@@ -200,38 +205,37 @@
200 >>> print reopen_message.owner.displayname205 >>> print reopen_message.owner.displayname
201 No Privileges Person206 No Privileges Person
202207
203This moves back the question to the OPEN state and the datelastquery208This moves back the question to the OPEN state and the last query date is
204attribute is updated to the message creation date.209updated to the message's creation date.
205210
206 >>> print question.status.name211 >>> print question.status.name
207 OPEN212 OPEN
208 >>> question.datelastquery == tomorrow213 >>> question.datelastquery == tomorrow
209 True214 True
210215
211The giveAnswer() will again be used to give an answer.216Once again, an answer is given.
212217
213 >>> login('test@canonical.com')218 >>> login('test@canonical.com')
214 >>> tomorrow_plus_one_hour = tomorrow + timedelta(hours=1)219 >>> tomorrow_plus_one_hour = tomorrow + timedelta(hours=1)
215 >>> answer2_message = question.giveAnswer(220 >>> answer2_message = question.giveAnswer(
216 ... marilize, "You probably do not have enough RAM to use the "221 ... marilize,
222 ... "You probably do not have enough RAM to use the "
217 ... "graphical installer. You can try the alternate CD with the "223 ... "graphical installer. You can try the alternate CD with the "
218 ... "text installer.")224 ... "text installer.")
219225
220This again moves the question to the ANSWERED state.226The question is moved back to the ANSWERED state.
221227
222 >>> print question.status.name228 >>> print question.status.name
223 ANSWERED229 ANSWERED
224230
225The question owner will hopefully come back to confirm that his231The question owner will hopefully come back to confirm that his problem is
226problem is solved. He can specify which answer message helped him232solved. He can specify which answer message helped him solved his problem.
227solved his problem. The confirmAnswer() method is used for that
228purpose.
229233
230 >>> login('no-priv@canonical.com')234 >>> login('no-priv@canonical.com')
231 >>> two_weeks_from_now = now + timedelta(days=14)235 >>> two_weeks_from_now = now + timedelta(days=14)
232 >>> confirm_message = question.confirmAnswer(236 >>> confirm_message = question.confirmAnswer(
233 ... "I upgraded to 512M of RAM (found on eBay) and I've "237 ... "I upgraded to 512M of RAM (found on eBay) and I've "
234 ... "succesfully managed to install Ubuntu. Thanks for all the help.",238 ... "successfully managed to install Ubuntu. Thanks for all the help.",
235 ... datecreated=two_weeks_from_now, answer=answer_message)239 ... datecreated=two_weeks_from_now, answer=answer_message)
236 >>> print confirm_message.action.name240 >>> print confirm_message.action.name
237 CONFIRM241 CONFIRM
@@ -240,9 +244,9 @@
240 >>> print confirm_message.owner.displayname244 >>> print confirm_message.owner.displayname
241 No Privileges Person245 No Privileges Person
242246
243The question is moved to the SOLVED state, the message that solved247The question is moved to the SOLVED state, and the message that solved the
244the question is saved in the answer attribute, the date_solved248question is saved. The date the question was solved and answerer are also
245and answerer attributes are also updated.249updated.
246250
247 >>> print question.status.name251 >>> print question.status.name
248 SOLVED252 SOLVED
@@ -254,45 +258,43 @@
254 True258 True
255259
256260
257== 2) Self-answer ==2612) Self-answering
258262=================
259Another scenario is for the case when the user comes back to give the263
260solution to the question himself. The giveAnswer() method is also used264In this scenario the user comes back to give the solution to the question
261for that case. The question owner can choose a best answer message265himself. The question owner can choose a best answer message later on. The
262later on. The workflow permits the question owner to choose an answer266workflow permits the question owner to choose an answer before or after the
263before or after the question status is set to SOLVED.267question status is set to SOLVED.
264268
265The question owner creates a question.269A new question is posed.
266270
267 >>> question = ubuntu.newQuestion(**new_question_args)271 >>> question = ubuntu.newQuestion(**new_question_args)
268272
269The question answer provides an answer that eludes to a decision273The answer provides some useful information to the questioner.
270the question owner must make.
271274
272 >>> login('test@canonical.com')275 >>> login('test@canonical.com')
273 >>> tomorrow_plus_one_hour = tomorrow + timedelta(hours=1)276 >>> tomorrow_plus_one_hour = tomorrow + timedelta(hours=1)
274 >>> alt_answer_message = question.giveAnswer(277 >>> alt_answer_message = question.giveAnswer(
275 ... marilize, "Are you using a pre-G3 Mac? They are very difficult "278 ... marilize,
279 ... "Are you using a pre-G3 Mac? They are very difficult "
276 ... "to install to. You must mess with the hardware to trick "280 ... "to install to. You must mess with the hardware to trick "
277 ... "the core chips to let it install. You may not want to do this.")281 ... "the core chips to let it install. You may not want to do this.")
278282
279The question owner logs in, and explains that he has researched the283The question has researched the problem, and has comes to a solution himself.
280problem, and come to a solution.
281284
282 >>> login('no-priv@canonical.com')285 >>> login('no-priv@canonical.com')
283 >>> self_answer_message = question.giveAnswer(286 >>> self_answer_message = question.giveAnswer(
284 ... no_priv, "I found some instructions on the Wiki on how to "287 ... no_priv,
288 ... "I found some instructions on the Wiki on how to "
285 ... "install BootX to boot the installation CD on OldWorld Mac: "289 ... "install BootX to boot the installation CD on OldWorld Mac: "
286 ... "https://help.ubuntu.com/community/Installation/OldWorldMacs "290 ... "https://help.ubuntu.com/community/Installation/OldWorldMacs "
287 ... "This is complicated and since it's a very old machine, not "291 ... "This is complicated and since it's a very old machine, not "
288 ... "worth the trouble.",292 ... "worth the trouble.",
289 ... datecreated=now_plus_one_hour)293 ... datecreated=now_plus_one_hour)
290294
291In that case, the question owner is considered to have given295The question owner is considered to have given information that the problem is
292information that the problem is solved and the question is moved to296solved and the question is moved to the SOLVED state. The 'answerer'
293the SOLVED state. The 'answerer' attribute will be the question owner,297will be the question owner.
294the 'date_solved' date of the message, but the 'answer' attribute
295will None.
296298
297 >>> print self_answer_message.action.name299 >>> print self_answer_message.action.name
298 CONFIRM300 CONFIRM
@@ -305,20 +307,20 @@
305 No Privileges Person307 No Privileges Person
306 >>> question.date_solved == now_plus_one_hour308 >>> question.date_solved == now_plus_one_hour
307 True309 True
308 >>> question.answer is None310 >>> print question.answer
309 True311 None
310312
311The question owner can still specify which message helped him solved313The question owner can still specify which message helped him solved his
312his problem. The confirmAnswer() method is used when the question314problem. The confirmAnswer() method is used when the question owner chooses
313owner chooses another user's answer as a best answer. The status315another user's answer as a best answer. The status will remain SOLVED. The
314will remain SOLVED. The 'answerer' attribute will be the message316'answerer' will be the message owner, and the 'answer' will be the message.
315owner, and the 'answer' will be the message. The question's317The question's solution date will be the date of the answer message.
316'date_solved' attribute will be the date of the answer message.
317318
318 >>> confirm_message = question.confirmAnswer(319 >>> confirm_message = question.confirmAnswer(
319 ... "Thanks Marilize for your help. I don't think I'll put Ubuntu "320 ... "Thanks Marilize for your help. I don't think I'll put Ubuntu "
320 ... "Ubuntu on my Mac.",321 ... "Ubuntu on my Mac.",
321 ... datecreated=now_plus_one_hour, answer=alt_answer_message)322 ... datecreated=now_plus_one_hour,
323 ... answer=alt_answer_message)
322 >>> print confirm_message.action.name324 >>> print confirm_message.action.name
323 CONFIRM325 CONFIRM
324 >>> print confirm_message.new_status.name326 >>> print confirm_message.new_status.name
@@ -336,17 +338,18 @@
336 True338 True
337339
338340
339== 3) The question expires ==3413) The question expires
342=======================
340343
341Another case is when nobody comes to answer the message, either because344It is also possible that nobody will answer the question, either because the
342the question is too complex or too vague. These questions can be expired345question is too complex or too vague. These questions are expired by using
343by using the expireQuestion() method. (See answer-tracker-expiration.txt346the expireQuestion() method.
344for the documentation of the cron script handling this task.)
345347
346 >>> login('no-priv@canonical.com')348 >>> login('no-priv@canonical.com')
347 >>> question = ubuntu.newQuestion(**new_question_args)349 >>> question = ubuntu.newQuestion(**new_question_args)
348 >>> expire_message = question.expireQuestion(350 >>> expire_message = question.expireQuestion(
349 ... sample_person, "There was no activity on this question for two "351 ... sample_person,
352 ... "There was no activity on this question for two "
350 ... "weeks and this question was expired. If you are still having "353 ... "weeks and this question was expired. If you are still having "
351 ... "this problem you should reopen the question and provide more "354 ... "this problem you should reopen the question and provide more "
352 ... "information about your problem.",355 ... "information about your problem.",
@@ -356,8 +359,8 @@
356 >>> print expire_message.new_status.name359 >>> print expire_message.new_status.name
357 EXPIRED360 EXPIRED
358361
359The question is moved to the EXPIRED state and the 'datelastresponse'362The question is moved to the EXPIRED state and the last response date is
360attribute is updated to the message creation date.363updated to the message creation date.
361364
362 >>> print question.status.name365 >>> print question.status.name
363 EXPIRED366 EXPIRED
@@ -376,8 +379,8 @@
376 >>> print reopen_message.action.name379 >>> print reopen_message.action.name
377 REOPEN380 REOPEN
378381
379The question status is changed back to OPEN and the 'datelastquery'382The question status is changed back to OPEN and the last query date is
380attribute is updated.383updated.
381384
382 >>> print question.status.name385 >>> print question.status.name
383 OPEN386 OPEN
@@ -385,22 +388,22 @@
385 True388 True
386389
387390
388== 4) The question is invalid ==3914) The question is invalid
392==========================
389393
390Another scenario to handle is the case where the user posts a message394In this scenario the user posts an inappropriate message, such as a spam
391that isn't really appropriate for the Answer Tracker like a SPAM
392message or a request for Ubuntu CDs.395message or a request for Ubuntu CDs.
393396
394 >>> spam_question = ubuntu.newQuestion(397 >>> spam_question = ubuntu.newQuestion(
395 ... no_priv, 'CDs', 'Please send 10 Ubuntu Dapper CDs.',398 ... no_priv, 'CDs', 'Please send 10 Ubuntu Dapper CDs.',
396 ... datecreated=now)399 ... datecreated=now)
397400
398The reject() method is used for such purpose. Only an answer contact,401Such questions can be rejected by an answer contact, a product or distribution
399a product or distribution owner, or an administrator can reject a question.402owner, or a Launchpad administrator.
400403
401The canReject() method can be used to test if a user is allowed to404The canReject() method can be used to test if a user is allowed to reject the
402reject the question. It takes as parameter the user who would reject the405question. While neither No Privileges Person nor Marilize are able to reject
403question:406questions, Sample Person and the Ubuntu owner can.
404407
405 >>> spam_question.canReject(no_priv)408 >>> spam_question.canReject(no_priv)
406 False409 False
@@ -413,7 +416,8 @@
413 >>> spam_question.canReject(ubuntu.owner)416 >>> spam_question.canReject(ubuntu.owner)
414 True417 True
415418
416 # Administrator419As a Launchpad administrator, so can Stub.
420
417 >>> spam_question.canReject(stub)421 >>> spam_question.canReject(stub)
418 True422 True
419423
@@ -424,8 +428,7 @@
424 ...428 ...
425 Unauthorized: ...429 Unauthorized: ...
426430
427The reject() method takes a comment explaining the reason behind the431When rejecting a question, a comment explaining the reason is given.
428rejection.
429432
430 >>> login('test@canonical.com')433 >>> login('test@canonical.com')
431 >>> reject_message = spam_question.reject(434 >>> reject_message = spam_question.reject(
@@ -436,8 +439,8 @@
436 >>> print reject_message.new_status.name439 >>> print reject_message.new_status.name
437 INVALID440 INVALID
438441
439After rejection, the question is marked as invalid and the442After rejection, the question is marked as invalid and the last response date
440'datelastresponse' attribute is updated.443is updated.
441444
442 >>> print spam_question.status.name445 >>> print spam_question.status.name
443 INVALID446 INVALID
@@ -445,7 +448,7 @@
445 True448 True
446449
447The rejection message is also considered as answering the message, so the450The rejection message is also considered as answering the message, so the
448date_solved, answerer and answer attributes are also updated.451solution date, answerer, and answer are also updated.
449452
450 >>> spam_question.answer == reject_message453 >>> spam_question.answer == reject_message
451 True454 True
@@ -454,23 +457,27 @@
454 >>> spam_question.date_solved == now_plus_one_hour457 >>> spam_question.date_solved == now_plus_one_hour
455 True458 True
456459
457== Other scenarios ==460
458461Other scenarios
459Many other scenarios are possible and some of those are probably more462===============
460common than the ones we exposed. For example, it is likely that a user463
461will answer directly a question (without asking for other464Many other scenarios are possible and some are likely more common than others.
462information first). Or that the question user won't come back to confirm465For example, it is likely that a user will directly answer a question without
463that an answer solved his problem. Another likely scenario is where466asking for other information first. Sometimes, the original questioner won't
464the question will expire in the NEEDSINFO state when the question owner467come back to confirm that an answer solved his problem.
465doesn't reply to the request for more information. All of these468
466scenarios are covered by this API. It is not necessary to cover all469Another likely scenario is where the question will expire in the NEEDSINFO
467these various possibilities here.470state because the question owner doesn't reply to the request for more
468(The ../interfaces/ftests/test_question_workflow.py functional test471information. All of these scenarios are covered by this API, though it is not
469exercices all the various possible transitions.)472necessary to cover all these various possibilities here. (The
470473../tests/test_question_workflow.py functional test exercises all the various
471== Changing the question status ==474possible transitions.)
472475
473It is not possible to change the status attribute directly:476
477Changing the question status
478============================
479
480It is not possible to change the status attribute directly.
474481
475 >>> login('foo.bar@canonical.com')482 >>> login('foo.bar@canonical.com')
476 >>> question = ubuntu.newQuestion(**new_question_args)483 >>> question = ubuntu.newQuestion(**new_question_args)
@@ -479,10 +486,9 @@
479 ...486 ...
480 ForbiddenAttribute...487 ForbiddenAttribute...
481488
482A user which has launchpad.Admin permission on the question, can set the489A user having launchpad.Admin permission on the question can set the question
483question status to an arbitrary value by using the setStatus() method.490status to an arbitrary value, by giving the new status and a comment
484That method takes as parameters the new status and a comment explaining491explaining the status change.
485the status change.
486492
487 >>> old_datelastquery = question.datelastquery493 >>> old_datelastquery = question.datelastquery
488 >>> login(stub.preferredemail.email)494 >>> login(stub.preferredemail.email)
@@ -490,7 +496,7 @@
490 ... stub, QuestionStatus.INVALID, 'Changed status to INVALID',496 ... stub, QuestionStatus.INVALID, 'Changed status to INVALID',
491 ... datecreated=now_plus_one_hour)497 ... datecreated=now_plus_one_hour)
492498
493The method returns the IQuestionMessage recording the change:499The method returns the IQuestionMessage recording the change.
494500
495 >>> print status_change_message.action.name501 >>> print status_change_message.action.name
496 SETSTATUS502 SETSTATUS
@@ -499,7 +505,7 @@
499 >>> print question.status.name505 >>> print question.status.name
500 INVALID506 INVALID
501507
502The status change updates the datelastresponse attribute:508The status change updates the last response date.
503509
504 >>> question.datelastresponse == now_plus_one_hour510 >>> question.datelastresponse == now_plus_one_hour
505 True511 True
@@ -507,7 +513,7 @@
507 True513 True
508514
509If an answer was present on the question, the status change also clears515If an answer was present on the question, the status change also clears
510the answer and date_solved attributes.516the answer and solution date.
511517
512 >>> msg = question.setStatus(stub, QuestionStatus.OPEN, 'Status change.')518 >>> msg = question.setStatus(stub, QuestionStatus.OPEN, 'Status change.')
513 >>> answer_message = question.giveAnswer(sample_person, 'Install BootX.')519 >>> answer_message = question.giveAnswer(sample_person, 'Install BootX.')
@@ -524,13 +530,13 @@
524 ... stub, QuestionStatus.OPEN, 'Reopen the question',530 ... stub, QuestionStatus.OPEN, 'Reopen the question',
525 ... datecreated=now_plus_one_hour)531 ... datecreated=now_plus_one_hour)
526532
527 >>> question.date_solved is None533 >>> print question.date_solved
528 True534 None
529 >>> question.answer is None535 >>> print question.answer
530 True536 None
531537
532But when the status is changed by a user who doesn't have the538When the status is changed by a user who doesn't have the launchpad.Admin
533launchpad.Admin permission, an Unauthorized error is thrown:539permission, an Unauthorized exception is thrown.
534540
535 >>> login('test@canonical.com')541 >>> login('test@canonical.com')
536 >>> question.setStatus(sample_person, QuestionStatus.EXPIRED, 'Expire.')542 >>> question.setStatus(sample_person, QuestionStatus.EXPIRED, 'Expire.')
@@ -538,10 +544,11 @@
538 ...544 ...
539 Unauthorized...545 Unauthorized...
540546
541== Adding Comments Without Changing the Status ==547
542548Adding Comments Without Changing the Status
543There is an addComment() method that can be use to add a message to the549===========================================
544question without changing its status.550
551Comments can be added to questions without changing the question's status.
545552
546 >>> login('no-priv@canonical.com')553 >>> login('no-priv@canonical.com')
547 >>> old_status = question.status554 >>> old_status = question.status
@@ -556,8 +563,7 @@
556 >>> comment.new_status == old_status563 >>> comment.new_status == old_status
557 True564 True
558565
559This method does not update the datelastresponse and datelastquery566This method does not update the last response date or last query date.
560attributes.
561567
562 >>> question.datelastresponse == old_datelastresponse568 >>> question.datelastresponse == old_datelastresponse
563 True569 True
@@ -565,19 +571,20 @@
565 True571 True
566572
567573
568== Setting the question assignee ==574Setting the question assignee
575=============================
569576
570Users with launchpad.Moderator privileges, which are answer contacts,577Users with launchpad.Moderator privileges, which are answer contacts,
571question target owners, and admins, can assign someone to answer a question.578question target owners, and admins, can assign someone to answer a question.
572579
573Sample Person is an answer contact for ubuntu. He can set the assignee.580Sample Person is an answer contact for ubuntu, so he can set the assignee.
574581
575 >>> login('test@canonical.com')582 >>> login('test@canonical.com')
576 >>> question.assignee = stub583 >>> question.assignee = stub
577 >>> print question.assignee.displayname584 >>> print question.assignee.displayname
578 Stuart Bishop585 Stuart Bishop
579586
580Users without launchpad.Moderator privileges cannot set the assignee587Users without launchpad.Moderator privileges cannot set the assignee.
581588
582 >>> login('no-priv@canonical.com')589 >>> login('no-priv@canonical.com')
583 >>> question.assignee = sample_person590 >>> question.assignee = sample_person
@@ -586,16 +593,18 @@
586 Unauthorized: (<Question ...>, 'assignee', 'launchpad.Moderate')593 Unauthorized: (<Question ...>, 'assignee', 'launchpad.Moderate')
587594
588595
589== Events ==596Events
597======
590598
591Each of the workflow methods will trigger a ObjectCreatedEvent for599Each of the workflow methods will trigger a ObjectCreatedEvent for
592the message they create and a ObjectModifiedEvent for the question.600the message they create and a ObjectModifiedEvent for the question.
593601
594 # Register an event listener that will print event it receives.602 # Register an event listener that will print events it receives.
595 >>> from lazr.lifecycle.interfaces import (603 >>> from lazr.lifecycle.interfaces import (
596 ... IObjectCreatedEvent, IObjectModifiedEvent)604 ... IObjectCreatedEvent, IObjectModifiedEvent)
597 >>> from canonical.launchpad.interfaces import IQuestion605 >>> from lp.answers.interfaces.question import IQuestion
598 >>> from canonical.launchpad.ftests.event import TestEventListener606 >>> from canonical.lazr.testing.event import TestEventListener
607
599 >>> def print_event(object, event):608 >>> def print_event(object, event):
600 ... print "Received %s on %s" % (609 ... print "Received %s on %s" % (
601 ... event.__class__.__name__.split('.')[-1],610 ... event.__class__.__name__.split('.')[-1],
@@ -605,14 +614,15 @@
605 >>> question_event_listener = TestEventListener(614 >>> question_event_listener = TestEventListener(
606 ... IQuestion, IObjectModifiedEvent, print_event)615 ... IQuestion, IObjectModifiedEvent, print_event)
607616
608Changing the status triggers the event:617Changing the status triggers the event.
609618
610 >>> login(stub.preferredemail.email)619 >>> login(stub.preferredemail.email)
611 >>> msg = question.setStatus(stub, QuestionStatus.EXPIRED, 'Status change.')620 >>> msg = question.setStatus(
621 ... stub, QuestionStatus.EXPIRED, 'Status change.')
612 Received ObjectCreatedEvent on QuestionMessage622 Received ObjectCreatedEvent on QuestionMessage
613 Received ObjectModifiedEvent on Question623 Received ObjectModifiedEvent on Question
614624
615Example of a workflow method that triggers the events:625Rejecting the question triggers the events.
616626
617 >>> msg = question.reject(stub, 'Close this question.')627 >>> msg = question.reject(stub, 'Close this question.')
618 Received ObjectCreatedEvent on QuestionMessage628 Received ObjectCreatedEvent on QuestionMessage
@@ -630,25 +640,28 @@
630 >>> questionmessage_event_listener.unregister()640 >>> questionmessage_event_listener.unregister()
631 >>> question_event_listener.unregister()641 >>> question_event_listener.unregister()
632642
633== Reopenings ==643
644Reopening the question
645======================
634646
635Whenever a question considered answered (in the SOLVED or INVALID state)647Whenever a question considered answered (in the SOLVED or INVALID state)
636is reopened, a QuestionReopening is created.648is reopened, a QuestionReopening is created.
637649
638 # Let's register an event listener to notify us whenever a650 # Register an event listener to notify us whenever a QuestionReopening is
639 # QuestionReopening is created.651 # created.
640 >>> from canonical.launchpad.interfaces import IQuestionReopening652 >>> from lp.answers.interfaces.questionreopening import IQuestionReopening
641 >>> reopening_event_listener = TestEventListener(653 >>> reopening_event_listener = TestEventListener(
642 ... IQuestionReopening, IObjectCreatedEvent, print_event)654 ... IQuestionReopening, IObjectCreatedEvent, print_event)
643655
644The most common use case is when a user confirms a solution, and then656The most common use case is when a user confirms a solution, and then
645comes back to say that it doesn't work in fact.657comes back to say that it doesn't, in fact, work.
646658
647 >>> login('no-priv@canonical.com')659 >>> login('no-priv@canonical.com')
648 >>> question = ubuntu.newQuestion(**new_question_args)660 >>> question = ubuntu.newQuestion(**new_question_args)
649 >>> answer_message = question.giveAnswer(661 >>> answer_message = question.giveAnswer(
650 ... sample_person, "You need some setup on the Mac side. "662 ... sample_person,
651 ... "Follow the instructions at "663 ... "You need some setup on the Mac side. "
664 ... "Follow the instructions at "
652 ... "https://help.ubuntu.com/community/Installation/OldWorldMacs",665 ... "https://help.ubuntu.com/community/Installation/OldWorldMacs",
653 ... datecreated=now_plus_one_hour)666 ... datecreated=now_plus_one_hour)
654 >>> confirm_message = question.confirmAnswer(667 >>> confirm_message = question.confirmAnswer(
@@ -662,23 +675,23 @@
662675
663The reopening record is available through the reopenings attribute.676The reopening record is available through the reopenings attribute.
664677
665 >>> list(question.reopenings)678 >>> reopenings = list(question.reopenings)
666 [<QuestionReopening...>]679 >>> len(reopenings)
667 >>> reopening = question.reopenings[0]680 1
681 >>> reopening = reopenings[0]
668 >>> verifyObject(IQuestionReopening, reopening)682 >>> verifyObject(IQuestionReopening, reopening)
669 True683 True
670684
671The reopening contain the date of the reopening in the datecreated685The reopening contain the date of the reopening, and the person who cause the
672attribute and the person who made the reopening in the reopener686reopening to happen.
673attribute.
674687
675 >>> reopening.datecreated == now_plus_three_hours688 >>> reopening.datecreated == now_plus_three_hours
676 True689 True
677 >>> print reopening.reopener.displayname690 >>> print reopening.reopener.displayname
678 No Privileges Person691 No Privileges Person
679692
680It contains the question prior answerer, datecreated, as well as the693It also contains the question's prior answerer, the date created, and the
681prior status in the priorstate attribute:694prior status of the question.
682695
683 >>> print reopening.answerer.displayname696 >>> print reopening.answerer.displayname
684 Sample Person697 Sample Person
@@ -687,8 +700,8 @@
687 >>> print reopening.priorstate.name700 >>> print reopening.priorstate.name
688 SOLVED701 SOLVED
689702
690Another example of a reopening, would be when the question status is set703A reopening also occurs when the question status is set back to OPEN after
691back to OPEN after having been rejected.704having been rejected.
692705
693 >>> login('test@canonical.com')706 >>> login('test@canonical.com')
694 >>> question = ubuntu.newQuestion(**new_question_args)707 >>> question = ubuntu.newQuestion(**new_question_args)
@@ -698,7 +711,8 @@
698711
699 >>> login(stub.preferredemail.email)712 >>> login(stub.preferredemail.email)
700 >>> status_change_message = question.setStatus(713 >>> status_change_message = question.setStatus(
701 ... stub, QuestionStatus.OPEN, 'Disregard previous rejection. '714 ... stub, QuestionStatus.OPEN,
715 ... 'Disregard previous rejection. '
702 ... 'Sample Person was having a bad day.',716 ... 'Sample Person was having a bad day.',
703 ... datecreated=now_plus_two_hours)717 ... datecreated=now_plus_two_hours)
704 Received ObjectCreatedEvent on QuestionReopening718 Received ObjectCreatedEvent on QuestionReopening
@@ -718,7 +732,9 @@
718 # Cleanup732 # Cleanup
719 >>> reopening_event_listener.unregister()733 >>> reopening_event_listener.unregister()
720734
721== Using an IMessage as Explanation ==735
736Using an IMessage as an explanation
737===================================
722738
723In all the workflow methods, it is possible to pass an IMessage instead of739In all the workflow methods, it is possible to pass an IMessage instead of
724a string.740a string.
@@ -729,7 +745,7 @@
729 >>> question = ubuntu.newQuestion(**new_question_args)745 >>> question = ubuntu.newQuestion(**new_question_args)
730 >>> reject_message = messageset.fromText(746 >>> reject_message = messageset.fromText(
731 ... 'Reject', 'Because I feel like it.', sample_person)747 ... 'Reject', 'Because I feel like it.', sample_person)
732 >>> question_message = question.reject(sample_person,reject_message)748 >>> question_message = question.reject(sample_person, reject_message)
733 >>> print question_message.subject749 >>> print question_message.subject
734 Reject750 Reject
735 >>> print question_message.text_contents751 >>> print question_message.text_contents
@@ -737,7 +753,7 @@
737 >>> question_message.rfc822msgid == reject_message.rfc822msgid753 >>> question_message.rfc822msgid == reject_message.rfc822msgid
738 True754 True
739755
740The IMessage owner must be the same than the person passed to the workflow756The IMessage owner must be the same as the person passed to the workflow
741method.757method.
742758
743 >>> login(stub.preferredemail.email)759 >>> login(stub.preferredemail.email)
744760
=== modified file 'lib/lp/answers/interfaces/questionreopening.py'
--- lib/lp/answers/interfaces/questionreopening.py 2009-06-24 23:10:46 +0000
+++ lib/lp/answers/interfaces/questionreopening.py 2010-02-10 15:24:19 +0000
@@ -20,6 +20,7 @@
20from lp.answers.interfaces.question import IQuestion20from lp.answers.interfaces.question import IQuestion
21from lp.answers.interfaces.questionenums import QuestionStatus21from lp.answers.interfaces.questionenums import QuestionStatus
2222
23
23class IQuestionReopening(Interface):24class IQuestionReopening(Interface):
24 """A record of the re-opening of a question.25 """A record of the re-opening of a question.
2526

Subscribers

People subscribed via source and target branches

to status/vote changes: