← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:export-polls into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:export-polls into launchpad:master.

Commit message:
Export a simple read-only API for polls

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/401056

This is just enough to allow us to deal with poll spam more easily.  The internal interface for selecting the ordering of poll results changes a bit, but existing web UI views should remain unchanged.

We don't need to consider privacy, as the validator on `Poll.team_id` already constrains polls to be owned only by public teams.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:export-polls into launchpad:master.
diff --git a/lib/lp/app/browser/launchpad.py b/lib/lp/app/browser/launchpad.py
index 7388562..ebeb6d5 100644
--- a/lib/lp/app/browser/launchpad.py
+++ b/lib/lp/app/browser/launchpad.py
@@ -114,6 +114,7 @@ from lp.registry.interfaces.karma import IKarmaActionSet
 from lp.registry.interfaces.nameblacklist import INameBlacklistSet
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.pillar import IPillarNameSet
+from lp.registry.interfaces.poll import IPollSet
 from lp.registry.interfaces.product import (
     InvalidProductName,
     IProduct,
@@ -867,6 +868,7 @@ class LaunchpadRootNavigation(Navigation):
         'package-sets': IPackagesetSet,
         'people': IPersonSet,
         'pillars': IPillarNameSet,
+        '+polls': IPollSet,
         '+processors': IProcessorSet,
         'projects': IProductSet,
         'projectgroups': IProjectGroupSet,
diff --git a/lib/lp/registry/adapters.py b/lib/lp/registry/adapters.py
index e5016c4..beba4a7 100644
--- a/lib/lp/registry/adapters.py
+++ b/lib/lp/registry/adapters.py
@@ -18,6 +18,7 @@ from zope.component.interfaces import ComponentLookupError
 from zope.interface import implementer
 
 from lp.archivepublisher.interfaces.publisherconfig import IPublisherConfigSet
+from lp.registry.enums import PollSort
 from lp.registry.interfaces.poll import (
     IPollSet,
     IPollSubset,
@@ -96,14 +97,16 @@ class PollSubset:
         assert self.team is not None, (
             'team cannot be None to call this method.')
         return getUtility(IPollSet).findByTeam(
-            self.team, [PollStatus.OPEN], order_by='datecloses', when=when)
+            self.team, [PollStatus.OPEN],
+            order_by=PollSort.CLOSING, when=when)
 
     def getClosedPolls(self, when=None):
         """See IPollSubset."""
         assert self.team is not None, (
             'team cannot be None to call this method.')
         return getUtility(IPollSet).findByTeam(
-            self.team, [PollStatus.CLOSED], order_by='datecloses', when=when)
+            self.team, [PollStatus.CLOSED],
+            order_by=PollSort.CLOSING, when=when)
 
     def getNotYetOpenedPolls(self, when=None):
         """See IPollSubset."""
@@ -111,7 +114,7 @@ class PollSubset:
             'team cannot be None to call this method.')
         return getUtility(IPollSet).findByTeam(
             self.team, [PollStatus.NOT_YET_OPENED],
-            order_by='dateopens', when=when)
+            order_by=PollSort.OPENING, when=when)
 
 
 def productseries_to_product(productseries):
diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
index cdaa3da..8895a76 100644
--- a/lib/lp/registry/browser/configure.zcml
+++ b/lib/lp/registry/browser/configure.zcml
@@ -757,6 +757,11 @@
         path_expression="string:+poll/${name}"
         attribute_to_parent="team"
         />
+    <browser:url
+        for="lp.registry.interfaces.poll.IPollSet"
+        path_expression="string:+polls"
+        parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot"
+        />
     <browser:pages
         for="lp.registry.interfaces.poll.IPoll"
         class="lp.registry.browser.poll.PollView"
diff --git a/lib/lp/registry/enums.py b/lib/lp/registry/enums.py
index 0ca6af9..1e99d85 100644
--- a/lib/lp/registry/enums.py
+++ b/lib/lp/registry/enums.py
@@ -14,6 +14,7 @@ __all__ = [
     'INCLUSIVE_TEAM_POLICY',
     'PersonTransferJobType',
     'PersonVisibility',
+    'PollSort',
     'ProductJobType',
     'VCSType',
     'SharingPermission',
@@ -25,6 +26,8 @@ __all__ = [
 from lazr.enum import (
     DBEnumeratedType,
     DBItem,
+    EnumeratedType,
+    Item,
     )
 
 
@@ -461,3 +464,31 @@ class DistributionDefaultTraversalPolicy(DBEnumeratedType):
         The default traversal from a distribution is used for OCI projects
         in that distribution.
         """)
+
+
+class PollSort(EnumeratedType):
+    """Choices for how to sort polls."""
+
+    OLDEST_FIRST = Item("""
+        oldest first
+
+        Sort polls from oldest to newest.
+        """)
+
+    NEWEST_FIRST = Item("""
+        newest first
+
+        Sort polls from newest to oldest.
+        """)
+
+    OPENING = Item("""
+        by opening date
+
+        Sort polls with the earliest opening date first.
+        """)
+
+    CLOSING = Item("""
+        by closing date
+
+        Sort polls with the earliest closing date first.
+        """)
diff --git a/lib/lp/registry/interfaces/poll.py b/lib/lp/registry/interfaces/poll.py
index 6b894a4..f73aeb8 100644
--- a/lib/lp/registry/interfaces/poll.py
+++ b/lib/lp/registry/interfaces/poll.py
@@ -27,7 +27,18 @@ from lazr.enum import (
     DBEnumeratedType,
     DBItem,
     )
-from lazr.restful.declarations import error_status
+from lazr.restful.declarations import (
+    collection_default_content,
+    error_status,
+    export_read_operation,
+    exported,
+    exported_as_webservice_collection,
+    exported_as_webservice_entry,
+    operation_for_version,
+    operation_parameters,
+    operation_returns_collection_of,
+    )
+from lazr.restful.fields import Reference
 import pytz
 from six.moves import http_client
 from zope.component import getUtility
@@ -42,12 +53,14 @@ from zope.schema import (
     Choice,
     Datetime,
     Int,
+    List,
     Text,
     TextLine,
     )
 
 from lp import _
 from lp.app.validators.name import name_validator
+from lp.registry.enums import PollSort
 from lp.registry.interfaces.person import ITeam
 from lp.services.fields import ContentNameField
 
@@ -120,53 +133,55 @@ class CannotCreatePoll(Exception):
     pass
 
 
+@exported_as_webservice_entry(as_of="beta")
 class IPoll(Interface):
     """A poll for a given proposition in a team."""
 
     id = Int(title=_('The unique ID'), required=True, readonly=True)
 
-    team = Int(
+    team = exported(Reference(
+        ITeam,
         title=_('The team that this poll refers to.'), required=True,
-        readonly=True)
+        readonly=True))
 
-    name = PollNameField(
+    name = exported(PollNameField(
         title=_('The unique name of this poll'),
         description=_('A short unique name, beginning with a lower-case '
                       'letter or number, and containing only letters, '
                       'numbers, dots, hyphens, or plus signs.'),
-        required=True, readonly=False, constraint=name_validator)
+        required=True, readonly=False, constraint=name_validator))
 
-    title = TextLine(
-        title=_('The title of this poll'), required=True, readonly=False)
+    title = exported(TextLine(
+        title=_('The title of this poll'), required=True, readonly=False))
 
-    dateopens = Datetime(
+    dateopens = exported(Datetime(
         title=_('The date and time when this poll opens'), required=True,
-        readonly=False)
+        readonly=False))
 
-    datecloses = Datetime(
+    datecloses = exported(Datetime(
         title=_('The date and time when this poll closes'), required=True,
-        readonly=False)
+        readonly=False))
 
-    proposition = Text(
+    proposition = exported(Text(
         title=_('The proposition that is going to be voted'), required=True,
-        readonly=False)
+        readonly=False))
 
-    type = Choice(
+    type = exported(Choice(
         title=_('The type of this poll'), required=True,
         readonly=False, vocabulary=PollAlgorithm,
-        default=PollAlgorithm.CONDORCET)
+        default=PollAlgorithm.CONDORCET))
 
-    allowspoilt = Bool(
+    allowspoilt = exported(Bool(
         title=_('Users can spoil their votes?'),
         description=_(
             'Allow users to leave the ballot blank (i.e. cast a vote for '
             '"None of the above")'),
-        required=True, readonly=False, default=True)
+        required=True, readonly=False, default=True))
 
-    secrecy = Choice(
+    secrecy = exported(Choice(
         title=_('The secrecy of the Poll'), required=True,
         readonly=False, vocabulary=PollSecrecy,
-        default=PollSecrecy.SECRET)
+        default=PollSecrecy.SECRET))
 
     @invariant
     def saneDates(poll):
@@ -297,6 +312,7 @@ class IPoll(Interface):
         """
 
 
+@exported_as_webservice_collection(IPoll)
 class IPollSet(Interface):
     """The set of Poll objects."""
 
@@ -304,19 +320,44 @@ class IPollSet(Interface):
             secrecy, allowspoilt, poll_type=PollAlgorithm.SIMPLE):
         """Create a new Poll for the given team."""
 
-    def findByTeam(team, status=PollStatus.ALL, order_by=None, when=None):
-        """Return all Polls for the given team, filtered by status.
-
-        :status: is a sequence containing as many values as you want from
-        PollStatus.
+    @operation_parameters(
+        team=Reference(ITeam, title=_("Team"), required=False),
+        status=List(
+            title=_("Poll statuses"),
+            description=_(
+                "A list of one or more of 'open', 'closed', or "
+                "'not-yet-opened'.  Defaults to all statuses."),
+            value_type=Choice(values=PollStatus.ALL), min_length=1,
+            required=False),
+        order_by=Choice(
+            title=_("Sort order"), vocabulary=PollSort, required=False))
+    @operation_returns_collection_of(IPoll)
+    @export_read_operation()
+    @operation_for_version("devel")
+    def find(team=None, status=None, order_by=PollSort.NEWEST_FIRST,
+             when=None):
+        """Search for polls.
+
+        :param team: An optional `ITeam` to filter by.
+        :param status: A collection containing as many values as you want
+            from PollStatus.  Defaults to `PollStatus.ALL`.
+        :param order_by: An optional `PollSort` item indicating how to sort
+            the results.  Defaults to `PollSort.NEWEST_FIRST`.
+        :param when: Used only by tests, to filter for polls open at a
+            specific date.
+        """
 
-        :order_by: can be either a string with the column name you want to
-        sort or a list of column names as strings.
-        If no order_by is specified the results will be ordered using the
-        default ordering specified in Poll.sortingColumns.
+    def findByTeam(team, status=None, order_by=PollSort.NEWEST_FIRST,
+                   when=None):
+        """Return all Polls for the given team, filtered by status.
 
-        The optional :when argument is used only by our tests, to test if the
-        poll is/was/will-be open at a specific date.
+        :param team: A `ITeam` to filter by.
+        :param status: A collection containing as many values as you want
+            from PollStatus.  Defaults to `PollStatus.ALL`.
+        :param order_by: An optional `PollSort` item indicating how to sort
+            the results.  Defaults to `PollSort.NEWEST_FIRST`.
+        :param when: Used only by tests, to filter for polls open at a
+            specific date.
         """
 
     def getByTeamAndName(team, name, default=None):
@@ -325,6 +366,13 @@ class IPollSet(Interface):
         Return :default if there's no Poll with this name for that team.
         """
 
+    @collection_default_content()
+    def emptyList():
+        """Return an empty collection of polls.
+
+        This only exists to keep lazr.restful happy.
+        """
+
 
 class IPollSubset(Interface):
     """The set of Poll objects for a given team."""
diff --git a/lib/lp/registry/interfaces/webservice.py b/lib/lp/registry/interfaces/webservice.py
index aab6c21..c27edda 100644
--- a/lib/lp/registry/interfaces/webservice.py
+++ b/lib/lp/registry/interfaces/webservice.py
@@ -22,6 +22,8 @@ __all__ = [
     'IPersonSet',
     'IPillar',
     'IPillarNameSet',
+    'IPoll',
+    'IPollSet',
     'IProduct',
     'IProductRelease',
     'IProductReleaseFile',
@@ -83,6 +85,10 @@ from lp.registry.interfaces.pillar import (
     IPillar,
     IPillarNameSet,
     )
+from lp.registry.interfaces.poll import (
+    IPoll,
+    IPollSet,
+    )
 from lp.registry.interfaces.product import (
     IProduct,
     IProductSet,
diff --git a/lib/lp/registry/model/poll.py b/lib/lp/registry/model/poll.py
index 419a620..a4fe45d 100644
--- a/lib/lp/registry/model/poll.py
+++ b/lib/lp/registry/model/poll.py
@@ -22,6 +22,7 @@ from storm.locals import (
     And,
     Bool,
     DateTime,
+    Desc,
     Int,
     Or,
     Reference,
@@ -31,6 +32,7 @@ from storm.locals import (
 from zope.component import getUtility
 from zope.interface import implementer
 
+from lp.registry.enums import PollSort
 from lp.registry.interfaces.person import validate_public_person
 from lp.registry.interfaces.poll import (
     CannotCreatePoll,
@@ -309,15 +311,27 @@ class PollSet:
         IStore(Poll).add(poll)
         return poll
 
-    def findByTeam(self, team, status=PollStatus.ALL, order_by=None,
-                   when=None):
+    @staticmethod
+    def _convertPollSortToOrderBy(sort_by):
+        """Compute a value to pass to `order_by` on a poll collection.
+
+        :param sort_by: An item from the `PollSort` enumeration.
+        """
+        return {
+            PollSort.OLDEST_FIRST: [Poll.id],
+            PollSort.NEWEST_FIRST: [Desc(Poll.id)],
+            PollSort.OPENING: [Poll.dateopens, Poll.id],
+            PollSort.CLOSING: [Poll.datecloses, Poll.id],
+            }[sort_by]
+
+    def find(self, team=None, status=None, order_by=PollSort.NEWEST_FIRST,
+             when=None):
         """See IPollSet."""
+        if status is None:
+            status = PollStatus.ALL
         if when is None:
             when = datetime.now(pytz.timezone('UTC'))
 
-        if order_by is None:
-            order_by = Poll.sortingColumns
-
         status = set(status)
         status_clauses = []
         if PollStatus.OPEN in status:
@@ -330,16 +344,30 @@ class PollSet:
 
         assert len(status_clauses) > 0, "No poll statuses were selected"
 
-        results = IStore(Poll).find(
-            Poll, Poll.team == team, Or(*status_clauses))
+        clauses = []
+        if team is not None:
+            clauses.append(Poll.team == team)
+        clauses.append(Or(*status_clauses))
+
+        results = IStore(Poll).find(Poll, *clauses)
 
-        return results.order_by(order_by)
+        return results.order_by(self._convertPollSortToOrderBy(order_by))
+
+    def findByTeam(self, team, status=None, order_by=PollSort.NEWEST_FIRST,
+                   when=None):
+        """See IPollSet."""
+        return self.find(
+            team=team, status=status, order_by=order_by, when=when)
 
     def getByTeamAndName(self, team, name, default=None):
         """See IPollSet."""
         poll = IStore(Poll).find(Poll, team=team, name=name).one()
         return poll if poll is not None else default
 
+    def emptyList(self):
+        """See IPollSet."""
+        return []
+
 
 @implementer(IPollOption)
 class PollOption(StormBase):
diff --git a/lib/lp/registry/tests/test_poll.py b/lib/lp/registry/tests/test_poll.py
index e2297fc..77591dc 100644
--- a/lib/lp/registry/tests/test_poll.py
+++ b/lib/lp/registry/tests/test_poll.py
@@ -7,14 +7,32 @@ from datetime import (
     datetime,
     timedelta,
     )
+from operator import attrgetter
 
 import pytz
+from testtools.matchers import (
+    ContainsDict,
+    Equals,
+    MatchesListwise,
+    )
+from zope.component import getUtility
 
+from lp.registry.interfaces.poll import (
+    IPollSet,
+    PollSecrecy,
+    )
+from lp.registry.model.poll import Poll
+from lp.services.database.interfaces import IStore
+from lp.services.webapp.interfaces import OAuthPermission
 from lp.testing import (
+    api_url,
     login,
+    login_person,
+    logout,
     TestCaseWithFactory,
     )
 from lp.testing.layers import LaunchpadFunctionalLayer
+from lp.testing.pages import webservice_for_person
 
 
 class TestPoll(TestCaseWithFactory):
@@ -31,3 +49,114 @@ class TestPoll(TestCaseWithFactory):
         # Force closing of the poll so that we can call getWinners().
         poll.datecloses = datetime.now(pytz.UTC)
         self.assertIsNone(poll.getWinners(), poll.getWinners())
+
+
+class MatchesPollAPI(ContainsDict):
+
+    def __init__(self, webservice, poll):
+        super(MatchesPollAPI, self).__init__({
+            "team_link": Equals(webservice.getAbsoluteUrl(api_url(poll.team))),
+            "name": Equals(poll.name),
+            "title": Equals(poll.title),
+            "dateopens": Equals(poll.dateopens.isoformat()),
+            "datecloses": Equals(poll.datecloses.isoformat()),
+            "proposition": Equals(poll.proposition),
+            "type": Equals(poll.type.title),
+            "allowspoilt": Equals(poll.allowspoilt),
+            "secrecy": Equals(poll.secrecy.title),
+            })
+
+
+class TestPollWebservice(TestCaseWithFactory):
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestPollWebservice, self).setUp()
+        self.person = self.factory.makePerson()
+        self.pushConfig("launchpad", default_batch_size=50)
+
+    def makePolls(self):
+        teams = [self.factory.makeTeam() for _ in range(3)]
+        polls = []
+        for team in teams:
+            for offset in (-8, -1, 1):
+                dateopens = datetime.now(pytz.UTC) + timedelta(days=offset)
+                datecloses = dateopens + timedelta(days=7)
+                polls.append(getUtility(IPollSet).new(
+                    team=team, name=self.factory.getUniqueUnicode(),
+                    title=self.factory.getUniqueUnicode(),
+                    proposition=self.factory.getUniqueUnicode(),
+                    dateopens=dateopens, datecloses=datecloses,
+                    secrecy=PollSecrecy.SECRET, allowspoilt=True,
+                    check_permissions=False))
+        return polls
+
+    def test_find_all(self):
+        polls = list(IStore(Poll).find(Poll)) + self.makePolls()
+        webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.READ_PUBLIC)
+        webservice.default_api_version = "devel"
+        logout()
+        response = webservice.named_get("/+polls", "find")
+        login_person(self.person)
+        self.assertEqual(200, response.status)
+        self.assertThat(response.jsonBody()["entries"], MatchesListwise([
+            MatchesPollAPI(webservice, poll)
+            for poll in sorted(polls, key=attrgetter("id"), reverse=True)
+            ]))
+
+    def test_find_all_ordered(self):
+        polls = list(IStore(Poll).find(Poll)) + self.makePolls()
+        webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.READ_PUBLIC)
+        webservice.default_api_version = "devel"
+        logout()
+        response = webservice.named_get(
+            "/+polls", "find", order_by="by opening date")
+        login_person(self.person)
+        self.assertEqual(200, response.status)
+        self.assertThat(response.jsonBody()["entries"], MatchesListwise([
+            MatchesPollAPI(webservice, poll)
+            for poll in sorted(polls, key=attrgetter("dateopens", "id"))
+            ]))
+
+    def test_find_by_team(self):
+        polls = self.makePolls()
+        team_url = api_url(polls[0].team)
+        webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.READ_PUBLIC)
+        webservice.default_api_version = "devel"
+        logout()
+        response = webservice.named_get("/+polls", "find", team=team_url)
+        login_person(self.person)
+        self.assertEqual(200, response.status)
+        self.assertThat(response.jsonBody()["entries"], MatchesListwise([
+            MatchesPollAPI(webservice, poll)
+            for poll in sorted(polls[:3], key=attrgetter("id"), reverse=True)
+            ]))
+
+    def test_find_by_team_and_status(self):
+        polls = self.makePolls()
+        team_url = api_url(polls[0].team)
+        webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.READ_PUBLIC)
+        webservice.default_api_version = "devel"
+        logout()
+        response = webservice.named_get(
+            "/+polls", "find", team=team_url,
+            status=["open", "not-yet-opened"])
+        login_person(self.person)
+        self.assertEqual(200, response.status)
+        self.assertThat(response.jsonBody()["entries"], MatchesListwise([
+            MatchesPollAPI(webservice, poll)
+            for poll in sorted(polls[1:3], key=attrgetter("id"), reverse=True)
+            ]))
+        logout()
+        response = webservice.named_get(
+            "/+polls", "find", team=team_url, status=["closed", "open"])
+        login_person(self.person)
+        self.assertEqual(200, response.status)
+        self.assertThat(response.jsonBody()["entries"], MatchesListwise([
+            MatchesPollAPI(webservice, poll)
+            for poll in sorted(polls[:2], key=attrgetter("id"), reverse=True)
+            ]))
diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl
index 331f09b..ee5e2e6 100644
--- a/lib/lp/services/webservice/wadl-to-refhtml.xsl
+++ b/lib/lp/services/webservice/wadl-to-refhtml.xsl
@@ -475,6 +475,12 @@
             <xsl:when test="@id = 'pillars'">
                 <xsl:text>/pillars</xsl:text>
             </xsl:when>
+            <xsl:when test="@id = 'poll'">
+                <xsl:text>/~</xsl:text>
+                <var>&lt;team.name&gt;</var>
+                <xsl:text>/+poll/</xsl:text>
+                <var>&lt;poll.name&gt;</var>
+            </xsl:when>
             <xsl:when test="@id = 'processor'">
                 <xsl:text>/+processors/</xsl:text>
                 <var>&lt;processor.name&gt;</var>
@@ -732,6 +738,9 @@
     <xsl:template name="find-root-object-uri">
         <xsl:value-of select="$base"/>
         <xsl:choose>
+            <xsl:when test="@id = 'polls'">
+                <xsl:text>/+polls</xsl:text>
+            </xsl:when>
             <xsl:when test="@id = 'snap_bases'">
                 <xsl:text>/+snap-bases</xsl:text>
             </xsl:when>