launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #26886
[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><team.name></var>
+ <xsl:text>/+poll/</xsl:text>
+ <var><poll.name></var>
+ </xsl:when>
<xsl:when test="@id = 'processor'">
<xsl:text>/+processors/</xsl:text>
<var><processor.name></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>