Merge lp:~sinzui/launchpad/anwers-api-0 into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Merged at revision: 13009
Proposed branch: lp:~sinzui/launchpad/anwers-api-0
Merge into: lp:launchpad
Diff against target: 1160 lines (+362/-88)
28 files modified
lib/lp/answers/browser/question.py (+1/-1)
lib/lp/answers/browser/questiontarget.py (+4/-4)
lib/lp/answers/browser/tests/test_menus.py (+1/-1)
lib/lp/answers/doc/expiration.txt (+1/-1)
lib/lp/answers/doc/faq.txt (+1/-1)
lib/lp/answers/doc/faqtarget.txt (+1/-1)
lib/lp/answers/doc/notifications.txt (+2/-2)
lib/lp/answers/doc/person.txt (+4/-3)
lib/lp/answers/doc/question.txt (+3/-3)
lib/lp/answers/doc/questiontarget-sourcepackage.txt (+7/-7)
lib/lp/answers/doc/questiontarget.txt (+10/-10)
lib/lp/answers/doc/workflow.txt (+1/-1)
lib/lp/answers/errors.py (+20/-0)
lib/lp/answers/interfaces/questiontarget.py (+73/-13)
lib/lp/answers/interfaces/webservice.py (+2/-0)
lib/lp/answers/model/question.py (+27/-8)
lib/lp/answers/stories/distribution-package-answer-contact.txt (+1/-1)
lib/lp/answers/stories/webservice.txt (+113/-0)
lib/lp/answers/tests/test_faq.py (+1/-1)
lib/lp/answers/tests/test_faqtarget.py (+1/-1)
lib/lp/answers/tests/test_question_workflow.py (+4/-4)
lib/lp/answers/tests/test_questionjob.py (+2/-2)
lib/lp/answers/tests/test_questiontarget.py (+43/-2)
lib/lp/coop/answersbugs/tests/test_doc.py (+2/-2)
lib/lp/registry/configure.zcml (+4/-0)
lib/lp/registry/interfaces/person.py (+16/-8)
lib/lp/registry/model/sourcepackage.py (+5/-3)
lib/lp/testing/factory.py (+12/-8)
To merge this branch: bzr merge lp:~sinzui/launchpad/anwers-api-0
Reviewer Review Type Date Requested Status
Benji York (community) code Approve
Review via email: mp+60238@code.launchpad.net

Description of the change

Export answer contact management over the API.

    Launchpad bug: https://bugs.launchpad.net/bugs/289926
    Pre-implementation: jcsackett

This is an incremental branch, probably 1 of 3 to allow users and team
to work with Lp Answers over the API.

--------------------------------------------------------------------

RULES

    * Export the needed methods on IQuestionTarget and IQuestion to
      allow be answer contacts for a project.

QA

    * Verify you can add yourself as an answer contact to Launchpad.
    from launchpadlib.launchpad import Launchpad
    lp = Launchpad.login_anonymously(
        'test', 'https://api.qastaging.launchpad.net/', version='devel')
    project = lp.projects['launchpad']
    user = lp.people['sinzui']
    language = lp.languages['fr']
    user.addLanguage(language)
    print project.canUserAlterAnswerContact(user, user)
    # expect True
    print project.addAnswer(user)
    # expect True
    for lang in project.getSupportedLanguages():
        print lang.code
    for contact in project.getAnswerContactsForLanguage(language):
        print contact.name
    # Expect sinzui

LINT

    lib/lp/answers/browser/question.py
    lib/lp/answers/browser/questiontarget.py
    lib/lp/answers/interfaces/questiontarget.py
    lib/lp/answers/interfaces/webservice.py
    lib/lp/answers/model/question.py
    lib/lp/answers/stories/webservice.txt
    lib/lp/answers/tests/test_questiontarget.py
    lib/lp/registry/configure.zcml
    lib/lp/registry/interfaces/person.py
    lib/lp/testing/factory.py

^ There are lint issues in some of these files and I can fix them after
the review.

TEST

    ./bin/test -vv -t answers.*webservice.txt -t test_questiontarget

IMPLEMENTATION

Updated the factory to allow me to use a more realistic user as the owner
of a question.
    lib/lp/testing/factory.py

Added canUserAlterAnswerContact following the example of questions and
StructuralSubscriptions. The view implicitly enforces this rule by building
a vocabulary of the user and this administered teams. I changed
addAnswerContact and removeAnswerContact to require a subscribed_by
argument, then updated the only callsites in browser/questiontarget.
I expect mechanical fallout from this last change in doctests. I will
fix these after the review.
    lib/lp/answers/browser/questiontarget.py
    lib/lp/answers/interfaces/questiontarget.py
    lib/lp/answers/model/question.py
    lib/lp/answers/tests/test_questiontarget.py
    lib/lp/registry/configure.zcml

Exported the methods to document how to make myself and my teams answer
contacts. Note that the doctest setup more than is used. I have another
branch in progress that uses that setup to document how to search for
questions.
    lib/lp/answers/interfaces/webservice.py
    lib/lp/answers/interfaces/questiontarget.py
    lib/lp/answers/stories/webservice.txt

@operation_returns_collection_of errors if the method it decorates returns a
Set. I changed the one call site that required a set to us a list.
    lib/lp/answers/model/question.py
    lib/lp/answers/browser/question.py

Exported addLanguage and removeLanguage so that answer contacts can control
the Languages they support in Answers.
    lib/lp/registry/interfaces/person.py

To post a comment you must log in.
Revision history for this message
Benji York (benji) wrote :

This branch looks good. A few thoughts:

In addAnswerContact you might want to use lazr.restful.error.expose to
make the ValueError return a 400 instead of 500, because (as far as I
can tell), the error is on the client's part and not the server.

The five attributes/methods newQuestion, createQuestionFromBug,
canUserAlterAnswerContact, addAnswerContact, removeAnswerContact are
used together several times in lib/lp/registry/configure.zcml. Perhaps
they should be made into an interface so they can be treated as a unit.

Although I'm not a fan of it, the two existing parameters of
makeQuestion in lib/lp/testing/factory.py were documented using the
JavaDoc style so you may want to do the same for the new parameter you
added.

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

On Mon, 2011-05-09 at 14:47 +0000, Benji York wrote:
> In addAnswerContact you might want to use lazr.restful.error.expose to
> make the ValueError return a 400 instead of 500, because (as far as I
> can tell), the error is on the client's part and not the server.

Thank you for calling my attention to this. I was under the mistaken
impression that Leonard had lands a change to Launchpad that ensures
ValueErrors raised on the API layer were always 400 errors. I do not see
code that does that So I created a new error that will raise a 400.

> The five attributes/methods newQuestion, createQuestionFromBug,
> canUserAlterAnswerContact, addAnswerContact, removeAnswerContact are
> used together several times in lib/lp/registry/configure.zcml.
> Perhaps
> they should be made into an interface so they can be treated as a
> unit.

I will decline this for now because it requires the rework of several
interfaces and models. This I will do this as time permits.

> Although I'm not a fan of it, the two existing parameters of
> makeQuestion in lib/lp/testing/factory.py were documented using the
> JavaDoc style so you may want to do the same for the new parameter you
> added.

Done.

--
__Curtis C. Hovey_________
http://launchpad.net/

Revision history for this message
Benji York (benji) wrote :

On Mon, May 9, 2011 at 12:48 PM, Curtis Hovey
<email address hidden> wrote:
> On Mon, 2011-05-09 at 14:47 +0000, Benji York wrote:
>> In addAnswerContact you might want to use lazr.restful.error.expose to
>> make the ValueError return a 400 instead of 500, because (as far as I
>> can tell), the error is on the client's part and not the server.
>
> Thank you for calling my attention to this. I was under the mistaken
> impression that Leonard had lands a change to Launchpad that ensures
> ValueErrors raised on the API layer were always 400 errors. I do not see
> code that does that So I created a new error that will raise a 400.

If you want to stick with ValueError, all you have to do is wrap it in a
call to lazr.restful.error.expose, like so:

    raise expose(ValueError('something bad happened'))

--
Benji York

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/answers/browser/question.py'
2--- lib/lp/answers/browser/question.py 2011-05-06 13:18:12 +0000
3+++ lib/lp/answers/browser/question.py 2011-05-09 18:59:17 +0000
4@@ -437,7 +437,7 @@
5 question_target = IQuestionTarget(self.view.question_target)
6 supported_languages = question_target.getSupportedLanguages()
7 else:
8- supported_languages = set([english])
9+ supported_languages = [english]
10
11 terms = []
12 for lang in languages:
13
14=== modified file 'lib/lp/answers/browser/questiontarget.py'
15--- lib/lp/answers/browser/questiontarget.py 2011-05-03 14:14:49 +0000
16+++ lib/lp/answers/browser/questiontarget.py 2011-05-09 18:59:17 +0000
17@@ -738,12 +738,12 @@
18 replacements = {'context': self.context.displayname}
19 if want_to_be_answer_contact:
20 self._updatePreferredLanguages(self.user)
21- if self.context.addAnswerContact(self.user):
22+ if self.context.addAnswerContact(self.user, self.user):
23 response.addNotification(
24 _('You have been added as an answer contact for '
25 '$context.', mapping=replacements))
26 else:
27- if self.context.removeAnswerContact(self.user):
28+ if self.context.removeAnswerContact(self.user, self.user):
29 response.addNotification(
30 _('You have been removed as an answer contact for '
31 '$context.', mapping=replacements))
32@@ -752,12 +752,12 @@
33 replacements['teamname'] = team.displayname
34 if team in answer_contact_teams:
35 self._updatePreferredLanguages(team)
36- if self.context.addAnswerContact(team):
37+ if self.context.addAnswerContact(team, self.user):
38 response.addNotification(
39 _('$teamname has been added as an answer contact '
40 'for $context.', mapping=replacements))
41 else:
42- if self.context.removeAnswerContact(team):
43+ if self.context.removeAnswerContact(team, self.user):
44 response.addNotification(
45 _('$teamname has been removed as an answer contact '
46 'for $context.', mapping=replacements))
47
48=== modified file 'lib/lp/answers/browser/tests/test_menus.py'
49--- lib/lp/answers/browser/tests/test_menus.py 2010-08-20 20:31:18 +0000
50+++ lib/lp/answers/browser/tests/test_menus.py 2011-05-09 18:59:17 +0000
51@@ -46,7 +46,7 @@
52 # A question with a linked FAQ has an 'edit' icon.
53 self.person.addLanguage(getUtility(ILanguageSet)['en'])
54 target = self.question.target
55- target.addAnswerContact(self.person)
56+ target.addAnswerContact(self.person, self.person)
57 faq = self.factory.makeFAQ(target=target)
58 self.question.linkFAQ(self.person, faq, 'message')
59 link = menu.linkfaq()
60
61=== modified file 'lib/lp/answers/doc/expiration.txt'
62--- lib/lp/answers/doc/expiration.txt 2011-04-26 15:44:26 +0000
63+++ lib/lp/answers/doc/expiration.txt 2011-05-09 18:59:17 +0000
64@@ -82,7 +82,7 @@
65 >>> old_open_question.subscribe(admin_team)
66 <QuestionSubscription...>
67 >>> salgado = getUtility(IPersonSet).getByName('salgado')
68- >>> old_open_question.target.addAnswerContact(salgado)
69+ >>> old_open_question.target.addAnswerContact(salgado, salgado)
70 True
71
72 # Link it to a FAQ item for the same reason. We are setting the
73
74=== modified file 'lib/lp/answers/doc/faq.txt'
75--- lib/lp/answers/doc/faq.txt 2010-12-06 22:59:15 +0000
76+++ lib/lp/answers/doc/faq.txt 2011-05-09 18:59:17 +0000
77@@ -199,7 +199,7 @@
78 >>> from lp.services.worlddata.interfaces.language import ILanguageSet
79 >>> no_priv = getUtility(ILaunchBag).user
80 >>> no_priv.addLanguage(getUtility(ILanguageSet)['en'])
81- >>> firefox.addAnswerContact(no_priv)
82+ >>> firefox.addAnswerContact(no_priv, no_priv)
83 True
84
85 >>> from canonical.launchpad.webapp.authorization import clear_cache
86
87=== modified file 'lib/lp/answers/doc/faqtarget.txt'
88--- lib/lp/answers/doc/faqtarget.txt 2010-10-18 22:24:59 +0000
89+++ lib/lp/answers/doc/faqtarget.txt 2011-05-09 18:59:17 +0000
90@@ -59,7 +59,7 @@
91
92 >>> from lp.services.worlddata.interfaces.language import ILanguageSet
93 >>> no_priv.addLanguage(getUtility(ILanguageSet)['en'])
94- >>> target.addAnswerContact(no_priv)
95+ >>> target.addAnswerContact(no_priv, no_priv)
96 True
97
98 >>> clear_cache() # Clear authorization cache for check_permission.
99
100=== modified file 'lib/lp/answers/doc/notifications.txt'
101--- lib/lp/answers/doc/notifications.txt 2011-04-30 01:40:37 +0000
102+++ lib/lp/answers/doc/notifications.txt 2011-05-09 18:59:17 +0000
103@@ -68,7 +68,7 @@
104 >>> from lp.services.worlddata.interfaces.language import ILanguageSet
105 >>> ubuntu_team = getUtility(IPersonSet).getByName('ubuntu-team')
106 >>> ubuntu_team.addLanguage(getUtility(ILanguageSet)['en'])
107- >>> ubuntu.addAnswerContact(ubuntu_team)
108+ >>> ubuntu.addAnswerContact(ubuntu_team, ubuntu_team.teamowner)
109 True
110
111 And assign this question to Foo Bar, so that he will also receive
112@@ -699,7 +699,7 @@
113 # supported in Ubuntu.
114
115 >>> salgado = getUtility(IPersonSet).getByName('salgado')
116- >>> ubuntu.addAnswerContact(salgado)
117+ >>> ubuntu.addAnswerContact(salgado, salgado)
118 True
119
120 >>> sorted([lang.code for lang in ubuntu.getSupportedLanguages()])
121
122=== modified file 'lib/lp/answers/doc/person.txt'
123--- lib/lp/answers/doc/person.txt 2011-04-26 16:39:33 +0000
124+++ lib/lp/answers/doc/person.txt 2011-05-09 18:59:17 +0000
125@@ -283,7 +283,7 @@
126
127 # Answer contacts must speak a language
128 >>> no_priv_raw.addLanguage(english)
129- >>> firefox.addAnswerContact(no_priv_raw)
130+ >>> firefox.addAnswerContact(no_priv_raw, no_priv_raw)
131 True
132
133 >>> for target in no_priv.getDirectAnswerQuestionTargets():
134@@ -306,7 +306,7 @@
135 >>> from lp.registry.interfaces.distribution import IDistributionSet
136 >>> ubuntu = getUtility(IDistributionSet).getByName("ubuntu")
137 >>> landscape_team.addLanguage(english)
138- >>> ubuntu.addAnswerContact(landscape_team)
139+ >>> ubuntu.addAnswerContact(landscape_team, landscape_team.teamowner)
140 True
141
142 >>> print ', '.join(
143@@ -334,7 +334,8 @@
144 True
145 >>> evolution_package = ubuntu.getSourcePackage('evolution')
146 >>> translator_team.addLanguage(english)
147- >>> evolution_package.addAnswerContact(translator_team)
148+ >>> evolution_package.addAnswerContact(
149+ ... translator_team, translator_team.teamowner)
150 True
151 >>> print ', '.join(
152 ... sorted(target.name
153
154=== modified file 'lib/lp/answers/doc/question.txt'
155--- lib/lp/answers/doc/question.txt 2011-04-25 14:37:52 +0000
156+++ lib/lp/answers/doc/question.txt 2011-05-09 18:59:17 +0000
157@@ -242,7 +242,7 @@
158 >>> from lp.services.worlddata.interfaces.language import ILanguageSet
159 >>> english = getUtility(ILanguageSet)['en']
160 >>> no_priv.addLanguage(english)
161- >>> firefox.addAnswerContact(no_priv)
162+ >>> firefox.addAnswerContact(no_priv, no_priv)
163 True
164
165 >>> from lp.services.propertycache import get_property_cache
166@@ -267,9 +267,9 @@
167 []
168 >>> ubuntu_team = getUtility(IPersonSet).getByName('ubuntu-team')
169 >>> ubuntu_team.addLanguage(english)
170- >>> ubuntu.addAnswerContact(ubuntu_team)
171+ >>> ubuntu.addAnswerContact(ubuntu_team, ubuntu_team.teamowner)
172 True
173- >>> evolution_in_ubuntu.addAnswerContact(no_priv)
174+ >>> evolution_in_ubuntu.addAnswerContact(no_priv, no_priv)
175 True
176 >>> package_question = evolution_in_ubuntu.newQuestion(
177 ... sample_person, 'Upgrading to Evolution 1.4 breaks plug-ins',
178
179=== modified file 'lib/lp/answers/doc/questiontarget-sourcepackage.txt'
180--- lib/lp/answers/doc/questiontarget-sourcepackage.txt 2011-04-21 21:22:16 +0000
181+++ lib/lp/answers/doc/questiontarget-sourcepackage.txt 2011-05-09 18:59:17 +0000
182@@ -60,7 +60,7 @@
183 >>> list(evolution.answer_contacts)
184 []
185
186- >>> ubuntu.addAnswerContact(sample_person)
187+ >>> ubuntu.addAnswerContact(sample_person, sample_person)
188 True
189
190 >>> [person.name for person in evolution.answer_contacts]
191@@ -76,7 +76,7 @@
192 A user can still subscribe as support contact for specific sourcepackage
193 even if he's already a support contact for the distribution.
194
195- >>> evolution.addAnswerContact(sample_person)
196+ >>> evolution.addAnswerContact(sample_person, sample_person)
197 True
198
199 >>> [person.name for person in evolution.direct_answer_contacts]
200@@ -91,7 +91,7 @@
201 from the sourcepackage support contact list, not from the ubuntu
202 distribution list:
203
204- >>> evolution.removeAnswerContact(sample_person)
205+ >>> evolution.removeAnswerContact(sample_person, sample_person)
206 True
207
208 >>> [person.name for person in evolution.direct_answer_contacts]
209@@ -100,7 +100,7 @@
210 >>> [person.name for person in evolution.answer_contacts]
211 [u'name16']
212
213- >>> evolution.removeAnswerContact(sample_person)
214+ >>> evolution.removeAnswerContact(sample_person, sample_person)
215 False
216
217 >>> [person.name for person in evolution.answer_contacts]
218@@ -114,7 +114,7 @@
219 >>> [person.name for person in evolution.answer_contacts]
220 [u'name16']
221
222- >>> evolution.addAnswerContact(sample_person)
223+ >>> evolution.addAnswerContact(sample_person, sample_person)
224 True
225
226 >>> [person.name for person in evolution.direct_answer_contacts]
227@@ -123,13 +123,13 @@
228 >>> [person.name for person in evolution.answer_contacts]
229 [u'name16']
230
231- >>> evolution.removeAnswerContact(sample_person)
232+ >>> evolution.removeAnswerContact(sample_person, sample_person)
233 True
234
235 >>> [person.name for person in evolution.answer_contacts]
236 [u'name16']
237
238- >>> evolution.removeAnswerContact(sample_person)
239+ >>> evolution.removeAnswerContact(sample_person, sample_person)
240 False
241
242 >>> [person.name for person in evolution.direct_answer_contacts]
243
244=== modified file 'lib/lp/answers/doc/questiontarget.txt'
245--- lib/lp/answers/doc/questiontarget.txt 2011-04-26 15:44:26 +0000
246+++ lib/lp/answers/doc/questiontarget.txt 2011-05-09 18:59:17 +0000
247@@ -385,7 +385,7 @@
248 is only available to registered users.
249
250 >>> name18 = getUtility(IPersonSet).getByName('name18')
251- >>> target.addAnswerContact(name18)
252+ >>> target.addAnswerContact(name18, name18)
253 Traceback (most recent call last):
254 ...
255 Unauthorized...
256@@ -394,7 +394,7 @@
257 was already on the list.
258
259 >>> login('no-priv@canonical.com')
260- >>> target.addAnswerContact(name18)
261+ >>> target.addAnswerContact(name18, name18)
262 True
263 >>> people = [p.name for p in target.answer_contacts]
264 >>> len(people)
265@@ -406,7 +406,7 @@
266 1
267 >>> print people[0]
268 name18
269- >>> target.addAnswerContact(name18)
270+ >>> target.addAnswerContact(name18, name18)
271 False
272
273 An answer contact must have at least one language among his preferred
274@@ -415,28 +415,28 @@
275 >>> sample_person = getUtility(IPersonSet).getByName('name12')
276 >>> len(sample_person.languages)
277 0
278- >>> target.addAnswerContact(sample_person)
279+ >>> target.addAnswerContact(sample_person, sample_person)
280 Traceback (most recent call last):
281 ...
282- AssertionError: An Answer Contact must speak a language...
283+ AddAnswerContactError: An answer contact must speak a language...
284
285 Answer contacts can be removed by using the removeAnswerContact() method.
286 Like its counterpart, it returns True when the answer contact was removed and
287 False when the person wasn't on the answer contact list.
288
289- >>> target.removeAnswerContact(name18)
290+ >>> target.removeAnswerContact(name18, name18)
291 True
292 >>> list(target.answer_contacts)
293 []
294 >>> list(target.direct_answer_contacts)
295 []
296- >>> target.removeAnswerContact(name18)
297+ >>> target.removeAnswerContact(name18, name18)
298 False
299
300 Only registered users can remove an answer contact.
301
302 >>> login(ANONYMOUS)
303- >>> target.removeAnswerContact(name18)
304+ >>> target.removeAnswerContact(name18, name18)
305 Traceback (most recent call last):
306 ...
307 Unauthorized...
308@@ -465,7 +465,7 @@
309 ca
310 en
311 es
312- >>> target.addAnswerContact(carlos)
313+ >>> target.addAnswerContact(carlos, carlos)
314 True
315
316 While daf has en_GB as one of his preferred languages...
317@@ -477,7 +477,7 @@
318 en_GB
319 ja
320 cy
321- >>> target.addAnswerContact(daf)
322+ >>> target.addAnswerContact(daf, daf)
323 True
324
325 ...en_GB is not included in the target's supported languages, because all
326
327=== modified file 'lib/lp/answers/doc/workflow.txt'
328--- lib/lp/answers/doc/workflow.txt 2011-04-26 15:44:26 +0000
329+++ lib/lp/answers/doc/workflow.txt 2011-05-09 18:59:17 +0000
330@@ -53,7 +53,7 @@
331 >>> ubuntu = getUtility(IDistributionSet)['ubuntu']
332 >>> english = getUtility(ILanguageSet)['en']
333 >>> sample_person.addLanguage(english)
334- >>> ubuntu.addAnswerContact(sample_person)
335+ >>> ubuntu.addAnswerContact(sample_person, sample_person)
336 True
337
338 # Sanity check: the admin isn't in the team owning the distribution.
339
340=== added file 'lib/lp/answers/errors.py'
341--- lib/lp/answers/errors.py 1970-01-01 00:00:00 +0000
342+++ lib/lp/answers/errors.py 2011-05-09 18:59:17 +0000
343@@ -0,0 +1,20 @@
344+# Copyright 2011 Canonical Ltd. This software is licensed under the
345+# GNU Affero General Public License version 3 (see the file LICENSE).
346+
347+__metaclass__ = type
348+__all__ = [
349+ 'AddAnswerContactError',
350+ ]
351+
352+import httplib
353+
354+from lazr.restful.declarations import webservice_error
355+
356+
357+class AddAnswerContactError(ValueError):
358+ """The person cannot be an answer contact.
359+
360+ An answer contacts must be a valid user or team that has a preferred
361+ language.
362+ """
363+ webservice_error(httplib.BAD_REQUEST)
364
365=== modified file 'lib/lp/answers/interfaces/questiontarget.py'
366--- lib/lp/answers/interfaces/questiontarget.py 2011-04-26 16:22:11 +0000
367+++ lib/lp/answers/interfaces/questiontarget.py 2011-05-09 18:59:17 +0000
368@@ -16,11 +16,24 @@
369 from zope.interface import Interface
370 from zope.schema import (
371 Choice,
372+ Int,
373 List,
374 Set,
375 TextLine,
376 )
377
378+from lazr.restful.declarations import (
379+ call_with,
380+ export_as_webservice_entry,
381+ export_read_operation,
382+ export_write_operation,
383+ operation_for_version,
384+ operation_parameters,
385+ operation_returns_collection_of,
386+ REQUEST_USER,
387+ )
388+from lazr.restful.fields import Reference
389+
390 from canonical.launchpad import _
391 from lp.answers.interfaces.questioncollection import (
392 ISearchableByQuestionOwner,
393@@ -30,12 +43,16 @@
394 QuestionStatus,
395 QUESTION_STATUS_DEFAULT_SEARCH,
396 )
397+from lp.registry.interfaces.person import IPerson
398 from lp.services.fields import PublicPersonChoice
399+from lp.services.worlddata.interfaces.language import ILanguage
400
401
402 class IQuestionTarget(ISearchableByQuestionOwner):
403 """An object that can have a new question asked about it."""
404
405+ export_as_webservice_entry(as_of='devel')
406+
407 def newQuestion(owner, title, description, language=None,
408 datecreated=None):
409 """Create a new question.
410@@ -69,6 +86,10 @@
411 :bug: An IBug.
412 """
413
414+ @operation_parameters(
415+ question_id=Int(title=_('Question Number'), required=True))
416+ @export_read_operation()
417+ @operation_for_version('devel')
418 def getQuestion(question_id):
419 """Return the question by its id, if it is applicable to this target.
420
421@@ -87,25 +108,62 @@
422 :title: A phrase
423 """
424
425- def addAnswerContact(person):
426+ @operation_parameters(
427+ person=PublicPersonChoice(
428+ title=_('The user or an administered team'), required=True,
429+ vocabulary='ValidPersonOrTeam'))
430+ @call_with(subscribed_by=REQUEST_USER)
431+ @export_read_operation()
432+ @operation_for_version('devel')
433+ def canUserAlterAnswerContact(person, subscribed_by):
434+ """Can the user add or remove the answer contact.
435+
436+ Users can add or remove themselves or one of the teams they
437+ administered.
438+
439+ :param person: The `IPerson` that is or will be an answer contact.
440+ :param subscribed_by: The `IPerson` making the change.
441+ """
442+
443+ @operation_parameters(
444+ person=PublicPersonChoice(
445+ title=_('The user of an administered team'), required=True,
446+ vocabulary='ValidPersonOrTeam'))
447+ @call_with(subscribed_by=REQUEST_USER)
448+ @export_write_operation()
449+ @operation_for_version('devel')
450+ def addAnswerContact(person, subscribed_by):
451 """Add a new answer contact.
452
453- :person: An IPerson.
454-
455- Returns True if the person was added, False if the person already was
456- an answer contact. A person must have at least one preferred
457- language to be an answer contact.
458+ :param person: An `IPerson`.
459+ :param subscribed_by: The user making the change.
460+ :return: True if the person was added, False if the person already is
461+ an answer contact.
462+ :raises ValueError: When the person or team does no have a preferred
463+ language.
464 """
465
466- def removeAnswerContact(person):
467+ @operation_parameters(
468+ person=PublicPersonChoice(
469+ title=_('The user of an administered team'), required=True,
470+ vocabulary='ValidPersonOrTeam'))
471+ @call_with(subscribed_by=REQUEST_USER)
472+ @export_write_operation()
473+ @operation_for_version('devel')
474+ def removeAnswerContact(person, subscribed_by):
475 """Remove an answer contact.
476
477- :person: An IPerson.
478-
479- Returns True if the person was removed, False if the person wasn't an
480- answer contact.
481+ :param person: An `IPerson`.
482+ :param subscribed_by: The user making the change.
483+ :return: True if the person was removed, False if the person wasn't an
484+ answer contact.
485 """
486
487+ @operation_parameters(
488+ language=Reference(ILanguage))
489+ @operation_returns_collection_of(IPerson)
490+ @export_read_operation()
491+ @operation_for_version('devel')
492 def getAnswerContactsForLanguage(language):
493 """Return the list of Persons that provide support for a language.
494
495@@ -124,9 +182,11 @@
496 for the QuestionTarget.
497 """
498
499+ @operation_returns_collection_of(ILanguage)
500+ @export_read_operation()
501+ @operation_for_version('devel')
502 def getSupportedLanguages():
503- """Return the set of languages spoken by at least one of this object's
504- answer contacts.
505+ """Return a list of languages spoken by at the answer contacts.
506
507 An answer contact is considered to speak a given language if that
508 language is listed as one of his preferred languages.
509
510=== modified file 'lib/lp/answers/interfaces/webservice.py'
511--- lib/lp/answers/interfaces/webservice.py 2011-04-14 16:16:30 +0000
512+++ lib/lp/answers/interfaces/webservice.py 2011-05-09 18:59:17 +0000
513@@ -18,6 +18,8 @@
514
515 from lp.answers.interfaces.question import IQuestion
516 from lp.answers.interfaces.questioncollection import IQuestionSet
517+from lp.answers.interfaces.questiontarget import IQuestionTarget
518+
519
520 IQuestionSet.queryTaggedValue(
521 LAZR_WEBSERVICE_EXPORTED)['collection_entry_schema'] = IQuestion
522
523=== modified file 'lib/lp/answers/model/question.py'
524--- lib/lp/answers/model/question.py 2011-04-28 18:40:45 +0000
525+++ lib/lp/answers/model/question.py 2011-05-09 18:59:17 +0000
526@@ -81,6 +81,9 @@
527 QuestionStatus,
528 QUESTION_STATUS_DEFAULT_SEARCH,
529 )
530+from lp.answers.errors import (
531+ AddAnswerContactError,
532+ )
533 from lp.answers.interfaces.questiontarget import IQuestionTarget
534 from lp.answers.model.answercontact import AnswerContact
535 from lp.answers.model.questionmessage import QuestionMessage
536@@ -1319,15 +1322,29 @@
537 person.setLanguagesCache(languages)
538 return sorted(D.keys(), key=operator.attrgetter('displayname'))
539
540- def addAnswerContact(self, person):
541- """See `IQuestionTarget`."""
542+ def canUserAlterAnswerContact(self, person, subscribed_by):
543+ """See `IQuestionTarget`."""
544+ if person is None or subscribed_by is None:
545+ return False
546+ admins = getUtility(ILaunchpadCelebrities).admin
547+ if (person == subscribed_by
548+ or person in subscribed_by.administrated_teams
549+ or subscribed_by.inTeam(admins)):
550+ return True
551+ return False
552+
553+ def addAnswerContact(self, person, subscribed_by):
554+ """See `IQuestionTarget`."""
555+ if not self.canUserAlterAnswerContact(person, subscribed_by):
556+ return False
557 answer_contact = AnswerContact.selectOneBy(
558 person=person, **self.getTargetTypes())
559 if answer_contact is not None:
560 return False
561 # Person must speak a language to be an answer contact.
562- assert len(person.languages) > 0, (
563- "An Answer Contact must speak a language.")
564+ if len(person.languages) == 0:
565+ raise AddAnswerContactError(
566+ "An answer contact must speak a language.")
567 params = dict(product=None, distribution=None, sourcepackagename=None)
568 params.update(self.getTargetTypes())
569 answer_contact = AnswerContact(person=person, **params)
570@@ -1370,8 +1387,8 @@
571 else:
572 constraints.append("""
573 Language.id = %s""" % sqlvalues(language))
574- return set(self._selectPersonFromAnswerContacts(
575- constraints, ['PersonLanguage', 'Language']))
576+ return list((self._selectPersonFromAnswerContacts(
577+ constraints, ['PersonLanguage', 'Language'])))
578
579 def getAnswerContactRecipients(self, language):
580 """See `IQuestionTarget`."""
581@@ -1395,8 +1412,10 @@
582 recipients.add(person, reason, header)
583 return recipients
584
585- def removeAnswerContact(self, person):
586+ def removeAnswerContact(self, person, subscribed_by):
587 """See `IQuestionTarget`."""
588+ if not self.canUserAlterAnswerContact(person, subscribed_by):
589+ return False
590 if person not in self.answer_contacts:
591 return False
592 answer_contact = AnswerContact.selectOneBy(
593@@ -1416,4 +1435,4 @@
594 languages.add(getUtility(ILaunchpadCelebrities).english)
595 languages = set(
596 lang for lang in languages if not is_english_variant(lang))
597- return languages
598+ return list(languages)
599
600=== modified file 'lib/lp/answers/stories/distribution-package-answer-contact.txt'
601--- lib/lp/answers/stories/distribution-package-answer-contact.txt 2011-03-23 16:28:51 +0000
602+++ lib/lp/answers/stories/distribution-package-answer-contact.txt 2011-05-09 18:59:17 +0000
603@@ -16,7 +16,7 @@
604 >>> # Answer contacts must speak a language
605 >>> user = getUtility(ILaunchBag).user
606 >>> user.addLanguage(getUtility(ILanguageSet)['en'])
607- >>> ubuntu.addAnswerContact(user)
608+ >>> ubuntu.addAnswerContact(user, user)
609 True
610 >>> flush_database_updates()
611 >>> logout()
612
613=== added file 'lib/lp/answers/stories/webservice.txt'
614--- lib/lp/answers/stories/webservice.txt 1970-01-01 00:00:00 +0000
615+++ lib/lp/answers/stories/webservice.txt 2011-05-09 18:59:17 +0000
616@@ -0,0 +1,113 @@
617+Working with Launchpad Answers over the API
618+===========================================
619+
620+Users can work with question targets and questions over the api to
621+search and update questions. This demonstration will use a project, it's
622+contact, and asker, and three questions.
623+
624+ >>> from zope.component import getUtility
625+ >>> from canonical.launchpad.testing.pages import webservice_for_person
626+ >>> from canonical.launchpad.webapp.interfaces import OAuthPermission
627+ >>> from lp.app.enums import ServiceUsage
628+ >>> from lp.services.worlddata.interfaces.language import ILanguageSet
629+ >>> from lp.testing.sampledata import ADMIN_EMAIL
630+ >>> lang_set = getUtility(ILanguageSet)
631+
632+ >>> login(ADMIN_EMAIL)
633+ >>> _contact = factory.makePerson(name='contact')
634+ >>> _project = factory.makeProduct(name='my-project', owner=_contact)
635+ >>> _contact.addLanguage(lang_set['en'])
636+ >>> _project.answers_usage = ServiceUsage.LAUNCHPAD
637+ >>> success = _project.addAnswerContact(_contact, _contact)
638+ >>> _team = factory.makeTeam(owner=_contact, name='my-team')
639+ >>> _team_project = factory.makeProduct(name='team-project', owner=_team)
640+ >>> _asker = factory.makePerson(name='asker')
641+ >>> _question_1 = factory.makeQuestion(
642+ ... target=_project, title="Q 1", owner=_asker)
643+ >>> _question_2 = factory.makeQuestion(
644+ ... target=_project, title="Q 2", owner=_asker)
645+ >>> _question_3 = factory.makeQuestion(
646+ ... target=_team_project, title="Q 3", owner=_asker)
647+ >>> logout()
648+
649+ >>> contact_webservice = webservice_for_person(
650+ ... _contact, permission=OAuthPermission.WRITE_PUBLIC)
651+
652+
653+Answer contacts
654+---------------
655+
656+Users can add or remove themselves as an answer contact for a project. The
657+user must have a preferred language. Scripts should call the
658+canUserAlterAnswerContact method first to verify that the person can
659+be added.
660+
661+ >>> project = contact_webservice.get(
662+ ... '/my-project', api_version='devel').jsonBody()
663+ >>> contact = contact_webservice.get(
664+ ... '/~contact', api_version='devel').jsonBody()
665+ >>> contact_webservice.named_get(
666+ ... project['self_link'], 'canUserAlterAnswerContact',
667+ ... person=contact['self_link'], api_version='devel').jsonBody()
668+ True
669+
670+ >>> contact_webservice.named_post(
671+ ... project['self_link'], 'removeAnswerContact',
672+ ... person=contact['self_link'], api_version='devel').jsonBody()
673+ True
674+
675+ >>> contact_webservice.named_post(
676+ ... project['self_link'], 'addAnswerContact',
677+ ... person=contact['self_link'], api_version='devel').jsonBody()
678+ True
679+
680+User can also make the teams they administer answer contacts if the team has a
681+preferred language.
682+
683+ >>> team = contact_webservice.get(
684+ ... '/~my-team', api_version='devel').jsonBody()
685+ >>> contact_webservice.named_get(
686+ ... project['self_link'], 'canUserAlterAnswerContact',
687+ ... person=team['self_link'], api_version='devel').jsonBody()
688+ True
689+
690+ >>> contact_webservice.named_post(
691+ ... team['self_link'], 'addLanguage',
692+ ... language='/+languages/fr', api_version='devel').jsonBody()
693+ >>> contact_webservice.named_post(
694+ ... project['self_link'], 'addAnswerContact',
695+ ... person=team['self_link'], api_version='devel').jsonBody()
696+ True
697+
698+
699+Anyone can get the collection of languages spoken by at least one
700+answer contact.
701+
702+ >>> languages = anon_webservice.named_get(
703+ ... project['self_link'], 'getSupportedLanguages',
704+ ... api_version='devel').jsonBody()
705+ >>> print_self_link_of_entries(languages)
706+ http://.../+languages/en
707+ http://.../+languages/fr
708+
709+ >>> english = languages['entries'][0]
710+
711+Anyone can retrieve the collection of answer contacts for a language.
712+
713+ >>> contacts = anon_webservice.named_get(
714+ ... project['self_link'], 'getAnswerContactsForLanguage',
715+ ... language=english['self_link'], api_version='devel').jsonBody()
716+ >>> print_self_link_of_entries(contacts)
717+ http://.../~contact
718+
719+
720+Questions
721+---------
722+
723+Anyone can retrieve a question from a `IQuestionTarget`.
724+
725+ >>> question_1 = anon_webservice.named_get(
726+ ... project['self_link'], 'getQuestion', question_id=_question_1.id,
727+ ... api_version='devel').jsonBody()
728+ >>> print question_1['title']
729+ Q 1
730
731=== modified file 'lib/lp/answers/tests/test_faq.py'
732--- lib/lp/answers/tests/test_faq.py 2010-12-07 14:15:37 +0000
733+++ lib/lp/answers/tests/test_faq.py 2011-05-09 18:59:17 +0000
734@@ -33,7 +33,7 @@
735 """Add the test person to the faq target's answer contacts."""
736 language_set = getUtility(ILanguageSet)
737 answer_contact.addLanguage(language_set['en'])
738- self.faq.target.addAnswerContact(answer_contact)
739+ self.faq.target.addAnswerContact(answer_contact, answer_contact)
740
741 def assertCanEdit(self, user, faq):
742 """Assert that the user can edit an FAQ."""
743
744=== modified file 'lib/lp/answers/tests/test_faqtarget.py'
745--- lib/lp/answers/tests/test_faqtarget.py 2010-12-07 14:15:37 +0000
746+++ lib/lp/answers/tests/test_faqtarget.py 2011-05-09 18:59:17 +0000
747@@ -27,7 +27,7 @@
748 """Add the test person to the faq target's answer contacts."""
749 language_set = getUtility(ILanguageSet)
750 answer_contact.addLanguage(language_set['en'])
751- self.target.addAnswerContact(answer_contact)
752+ self.target.addAnswerContact(answer_contact, answer_contact)
753
754 def assertCanAppend(self, user, target):
755 """Assert that the user can add an FAQ to an FAQ target."""
756
757=== modified file 'lib/lp/answers/tests/test_question_workflow.py'
758--- lib/lp/answers/tests/test_question_workflow.py 2011-04-26 15:44:26 +0000
759+++ lib/lp/answers/tests/test_question_workflow.py 2011-05-09 18:59:17 +0000
760@@ -866,7 +866,7 @@
761 # Reject user must be an answer contact, (or admin, or product owner).
762 # Answer contacts must speak a language
763 self.answerer.addLanguage(getUtility(ILanguageSet)['en'])
764- self.ubuntu.addAnswerContact(self.answerer)
765+ self.ubuntu.addAnswerContact(self.answerer, self.answerer)
766 login_person(self.answerer)
767 self._testInvalidTransition(
768 valid_statuses, self.question.reject,
769@@ -880,7 +880,7 @@
770 login_person(self.answerer)
771 # Answer contacts must speak a language
772 self.answerer.addLanguage(getUtility(ILanguageSet)['en'])
773- self.ubuntu.addAnswerContact(self.answerer)
774+ self.ubuntu.addAnswerContact(self.answerer, self.answerer)
775 valid_statuses = [status for status in QuestionStatus.items
776 if status.name != 'INVALID']
777
778@@ -919,7 +919,7 @@
779
780 # Answer contacts must speak a language
781 self.answerer.addLanguage(getUtility(ILanguageSet)['en'])
782- self.question.target.addAnswerContact(self.answerer)
783+ self.question.target.addAnswerContact(self.answerer, self.answerer)
784 # clear authorization cache for check_permission
785 clear_cache()
786 self.assertTrue(
787@@ -938,7 +938,7 @@
788 self.question.target = dsp
789 login_person(self.answerer)
790 self.answerer.addLanguage(getUtility(ILanguageSet)['en'])
791- self.ubuntu.addAnswerContact(self.answerer)
792+ self.ubuntu.addAnswerContact(self.answerer, self.answerer)
793 self.assertTrue(
794 getattr(self.question, 'reject'),
795 "Answer contact cannot reject question.")
796
797=== modified file 'lib/lp/answers/tests/test_questionjob.py'
798--- lib/lp/answers/tests/test_questionjob.py 2011-05-02 18:23:05 +0000
799+++ lib/lp/answers/tests/test_questionjob.py 2011-05-09 18:59:17 +0000
800@@ -82,7 +82,7 @@
801 with person_logged_in(contact):
802 lang_set = getUtility(ILanguageSet)
803 contact.addLanguage(lang_set['en'])
804- question.target.addAnswerContact(contact)
805+ question.target.addAnswerContact(contact, contact)
806 return contact
807
808 def test_create(self):
809@@ -345,7 +345,7 @@
810 with person_logged_in(user):
811 lang_set = getUtility(ILanguageSet)
812 user.addLanguage(lang_set['en'])
813- question.target.addAnswerContact(user)
814+ question.target.addAnswerContact(user, user)
815 job = QuestionEmailJob.create(
816 question, user, QuestionRecipientSet.ASKER_SUBSCRIBER,
817 subject, body, headers)
818
819=== modified file 'lib/lp/answers/tests/test_questiontarget.py'
820--- lib/lp/answers/tests/test_questiontarget.py 2011-04-22 14:39:54 +0000
821+++ lib/lp/answers/tests/test_questiontarget.py 2011-05-09 18:59:17 +0000
822@@ -15,11 +15,52 @@
823 from lp.registry.interfaces.distribution import IDistributionSet
824 from lp.services.worlddata.interfaces.language import ILanguageSet
825 from lp.testing import (
826+ login_celebrity,
827 person_logged_in,
828 TestCaseWithFactory,
829 )
830
831
832+class QuestionTargetAnswerContactTestCase(TestCaseWithFactory):
833+ """Tests for changing an answer contact."""
834+
835+ layer = DatabaseFunctionalLayer
836+
837+ def setUp(self):
838+ super(QuestionTargetAnswerContactTestCase, self).setUp()
839+ self.project = self.factory.makeProduct()
840+ self.user = self.factory.makePerson()
841+
842+ def test_canUserAlterAnswerContact_self(self):
843+ login_person(self.user)
844+ self.assertTrue(
845+ self.project.canUserAlterAnswerContact(self.user, self.user))
846+
847+ def test_canUserAlterAnswerContact_other_user(self):
848+ login_person(self.user)
849+ other_user = self.factory.makePerson()
850+ self.assertFalse(
851+ self.project.canUserAlterAnswerContact(other_user, self.user))
852+
853+ def test_canUserAlterAnswerContact_administered_team(self):
854+ login_person(self.user)
855+ team = self.factory.makeTeam(owner=self.user)
856+ self.assertTrue(
857+ self.project.canUserAlterAnswerContact(team, self.user))
858+
859+ def test_canUserAlterAnswerContact_other_team(self):
860+ login_person(self.user)
861+ other_team = self.factory.makeTeam()
862+ self.assertFalse(
863+ self.project.canUserAlterAnswerContact(other_team, self.user))
864+
865+ def test_canUserAlterAnswerContact_admin(self):
866+ admin = login_celebrity('admin')
867+ other_user = self.factory.makePerson()
868+ self.assertTrue(
869+ self.project.canUserAlterAnswerContact(other_user, admin))
870+
871+
872 class TestQuestionTarget_answer_contacts_with_languages(TestCaseWithFactory):
873 """Tests for the 'answer_contacts_with_languages' property of question
874 targets.
875@@ -39,7 +80,7 @@
876 # some non public methods to change its language cache.
877 answer_contact = removeSecurityProxy(self.answer_contact)
878 product = self.factory.makeProduct()
879- product.addAnswerContact(answer_contact)
880+ product.addAnswerContact(answer_contact, answer_contact)
881
882 # Must delete the cache because it's been filled in addAnswerContact.
883 answer_contact.deleteLanguagesCache()
884@@ -62,7 +103,7 @@
885 ubuntu = getUtility(IDistributionSet)['ubuntu']
886 self.factory.makeSourcePackageName(name='test-pkg')
887 source_package = ubuntu.getSourcePackage('test-pkg')
888- source_package.addAnswerContact(answer_contact)
889+ source_package.addAnswerContact(answer_contact, answer_contact)
890
891 # Must delete the cache because it's been filled in addAnswerContact.
892 answer_contact.deleteLanguagesCache()
893
894=== modified file 'lib/lp/coop/answersbugs/tests/test_doc.py'
895--- lib/lp/coop/answersbugs/tests/test_doc.py 2010-10-21 04:19:36 +0000
896+++ lib/lp/coop/answersbugs/tests/test_doc.py 2011-05-09 18:59:17 +0000
897@@ -49,7 +49,7 @@
898 ubuntu_team = getUtility(IPersonSet).getByName('ubuntu-team')
899 ubuntu_team.addLanguage(getUtility(ILanguageSet)['en'])
900 ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
901- ubuntu.addAnswerContact(ubuntu_team)
902+ ubuntu.addAnswerContact(ubuntu_team, ubuntu_team.teamowner)
903 ubuntu_question = ubuntu.newQuestion(
904 sample_person, "Can't install Ubuntu",
905 "I insert the install CD in the CD-ROM drive, but it won't boot.")
906@@ -62,7 +62,7 @@
907 [ubuntu_bugtask] = bug.bugtasks
908 login(ANONYMOUS)
909 # Remove the notifcations for the newly created question.
910- notifications = pop_notifications()
911+ pop_notifications()
912 return ubuntu_bugtask.id
913
914
915
916=== modified file 'lib/lp/registry/configure.zcml'
917--- lib/lp/registry/configure.zcml 2011-05-03 04:39:43 +0000
918+++ lib/lp/registry/configure.zcml 2011-05-09 18:59:17 +0000
919@@ -529,6 +529,7 @@
920 attributes="
921 newQuestion
922 createQuestionFromBug
923+ canUserAlterAnswerContact
924 addAnswerContact
925 removeAnswerContact"/>
926 <require
927@@ -1269,6 +1270,7 @@
928 attributes="
929 newQuestion
930 createQuestionFromBug
931+ canUserAlterAnswerContact
932 addAnswerContact
933 removeAnswerContact"/>
934
935@@ -1550,6 +1552,7 @@
936 attributes="
937 newQuestion
938 createQuestionFromBug
939+ canUserAlterAnswerContact
940 addAnswerContact
941 removeAnswerContact"/>
942
943@@ -1647,6 +1650,7 @@
944 attributes="
945 newQuestion
946 createQuestionFromBug
947+ canUserAlterAnswerContact
948 addAnswerContact
949 removeAnswerContact"/>
950 </class>
951
952=== modified file 'lib/lp/registry/interfaces/person.py'
953--- lib/lp/registry/interfaces/person.py 2011-05-03 13:38:30 +0000
954+++ lib/lp/registry/interfaces/person.py 2011-05-09 18:59:17 +0000
955@@ -15,7 +15,7 @@
956 'IObjectReassignment',
957 'IPerson',
958 'IPersonClaim',
959- 'IPersonPublic', # Required for a monkey patch in interfaces/archive.py
960+ 'IPersonPublic', # Required for a monkey patch in interfaces/archive.py
961 'IPersonSet',
962 'IPersonSettings',
963 'ISoftwareCenterAgentAPI',
964@@ -731,7 +731,7 @@
965
966 sshkeys = exported(
967 CollectionField(
968- title= _('List of SSH keys'),
969+ title=_('List of SSH keys'),
970 readonly=False, required=False,
971 value_type=Reference(schema=ISSHKey)))
972
973@@ -965,7 +965,7 @@
974 hardware_submissions = exported(CollectionField(
975 title=_("Hardware submissions"),
976 readonly=True, required=False,
977- value_type=Reference(schema=Interface))) # HWSubmission
978+ value_type=Reference(schema=Interface))) # HWSubmission
979
980 # This is redefined from IPrivacy.private because the attribute is
981 # read-only. It is a summary of the team's visibility.
982@@ -1030,7 +1030,7 @@
983 """
984
985 @operation_parameters(name=TextLine(required=True))
986- @operation_returns_entry(Interface) # Really ISourcePackageRecipe.
987+ @operation_returns_entry(Interface) # Really ISourcePackageRecipe.
988 @export_read_operation()
989 @operation_for_version("beta")
990 def getRecipe(name):
991@@ -1052,7 +1052,7 @@
992
993 @call_with(requester=REQUEST_USER)
994 @operation_parameters(
995- archive=Reference(schema=Interface)) # Really IArchive
996+ archive=Reference(schema=Interface)) # Really IArchive
997 @export_write_operation()
998 @operation_for_version("beta")
999 def getArchiveSubscriptionURL(requester, archive):
1000@@ -1302,6 +1302,10 @@
1001 def getPathsToTeams():
1002 """Return the paths to all teams related to this person."""
1003
1004+ @operation_parameters(
1005+ language=Reference(schema=ILanguage))
1006+ @export_write_operation()
1007+ @operation_for_version("devel")
1008 def addLanguage(language):
1009 """Add a language to this person's preferences.
1010
1011@@ -1311,6 +1315,10 @@
1012 already, nothing will happen.
1013 """
1014
1015+ @operation_parameters(
1016+ language=Reference(schema=ILanguage))
1017+ @export_write_operation()
1018+ @operation_for_version("devel")
1019 def removeLanguage(language):
1020 """Remove a language from this person's preferences.
1021
1022@@ -1376,7 +1384,7 @@
1023
1024 @operation_parameters(
1025 name=TextLine(required=True, constraint=name_validator))
1026- @operation_returns_entry(Interface) # Really IArchive.
1027+ @operation_returns_entry(Interface) # Really IArchive.
1028 @export_read_operation()
1029 @operation_for_version("beta")
1030 def getPPAByName(name):
1031@@ -1392,7 +1400,7 @@
1032 name=TextLine(required=True, constraint=name_validator),
1033 displayname=TextLine(required=False),
1034 description=TextLine(required=False))
1035- @export_factory_operation(Interface, []) # Really IArchive.
1036+ @export_factory_operation(Interface, []) # Really IArchive.
1037 @operation_for_version("beta")
1038 def createPPA(name=None, displayname=None, description=None):
1039 """Create a PPA.
1040@@ -1564,7 +1572,7 @@
1041 """
1042
1043 @operation_parameters(status=copy_field(ITeamMembership['status']))
1044- @operation_returns_collection_of(Interface) # Really IPerson
1045+ @operation_returns_collection_of(Interface) # Really IPerson
1046 @export_read_operation()
1047 @operation_for_version("beta")
1048 def getMembersByStatus(status, orderby=None):
1049
1050=== modified file 'lib/lp/registry/model/sourcepackage.py'
1051--- lib/lp/registry/model/sourcepackage.py 2011-04-26 16:22:11 +0000
1052+++ lib/lp/registry/model/sourcepackage.py 2011-05-09 18:59:17 +0000
1053@@ -141,9 +141,11 @@
1054 def getAnswerContactsForLanguage(self, language):
1055 """See `IQuestionTarget`."""
1056 # Sourcepackages are supported by their distribtions too.
1057- persons = self.distribution.getAnswerContactsForLanguage(language)
1058- persons.update(QuestionTargetMixin.getAnswerContactsForLanguage(
1059- self, language))
1060+ persons = set(
1061+ self.distribution.getAnswerContactsForLanguage(language))
1062+ persons.update(
1063+ set(QuestionTargetMixin.getAnswerContactsForLanguage(
1064+ self, language)))
1065 return sorted(
1066 [person for person in persons], key=attrgetter('displayname'))
1067
1068
1069=== modified file 'lib/lp/testing/factory.py'
1070--- lib/lp/testing/factory.py 2011-05-06 15:05:51 +0000
1071+++ lib/lp/testing/factory.py 2011-05-09 18:59:17 +0000
1072@@ -1249,6 +1249,7 @@
1073 if with_series_branches and naked_product is not None:
1074 series_branch_info = []
1075 # Add some product series
1076+
1077 def makeSeriesBranch(name, is_private=False):
1078 branch = self.makeBranch(
1079 name=name,
1080@@ -1343,7 +1344,6 @@
1081 related_package_branch_info, key=lambda branch_info: (
1082 getattr(branch_info[1], 'name')))
1083
1084-
1085 return (
1086 reference_branch,
1087 related_series_branch_info,
1088@@ -1997,21 +1997,25 @@
1089
1090 makeBlueprint = makeSpecification
1091
1092- def makeQuestion(self, target=None, title=None):
1093+ def makeQuestion(self, target=None, title=None, owner=None):
1094 """Create and return a new, arbitrary Question.
1095
1096 :param target: The IQuestionTarget to make the question on. If one is
1097 not specified, an arbitrary product is created.
1098 :param title: The question title. If one is not provided, an
1099 arbitrary title is created.
1100+ :param owner: The owner of the question. If one is not provided, the
1101+ question target owner will be used.
1102 """
1103 if target is None:
1104 target = self.makeProduct()
1105 if title is None:
1106 title = self.getUniqueString('title')
1107+ if owner is None:
1108+ owner = target.owner
1109 with person_logged_in(target.owner):
1110 question = target.newQuestion(
1111- owner=target.owner, title=title, description='description')
1112+ owner=owner, title=title, description='description')
1113 return question
1114
1115 def makeFAQ(self, target=None, title=None):
1116@@ -2360,7 +2364,7 @@
1117 changelog=changelogs.get('derived'))
1118 self.makeSourcePackagePublishingHistory(
1119 distroseries=derived_series, sourcepackagerelease=spr,
1120- status = PackagePublishingStatus.PUBLISHED)
1121+ status=PackagePublishingStatus.PUBLISHED)
1122
1123 if difference_type is not (
1124 DistroSeriesDifferenceType.UNIQUE_TO_DERIVED_SERIES):
1125@@ -2371,7 +2375,7 @@
1126 self.makeSourcePackagePublishingHistory(
1127 distroseries=derived_series.parent_series,
1128 sourcepackagerelease=spr,
1129- status = PackagePublishingStatus.PUBLISHED)
1130+ status=PackagePublishingStatus.PUBLISHED)
1131
1132 diff = getUtility(IDistroSeriesDifferenceSource).new(
1133 derived_series, source_package_name)
1134@@ -2641,7 +2645,7 @@
1135 bq = BuildQueue(
1136 job=recipe_build_job.job, lastscore=score,
1137 job_type=BuildFarmJobType.RECIPEBRANCHBUILD,
1138- estimated_duration = timedelta(seconds=estimated_duration),
1139+ estimated_duration=timedelta(seconds=estimated_duration),
1140 virtualized=virtualized)
1141 store.add(bq)
1142 return bq
1143@@ -2671,7 +2675,7 @@
1144
1145 records_inside_epoch = []
1146 all_records = []
1147- for x in range(num_recent_records+num_records_outside_epoch):
1148+ for x in range(num_recent_records + num_records_outside_epoch):
1149
1150 # We want some different source package names occasionally
1151 if not x % 3:
1152@@ -2725,7 +2729,7 @@
1153 if x < num_recent_records:
1154 naked_build.date_finished = (
1155 now - timedelta(
1156- days=epoch_days-1,
1157+ days=epoch_days - 1,
1158 hours=-x))
1159 # And others is descending order
1160 else: