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