launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #03525
[Merge] lp:~sinzui/launchpad/anwers-api-0 into lp:launchpad
Curtis Hovey has proposed merging lp:~sinzui/launchpad/anwers-api-0 into lp:launchpad.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~sinzui/launchpad/anwers-api-0/+merge/60238
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
--
https://code.launchpad.net/~sinzui/launchpad/anwers-api-0/+merge/60238
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~sinzui/launchpad/anwers-api-0 into lp:launchpad.
=== modified file 'lib/lp/answers/browser/question.py'
--- lib/lp/answers/browser/question.py 2011-04-28 15:09:58 +0000
+++ lib/lp/answers/browser/question.py 2011-05-06 19:11:56 +0000
@@ -437,7 +437,7 @@
question_target = IQuestionTarget(self.view.question_target)
supported_languages = question_target.getSupportedLanguages()
else:
- supported_languages = set([english])
+ supported_languages = [english]
terms = []
for lang in languages:
=== modified file 'lib/lp/answers/browser/questiontarget.py'
--- lib/lp/answers/browser/questiontarget.py 2011-05-03 14:14:49 +0000
+++ lib/lp/answers/browser/questiontarget.py 2011-05-06 19:11:56 +0000
@@ -738,12 +738,12 @@
replacements = {'context': self.context.displayname}
if want_to_be_answer_contact:
self._updatePreferredLanguages(self.user)
- if self.context.addAnswerContact(self.user):
+ if self.context.addAnswerContact(self.user, self.user):
response.addNotification(
_('You have been added as an answer contact for '
'$context.', mapping=replacements))
else:
- if self.context.removeAnswerContact(self.user):
+ if self.context.removeAnswerContact(self.user, self.user):
response.addNotification(
_('You have been removed as an answer contact for '
'$context.', mapping=replacements))
@@ -752,12 +752,12 @@
replacements['teamname'] = team.displayname
if team in answer_contact_teams:
self._updatePreferredLanguages(team)
- if self.context.addAnswerContact(team):
+ if self.context.addAnswerContact(team, self.user):
response.addNotification(
_('$teamname has been added as an answer contact '
'for $context.', mapping=replacements))
else:
- if self.context.removeAnswerContact(team):
+ if self.context.removeAnswerContact(team, self.user):
response.addNotification(
_('$teamname has been removed as an answer contact '
'for $context.', mapping=replacements))
=== modified file 'lib/lp/answers/interfaces/questiontarget.py'
--- lib/lp/answers/interfaces/questiontarget.py 2011-04-26 16:22:11 +0000
+++ lib/lp/answers/interfaces/questiontarget.py 2011-05-06 19:11:56 +0000
@@ -16,11 +16,24 @@
from zope.interface import Interface
from zope.schema import (
Choice,
+ Int,
List,
Set,
TextLine,
)
+from lazr.restful.declarations import (
+ call_with,
+ export_as_webservice_entry,
+ export_read_operation,
+ export_write_operation,
+ operation_for_version,
+ operation_parameters,
+ operation_returns_collection_of,
+ REQUEST_USER,
+ )
+from lazr.restful.fields import Reference
+
from canonical.launchpad import _
from lp.answers.interfaces.questioncollection import (
ISearchableByQuestionOwner,
@@ -30,12 +43,16 @@
QuestionStatus,
QUESTION_STATUS_DEFAULT_SEARCH,
)
+from lp.registry.interfaces.person import IPerson
from lp.services.fields import PublicPersonChoice
+from lp.services.worlddata.interfaces.language import ILanguage
class IQuestionTarget(ISearchableByQuestionOwner):
"""An object that can have a new question asked about it."""
+ export_as_webservice_entry(as_of='devel')
+
def newQuestion(owner, title, description, language=None,
datecreated=None):
"""Create a new question.
@@ -69,6 +86,10 @@
:bug: An IBug.
"""
+ @operation_parameters(
+ question_id=Int(title=_('Question Number'), required=True))
+ @export_read_operation()
+ @operation_for_version('devel')
def getQuestion(question_id):
"""Return the question by its id, if it is applicable to this target.
@@ -87,25 +108,62 @@
:title: A phrase
"""
- def addAnswerContact(person):
+ @operation_parameters(
+ person=PublicPersonChoice(
+ title=_('The user or an administered team'), required=True,
+ vocabulary='ValidPersonOrTeam'))
+ @call_with(subscribed_by=REQUEST_USER)
+ @export_read_operation()
+ @operation_for_version('devel')
+ def canUserAlterAnswerContact(person, subscribed_by):
+ """Can the user add or remove the answer contact.
+
+ Users can add or remove themselves or one of the teams they
+ administered.
+
+ :param person: The `IPerson` that is or will be an answer contact.
+ :param subscribed_by: The `IPerson` making the change.
+ """
+
+ @operation_parameters(
+ person=PublicPersonChoice(
+ title=_('The user of an administered team'), required=True,
+ vocabulary='ValidPersonOrTeam'))
+ @call_with(subscribed_by=REQUEST_USER)
+ @export_write_operation()
+ @operation_for_version('devel')
+ def addAnswerContact(person, subscribed_by):
"""Add a new answer contact.
- :person: An IPerson.
-
- Returns True if the person was added, False if the person already was
- an answer contact. A person must have at least one preferred
- language to be an answer contact.
+ :param person: An `IPerson`.
+ :param subscribed_by: The user making the change.
+ :return: True if the person was added, False if the person already is
+ an answer contact.
+ :raises ValueError: When the person or team does no have a preferred
+ language.
"""
- def removeAnswerContact(person):
+ @operation_parameters(
+ person=PublicPersonChoice(
+ title=_('The user of an administered team'), required=True,
+ vocabulary='ValidPersonOrTeam'))
+ @call_with(subscribed_by=REQUEST_USER)
+ @export_write_operation()
+ @operation_for_version('devel')
+ def removeAnswerContact(person, subscribed_by):
"""Remove an answer contact.
- :person: An IPerson.
-
- Returns True if the person was removed, False if the person wasn't an
- answer contact.
+ :param person: An `IPerson`.
+ :param subscribed_by: The user making the change.
+ :return: True if the person was removed, False if the person wasn't an
+ answer contact.
"""
+ @operation_parameters(
+ language=Reference(ILanguage))
+ @operation_returns_collection_of(IPerson)
+ @export_read_operation()
+ @operation_for_version('devel')
def getAnswerContactsForLanguage(language):
"""Return the list of Persons that provide support for a language.
@@ -124,9 +182,11 @@
for the QuestionTarget.
"""
+ @operation_returns_collection_of(ILanguage)
+ @export_read_operation()
+ @operation_for_version('devel')
def getSupportedLanguages():
- """Return the set of languages spoken by at least one of this object's
- answer contacts.
+ """Return a list of languages spoken by at the answer contacts.
An answer contact is considered to speak a given language if that
language is listed as one of his preferred languages.
=== modified file 'lib/lp/answers/interfaces/webservice.py'
--- lib/lp/answers/interfaces/webservice.py 2011-04-14 16:16:30 +0000
+++ lib/lp/answers/interfaces/webservice.py 2011-05-06 19:11:56 +0000
@@ -18,6 +18,8 @@
from lp.answers.interfaces.question import IQuestion
from lp.answers.interfaces.questioncollection import IQuestionSet
+from lp.answers.interfaces.questiontarget import IQuestionTarget
+
IQuestionSet.queryTaggedValue(
LAZR_WEBSERVICE_EXPORTED)['collection_entry_schema'] = IQuestion
=== modified file 'lib/lp/answers/model/question.py'
--- lib/lp/answers/model/question.py 2011-04-28 18:40:45 +0000
+++ lib/lp/answers/model/question.py 2011-05-06 19:11:56 +0000
@@ -1319,15 +1319,28 @@
person.setLanguagesCache(languages)
return sorted(D.keys(), key=operator.attrgetter('displayname'))
- def addAnswerContact(self, person):
- """See `IQuestionTarget`."""
+ def canUserAlterAnswerContact(self, person, subscribed_by):
+ """See `IQuestionTarget`."""
+ if person is None or subscribed_by is None:
+ return False
+ admins = getUtility(ILaunchpadCelebrities).admin
+ if (person == subscribed_by
+ or person in subscribed_by.administrated_teams
+ or subscribed_by.inTeam(admins)):
+ return True
+ return False
+
+ def addAnswerContact(self, person, subscribed_by):
+ """See `IQuestionTarget`."""
+ if not self.canUserAlterAnswerContact(person, subscribed_by):
+ return False
answer_contact = AnswerContact.selectOneBy(
person=person, **self.getTargetTypes())
if answer_contact is not None:
return False
# Person must speak a language to be an answer contact.
- assert len(person.languages) > 0, (
- "An Answer Contact must speak a language.")
+ if len(person.languages) == 0:
+ raise ValueError("An Answer Contact must speak a language.")
params = dict(product=None, distribution=None, sourcepackagename=None)
params.update(self.getTargetTypes())
answer_contact = AnswerContact(person=person, **params)
@@ -1370,8 +1383,8 @@
else:
constraints.append("""
Language.id = %s""" % sqlvalues(language))
- return set(self._selectPersonFromAnswerContacts(
- constraints, ['PersonLanguage', 'Language']))
+ return list((self._selectPersonFromAnswerContacts(
+ constraints, ['PersonLanguage', 'Language'])))
def getAnswerContactRecipients(self, language):
"""See `IQuestionTarget`."""
@@ -1395,8 +1408,10 @@
recipients.add(person, reason, header)
return recipients
- def removeAnswerContact(self, person):
+ def removeAnswerContact(self, person, subscribed_by):
"""See `IQuestionTarget`."""
+ if not self.canUserAlterAnswerContact(person, subscribed_by):
+ return False
if person not in self.answer_contacts:
return False
answer_contact = AnswerContact.selectOneBy(
@@ -1416,4 +1431,4 @@
languages.add(getUtility(ILaunchpadCelebrities).english)
languages = set(
lang for lang in languages if not is_english_variant(lang))
- return languages
+ return list(languages)
=== added file 'lib/lp/answers/stories/webservice.txt'
--- lib/lp/answers/stories/webservice.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/answers/stories/webservice.txt 2011-05-06 19:11:56 +0000
@@ -0,0 +1,113 @@
+Working with Launchpad Answers over the API
+===========================================
+
+Users can work with question targets and questions over the api to
+search and update questions. This demonstration will use a project, it's
+contact, and asker, and three questions.
+
+ >>> from zope.component import getUtility
+ >>> from canonical.launchpad.testing.pages import webservice_for_person
+ >>> from canonical.launchpad.webapp.interfaces import OAuthPermission
+ >>> from lp.app.enums import ServiceUsage
+ >>> from lp.services.worlddata.interfaces.language import ILanguageSet
+ >>> from lp.testing.sampledata import ADMIN_EMAIL
+ >>> lang_set = getUtility(ILanguageSet)
+
+ >>> login(ADMIN_EMAIL)
+ >>> _contact = factory.makePerson(name='contact')
+ >>> _project = factory.makeProduct(name='my-project', owner=_contact)
+ >>> _contact.addLanguage(lang_set['en'])
+ >>> _project.answers_usage = ServiceUsage.LAUNCHPAD
+ >>> success = _project.addAnswerContact(_contact, _contact)
+ >>> _team = factory.makeTeam(owner=_contact, name='my-team')
+ >>> _team_project = factory.makeProduct(name='team-project', owner=_team)
+ >>> _asker = factory.makePerson(name='asker')
+ >>> _question_1 = factory.makeQuestion(
+ ... target=_project, title="Q 1", owner=_asker)
+ >>> _question_2 = factory.makeQuestion(
+ ... target=_project, title="Q 2", owner=_asker)
+ >>> _question_3 = factory.makeQuestion(
+ ... target=_team_project, title="Q 3", owner=_asker)
+ >>> logout()
+
+ >>> contact_webservice = webservice_for_person(
+ ... _contact, permission=OAuthPermission.WRITE_PUBLIC)
+
+
+Answer contacts
+---------------
+
+Users can add or remove themselves as an answer contact for a project. The
+user must have a preferred language. Scripts should call the
+canUserAlterAnswerContact method first to verify that the person can
+be added.
+
+ >>> project = contact_webservice.get(
+ ... '/my-project', api_version='devel').jsonBody()
+ >>> contact = contact_webservice.get(
+ ... '/~contact', api_version='devel').jsonBody()
+ >>> contact_webservice.named_get(
+ ... project['self_link'], 'canUserAlterAnswerContact',
+ ... person=contact['self_link'], api_version='devel').jsonBody()
+ True
+
+ >>> contact_webservice.named_post(
+ ... project['self_link'], 'removeAnswerContact',
+ ... person=contact['self_link'], api_version='devel').jsonBody()
+ True
+
+ >>> contact_webservice.named_post(
+ ... project['self_link'], 'addAnswerContact',
+ ... person=contact['self_link'], api_version='devel').jsonBody()
+ True
+
+User can also make the teams they administer answer contacts if the team has a
+preferred language.
+
+ >>> team = contact_webservice.get(
+ ... '/~my-team', api_version='devel').jsonBody()
+ >>> contact_webservice.named_get(
+ ... project['self_link'], 'canUserAlterAnswerContact',
+ ... person=team['self_link'], api_version='devel').jsonBody()
+ True
+
+ >>> contact_webservice.named_post(
+ ... team['self_link'], 'addLanguage',
+ ... language='/+languages/fr', api_version='devel').jsonBody()
+ >>> contact_webservice.named_post(
+ ... project['self_link'], 'addAnswerContact',
+ ... person=team['self_link'], api_version='devel').jsonBody()
+ True
+
+
+Anyone can get the collection of languages spoken by at least one
+answer contact.
+
+ >>> languages = anon_webservice.named_get(
+ ... project['self_link'], 'getSupportedLanguages',
+ ... api_version='devel').jsonBody()
+ >>> print_self_link_of_entries(languages)
+ http://.../+languages/en
+ http://.../+languages/fr
+
+ >>> english = languages['entries'][0]
+
+Anyone can retrieve the collection of answer contacts for a language.
+
+ >>> contacts = anon_webservice.named_get(
+ ... project['self_link'], 'getAnswerContactsForLanguage',
+ ... language=english['self_link'], api_version='devel').jsonBody()
+ >>> print_self_link_of_entries(contacts)
+ http://.../~contact
+
+
+Questions
+---------
+
+Anyone can retrieve a question from a `IQuestionTarget`.
+
+ >>> question_1 = anon_webservice.named_get(
+ ... project['self_link'], 'getQuestion', question_id=_question_1.id,
+ ... api_version='devel').jsonBody()
+ >>> print question_1['title']
+ Q 1
=== modified file 'lib/lp/answers/tests/test_questiontarget.py'
--- lib/lp/answers/tests/test_questiontarget.py 2011-04-22 14:39:54 +0000
+++ lib/lp/answers/tests/test_questiontarget.py 2011-05-06 19:11:56 +0000
@@ -15,11 +15,52 @@
from lp.registry.interfaces.distribution import IDistributionSet
from lp.services.worlddata.interfaces.language import ILanguageSet
from lp.testing import (
+ login_celebrity,
person_logged_in,
TestCaseWithFactory,
)
+class QuestionTargetAnswerContactTestCase(TestCaseWithFactory):
+ """Tests for changing an answer contact."""
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(QuestionTargetAnswerContactTestCase, self).setUp()
+ self.project = self.factory.makeProduct()
+ self.user = self.factory.makePerson()
+
+ def test_canUserAlterAnswerContact_self(self):
+ login_person(self.user)
+ self.assertTrue(
+ self.project.canUserAlterAnswerContact(self.user, self.user))
+
+ def test_canUserAlterAnswerContact_other_user(self):
+ login_person(self.user)
+ other_user = self.factory.makePerson()
+ self.assertFalse(
+ self.project.canUserAlterAnswerContact(other_user, self.user))
+
+ def test_canUserAlterAnswerContact_administered_team(self):
+ login_person(self.user)
+ team = self.factory.makeTeam(owner=self.user)
+ self.assertTrue(
+ self.project.canUserAlterAnswerContact(team, self.user))
+
+ def test_canUserAlterAnswerContact_other_team(self):
+ login_person(self.user)
+ other_team = self.factory.makeTeam()
+ self.assertFalse(
+ self.project.canUserAlterAnswerContact(other_team, self.user))
+
+ def test_canUserAlterAnswerContact_admin(self):
+ admin = login_celebrity('admin')
+ other_user = self.factory.makePerson()
+ self.assertTrue(
+ self.project.canUserAlterAnswerContact(other_user, admin))
+
+
class TestQuestionTarget_answer_contacts_with_languages(TestCaseWithFactory):
"""Tests for the 'answer_contacts_with_languages' property of question
targets.
@@ -39,7 +80,7 @@
# some non public methods to change its language cache.
answer_contact = removeSecurityProxy(self.answer_contact)
product = self.factory.makeProduct()
- product.addAnswerContact(answer_contact)
+ product.addAnswerContact(answer_contact, answer_contact)
# Must delete the cache because it's been filled in addAnswerContact.
answer_contact.deleteLanguagesCache()
@@ -62,7 +103,7 @@
ubuntu = getUtility(IDistributionSet)['ubuntu']
self.factory.makeSourcePackageName(name='test-pkg')
source_package = ubuntu.getSourcePackage('test-pkg')
- source_package.addAnswerContact(answer_contact)
+ source_package.addAnswerContact(answer_contact, answer_contact)
# Must delete the cache because it's been filled in addAnswerContact.
answer_contact.deleteLanguagesCache()
=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml 2011-05-03 04:39:43 +0000
+++ lib/lp/registry/configure.zcml 2011-05-06 19:11:56 +0000
@@ -529,6 +529,7 @@
attributes="
newQuestion
createQuestionFromBug
+ canUserAlterAnswerContact
addAnswerContact
removeAnswerContact"/>
<require
@@ -1269,6 +1270,7 @@
attributes="
newQuestion
createQuestionFromBug
+ canUserAlterAnswerContact
addAnswerContact
removeAnswerContact"/>
@@ -1550,6 +1552,7 @@
attributes="
newQuestion
createQuestionFromBug
+ canUserAlterAnswerContact
addAnswerContact
removeAnswerContact"/>
@@ -1647,6 +1650,7 @@
attributes="
newQuestion
createQuestionFromBug
+ canUserAlterAnswerContact
addAnswerContact
removeAnswerContact"/>
</class>
=== modified file 'lib/lp/registry/interfaces/person.py'
--- lib/lp/registry/interfaces/person.py 2011-05-03 13:38:30 +0000
+++ lib/lp/registry/interfaces/person.py 2011-05-06 19:11:56 +0000
@@ -1302,6 +1302,10 @@
def getPathsToTeams():
"""Return the paths to all teams related to this person."""
+ @operation_parameters(
+ language=Reference(schema=ILanguage))
+ @export_write_operation()
+ @operation_for_version("devel")
def addLanguage(language):
"""Add a language to this person's preferences.
@@ -1311,6 +1315,10 @@
already, nothing will happen.
"""
+ @operation_parameters(
+ language=Reference(schema=ILanguage))
+ @export_write_operation()
+ @operation_for_version("devel")
def removeLanguage(language):
"""Remove a language from this person's preferences.
=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py 2011-05-03 04:39:43 +0000
+++ lib/lp/testing/factory.py 2011-05-06 19:11:56 +0000
@@ -1987,7 +1987,7 @@
makeBlueprint = makeSpecification
- def makeQuestion(self, target=None, title=None):
+ def makeQuestion(self, target=None, title=None, owner=None):
"""Create and return a new, arbitrary Question.
:param target: The IQuestionTarget to make the question on. If one is
@@ -1999,9 +1999,11 @@
target = self.makeProduct()
if title is None:
title = self.getUniqueString('title')
+ if owner is None:
+ owner = target.owner
with person_logged_in(target.owner):
question = target.newQuestion(
- owner=target.owner, title=title, description='description')
+ owner=owner, title=title, description='description')
return question
def makeFAQ(self, target=None, title=None):