← Back to team overview

launchpad-reviewers team mailing list archive

[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):