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

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 9ed8b9916b05ae312e8edf9c968a65a65dc48bd7
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:export-polls
Merge into: launchpad:master
Diff against target: 610 lines (+302/-41)
9 files modified
lib/lp/app/browser/launchpad.py (+2/-0)
lib/lp/registry/adapters.py (+6/-3)
lib/lp/registry/browser/configure.zcml (+5/-0)
lib/lp/registry/enums.py (+31/-0)
lib/lp/registry/interfaces/poll.py (+78/-30)
lib/lp/registry/interfaces/webservice.py (+6/-0)
lib/lp/registry/model/poll.py (+36/-8)
lib/lp/registry/tests/test_poll.py (+129/-0)
lib/lp/services/webservice/wadl-to-refhtml.xsl (+9/-0)
Reviewer Review Type Date Requested Status
Tom Wardill (community) Approve
Review via email: mp+401056@code.launchpad.net

Commit message

Export a simple read-only API for polls

Description of the change

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.

To post a comment you must log in.
Revision history for this message
Tom Wardill (twom) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/app/browser/launchpad.py b/lib/lp/app/browser/launchpad.py
2index 7388562..ebeb6d5 100644
3--- a/lib/lp/app/browser/launchpad.py
4+++ b/lib/lp/app/browser/launchpad.py
5@@ -114,6 +114,7 @@ from lp.registry.interfaces.karma import IKarmaActionSet
6 from lp.registry.interfaces.nameblacklist import INameBlacklistSet
7 from lp.registry.interfaces.person import IPersonSet
8 from lp.registry.interfaces.pillar import IPillarNameSet
9+from lp.registry.interfaces.poll import IPollSet
10 from lp.registry.interfaces.product import (
11 InvalidProductName,
12 IProduct,
13@@ -867,6 +868,7 @@ class LaunchpadRootNavigation(Navigation):
14 'package-sets': IPackagesetSet,
15 'people': IPersonSet,
16 'pillars': IPillarNameSet,
17+ '+polls': IPollSet,
18 '+processors': IProcessorSet,
19 'projects': IProductSet,
20 'projectgroups': IProjectGroupSet,
21diff --git a/lib/lp/registry/adapters.py b/lib/lp/registry/adapters.py
22index e5016c4..beba4a7 100644
23--- a/lib/lp/registry/adapters.py
24+++ b/lib/lp/registry/adapters.py
25@@ -18,6 +18,7 @@ from zope.component.interfaces import ComponentLookupError
26 from zope.interface import implementer
27
28 from lp.archivepublisher.interfaces.publisherconfig import IPublisherConfigSet
29+from lp.registry.enums import PollSort
30 from lp.registry.interfaces.poll import (
31 IPollSet,
32 IPollSubset,
33@@ -96,14 +97,16 @@ class PollSubset:
34 assert self.team is not None, (
35 'team cannot be None to call this method.')
36 return getUtility(IPollSet).findByTeam(
37- self.team, [PollStatus.OPEN], order_by='datecloses', when=when)
38+ self.team, [PollStatus.OPEN],
39+ order_by=PollSort.CLOSING, when=when)
40
41 def getClosedPolls(self, when=None):
42 """See IPollSubset."""
43 assert self.team is not None, (
44 'team cannot be None to call this method.')
45 return getUtility(IPollSet).findByTeam(
46- self.team, [PollStatus.CLOSED], order_by='datecloses', when=when)
47+ self.team, [PollStatus.CLOSED],
48+ order_by=PollSort.CLOSING, when=when)
49
50 def getNotYetOpenedPolls(self, when=None):
51 """See IPollSubset."""
52@@ -111,7 +114,7 @@ class PollSubset:
53 'team cannot be None to call this method.')
54 return getUtility(IPollSet).findByTeam(
55 self.team, [PollStatus.NOT_YET_OPENED],
56- order_by='dateopens', when=when)
57+ order_by=PollSort.OPENING, when=when)
58
59
60 def productseries_to_product(productseries):
61diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
62index cdaa3da..8895a76 100644
63--- a/lib/lp/registry/browser/configure.zcml
64+++ b/lib/lp/registry/browser/configure.zcml
65@@ -757,6 +757,11 @@
66 path_expression="string:+poll/${name}"
67 attribute_to_parent="team"
68 />
69+ <browser:url
70+ for="lp.registry.interfaces.poll.IPollSet"
71+ path_expression="string:+polls"
72+ parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot"
73+ />
74 <browser:pages
75 for="lp.registry.interfaces.poll.IPoll"
76 class="lp.registry.browser.poll.PollView"
77diff --git a/lib/lp/registry/enums.py b/lib/lp/registry/enums.py
78index 0ca6af9..1e99d85 100644
79--- a/lib/lp/registry/enums.py
80+++ b/lib/lp/registry/enums.py
81@@ -14,6 +14,7 @@ __all__ = [
82 'INCLUSIVE_TEAM_POLICY',
83 'PersonTransferJobType',
84 'PersonVisibility',
85+ 'PollSort',
86 'ProductJobType',
87 'VCSType',
88 'SharingPermission',
89@@ -25,6 +26,8 @@ __all__ = [
90 from lazr.enum import (
91 DBEnumeratedType,
92 DBItem,
93+ EnumeratedType,
94+ Item,
95 )
96
97
98@@ -461,3 +464,31 @@ class DistributionDefaultTraversalPolicy(DBEnumeratedType):
99 The default traversal from a distribution is used for OCI projects
100 in that distribution.
101 """)
102+
103+
104+class PollSort(EnumeratedType):
105+ """Choices for how to sort polls."""
106+
107+ OLDEST_FIRST = Item("""
108+ oldest first
109+
110+ Sort polls from oldest to newest.
111+ """)
112+
113+ NEWEST_FIRST = Item("""
114+ newest first
115+
116+ Sort polls from newest to oldest.
117+ """)
118+
119+ OPENING = Item("""
120+ by opening date
121+
122+ Sort polls with the earliest opening date first.
123+ """)
124+
125+ CLOSING = Item("""
126+ by closing date
127+
128+ Sort polls with the earliest closing date first.
129+ """)
130diff --git a/lib/lp/registry/interfaces/poll.py b/lib/lp/registry/interfaces/poll.py
131index 6b894a4..f73aeb8 100644
132--- a/lib/lp/registry/interfaces/poll.py
133+++ b/lib/lp/registry/interfaces/poll.py
134@@ -27,7 +27,18 @@ from lazr.enum import (
135 DBEnumeratedType,
136 DBItem,
137 )
138-from lazr.restful.declarations import error_status
139+from lazr.restful.declarations import (
140+ collection_default_content,
141+ error_status,
142+ export_read_operation,
143+ exported,
144+ exported_as_webservice_collection,
145+ exported_as_webservice_entry,
146+ operation_for_version,
147+ operation_parameters,
148+ operation_returns_collection_of,
149+ )
150+from lazr.restful.fields import Reference
151 import pytz
152 from six.moves import http_client
153 from zope.component import getUtility
154@@ -42,12 +53,14 @@ from zope.schema import (
155 Choice,
156 Datetime,
157 Int,
158+ List,
159 Text,
160 TextLine,
161 )
162
163 from lp import _
164 from lp.app.validators.name import name_validator
165+from lp.registry.enums import PollSort
166 from lp.registry.interfaces.person import ITeam
167 from lp.services.fields import ContentNameField
168
169@@ -120,53 +133,55 @@ class CannotCreatePoll(Exception):
170 pass
171
172
173+@exported_as_webservice_entry(as_of="beta")
174 class IPoll(Interface):
175 """A poll for a given proposition in a team."""
176
177 id = Int(title=_('The unique ID'), required=True, readonly=True)
178
179- team = Int(
180+ team = exported(Reference(
181+ ITeam,
182 title=_('The team that this poll refers to.'), required=True,
183- readonly=True)
184+ readonly=True))
185
186- name = PollNameField(
187+ name = exported(PollNameField(
188 title=_('The unique name of this poll'),
189 description=_('A short unique name, beginning with a lower-case '
190 'letter or number, and containing only letters, '
191 'numbers, dots, hyphens, or plus signs.'),
192- required=True, readonly=False, constraint=name_validator)
193+ required=True, readonly=False, constraint=name_validator))
194
195- title = TextLine(
196- title=_('The title of this poll'), required=True, readonly=False)
197+ title = exported(TextLine(
198+ title=_('The title of this poll'), required=True, readonly=False))
199
200- dateopens = Datetime(
201+ dateopens = exported(Datetime(
202 title=_('The date and time when this poll opens'), required=True,
203- readonly=False)
204+ readonly=False))
205
206- datecloses = Datetime(
207+ datecloses = exported(Datetime(
208 title=_('The date and time when this poll closes'), required=True,
209- readonly=False)
210+ readonly=False))
211
212- proposition = Text(
213+ proposition = exported(Text(
214 title=_('The proposition that is going to be voted'), required=True,
215- readonly=False)
216+ readonly=False))
217
218- type = Choice(
219+ type = exported(Choice(
220 title=_('The type of this poll'), required=True,
221 readonly=False, vocabulary=PollAlgorithm,
222- default=PollAlgorithm.CONDORCET)
223+ default=PollAlgorithm.CONDORCET))
224
225- allowspoilt = Bool(
226+ allowspoilt = exported(Bool(
227 title=_('Users can spoil their votes?'),
228 description=_(
229 'Allow users to leave the ballot blank (i.e. cast a vote for '
230 '"None of the above")'),
231- required=True, readonly=False, default=True)
232+ required=True, readonly=False, default=True))
233
234- secrecy = Choice(
235+ secrecy = exported(Choice(
236 title=_('The secrecy of the Poll'), required=True,
237 readonly=False, vocabulary=PollSecrecy,
238- default=PollSecrecy.SECRET)
239+ default=PollSecrecy.SECRET))
240
241 @invariant
242 def saneDates(poll):
243@@ -297,6 +312,7 @@ class IPoll(Interface):
244 """
245
246
247+@exported_as_webservice_collection(IPoll)
248 class IPollSet(Interface):
249 """The set of Poll objects."""
250
251@@ -304,19 +320,44 @@ class IPollSet(Interface):
252 secrecy, allowspoilt, poll_type=PollAlgorithm.SIMPLE):
253 """Create a new Poll for the given team."""
254
255- def findByTeam(team, status=PollStatus.ALL, order_by=None, when=None):
256- """Return all Polls for the given team, filtered by status.
257-
258- :status: is a sequence containing as many values as you want from
259- PollStatus.
260+ @operation_parameters(
261+ team=Reference(ITeam, title=_("Team"), required=False),
262+ status=List(
263+ title=_("Poll statuses"),
264+ description=_(
265+ "A list of one or more of 'open', 'closed', or "
266+ "'not-yet-opened'. Defaults to all statuses."),
267+ value_type=Choice(values=PollStatus.ALL), min_length=1,
268+ required=False),
269+ order_by=Choice(
270+ title=_("Sort order"), vocabulary=PollSort, required=False))
271+ @operation_returns_collection_of(IPoll)
272+ @export_read_operation()
273+ @operation_for_version("devel")
274+ def find(team=None, status=None, order_by=PollSort.NEWEST_FIRST,
275+ when=None):
276+ """Search for polls.
277+
278+ :param team: An optional `ITeam` to filter by.
279+ :param status: A collection containing as many values as you want
280+ from PollStatus. Defaults to `PollStatus.ALL`.
281+ :param order_by: An optional `PollSort` item indicating how to sort
282+ the results. Defaults to `PollSort.NEWEST_FIRST`.
283+ :param when: Used only by tests, to filter for polls open at a
284+ specific date.
285+ """
286
287- :order_by: can be either a string with the column name you want to
288- sort or a list of column names as strings.
289- If no order_by is specified the results will be ordered using the
290- default ordering specified in Poll.sortingColumns.
291+ def findByTeam(team, status=None, order_by=PollSort.NEWEST_FIRST,
292+ when=None):
293+ """Return all Polls for the given team, filtered by status.
294
295- The optional :when argument is used only by our tests, to test if the
296- poll is/was/will-be open at a specific date.
297+ :param team: A `ITeam` to filter by.
298+ :param status: A collection containing as many values as you want
299+ from PollStatus. Defaults to `PollStatus.ALL`.
300+ :param order_by: An optional `PollSort` item indicating how to sort
301+ the results. Defaults to `PollSort.NEWEST_FIRST`.
302+ :param when: Used only by tests, to filter for polls open at a
303+ specific date.
304 """
305
306 def getByTeamAndName(team, name, default=None):
307@@ -325,6 +366,13 @@ class IPollSet(Interface):
308 Return :default if there's no Poll with this name for that team.
309 """
310
311+ @collection_default_content()
312+ def emptyList():
313+ """Return an empty collection of polls.
314+
315+ This only exists to keep lazr.restful happy.
316+ """
317+
318
319 class IPollSubset(Interface):
320 """The set of Poll objects for a given team."""
321diff --git a/lib/lp/registry/interfaces/webservice.py b/lib/lp/registry/interfaces/webservice.py
322index aab6c21..c27edda 100644
323--- a/lib/lp/registry/interfaces/webservice.py
324+++ b/lib/lp/registry/interfaces/webservice.py
325@@ -22,6 +22,8 @@ __all__ = [
326 'IPersonSet',
327 'IPillar',
328 'IPillarNameSet',
329+ 'IPoll',
330+ 'IPollSet',
331 'IProduct',
332 'IProductRelease',
333 'IProductReleaseFile',
334@@ -83,6 +85,10 @@ from lp.registry.interfaces.pillar import (
335 IPillar,
336 IPillarNameSet,
337 )
338+from lp.registry.interfaces.poll import (
339+ IPoll,
340+ IPollSet,
341+ )
342 from lp.registry.interfaces.product import (
343 IProduct,
344 IProductSet,
345diff --git a/lib/lp/registry/model/poll.py b/lib/lp/registry/model/poll.py
346index 419a620..a4fe45d 100644
347--- a/lib/lp/registry/model/poll.py
348+++ b/lib/lp/registry/model/poll.py
349@@ -22,6 +22,7 @@ from storm.locals import (
350 And,
351 Bool,
352 DateTime,
353+ Desc,
354 Int,
355 Or,
356 Reference,
357@@ -31,6 +32,7 @@ from storm.locals import (
358 from zope.component import getUtility
359 from zope.interface import implementer
360
361+from lp.registry.enums import PollSort
362 from lp.registry.interfaces.person import validate_public_person
363 from lp.registry.interfaces.poll import (
364 CannotCreatePoll,
365@@ -309,15 +311,27 @@ class PollSet:
366 IStore(Poll).add(poll)
367 return poll
368
369- def findByTeam(self, team, status=PollStatus.ALL, order_by=None,
370- when=None):
371+ @staticmethod
372+ def _convertPollSortToOrderBy(sort_by):
373+ """Compute a value to pass to `order_by` on a poll collection.
374+
375+ :param sort_by: An item from the `PollSort` enumeration.
376+ """
377+ return {
378+ PollSort.OLDEST_FIRST: [Poll.id],
379+ PollSort.NEWEST_FIRST: [Desc(Poll.id)],
380+ PollSort.OPENING: [Poll.dateopens, Poll.id],
381+ PollSort.CLOSING: [Poll.datecloses, Poll.id],
382+ }[sort_by]
383+
384+ def find(self, team=None, status=None, order_by=PollSort.NEWEST_FIRST,
385+ when=None):
386 """See IPollSet."""
387+ if status is None:
388+ status = PollStatus.ALL
389 if when is None:
390 when = datetime.now(pytz.timezone('UTC'))
391
392- if order_by is None:
393- order_by = Poll.sortingColumns
394-
395 status = set(status)
396 status_clauses = []
397 if PollStatus.OPEN in status:
398@@ -330,16 +344,30 @@ class PollSet:
399
400 assert len(status_clauses) > 0, "No poll statuses were selected"
401
402- results = IStore(Poll).find(
403- Poll, Poll.team == team, Or(*status_clauses))
404+ clauses = []
405+ if team is not None:
406+ clauses.append(Poll.team == team)
407+ clauses.append(Or(*status_clauses))
408+
409+ results = IStore(Poll).find(Poll, *clauses)
410
411- return results.order_by(order_by)
412+ return results.order_by(self._convertPollSortToOrderBy(order_by))
413+
414+ def findByTeam(self, team, status=None, order_by=PollSort.NEWEST_FIRST,
415+ when=None):
416+ """See IPollSet."""
417+ return self.find(
418+ team=team, status=status, order_by=order_by, when=when)
419
420 def getByTeamAndName(self, team, name, default=None):
421 """See IPollSet."""
422 poll = IStore(Poll).find(Poll, team=team, name=name).one()
423 return poll if poll is not None else default
424
425+ def emptyList(self):
426+ """See IPollSet."""
427+ return []
428+
429
430 @implementer(IPollOption)
431 class PollOption(StormBase):
432diff --git a/lib/lp/registry/tests/test_poll.py b/lib/lp/registry/tests/test_poll.py
433index e2297fc..77591dc 100644
434--- a/lib/lp/registry/tests/test_poll.py
435+++ b/lib/lp/registry/tests/test_poll.py
436@@ -7,14 +7,32 @@ from datetime import (
437 datetime,
438 timedelta,
439 )
440+from operator import attrgetter
441
442 import pytz
443+from testtools.matchers import (
444+ ContainsDict,
445+ Equals,
446+ MatchesListwise,
447+ )
448+from zope.component import getUtility
449
450+from lp.registry.interfaces.poll import (
451+ IPollSet,
452+ PollSecrecy,
453+ )
454+from lp.registry.model.poll import Poll
455+from lp.services.database.interfaces import IStore
456+from lp.services.webapp.interfaces import OAuthPermission
457 from lp.testing import (
458+ api_url,
459 login,
460+ login_person,
461+ logout,
462 TestCaseWithFactory,
463 )
464 from lp.testing.layers import LaunchpadFunctionalLayer
465+from lp.testing.pages import webservice_for_person
466
467
468 class TestPoll(TestCaseWithFactory):
469@@ -31,3 +49,114 @@ class TestPoll(TestCaseWithFactory):
470 # Force closing of the poll so that we can call getWinners().
471 poll.datecloses = datetime.now(pytz.UTC)
472 self.assertIsNone(poll.getWinners(), poll.getWinners())
473+
474+
475+class MatchesPollAPI(ContainsDict):
476+
477+ def __init__(self, webservice, poll):
478+ super(MatchesPollAPI, self).__init__({
479+ "team_link": Equals(webservice.getAbsoluteUrl(api_url(poll.team))),
480+ "name": Equals(poll.name),
481+ "title": Equals(poll.title),
482+ "dateopens": Equals(poll.dateopens.isoformat()),
483+ "datecloses": Equals(poll.datecloses.isoformat()),
484+ "proposition": Equals(poll.proposition),
485+ "type": Equals(poll.type.title),
486+ "allowspoilt": Equals(poll.allowspoilt),
487+ "secrecy": Equals(poll.secrecy.title),
488+ })
489+
490+
491+class TestPollWebservice(TestCaseWithFactory):
492+ layer = LaunchpadFunctionalLayer
493+
494+ def setUp(self):
495+ super(TestPollWebservice, self).setUp()
496+ self.person = self.factory.makePerson()
497+ self.pushConfig("launchpad", default_batch_size=50)
498+
499+ def makePolls(self):
500+ teams = [self.factory.makeTeam() for _ in range(3)]
501+ polls = []
502+ for team in teams:
503+ for offset in (-8, -1, 1):
504+ dateopens = datetime.now(pytz.UTC) + timedelta(days=offset)
505+ datecloses = dateopens + timedelta(days=7)
506+ polls.append(getUtility(IPollSet).new(
507+ team=team, name=self.factory.getUniqueUnicode(),
508+ title=self.factory.getUniqueUnicode(),
509+ proposition=self.factory.getUniqueUnicode(),
510+ dateopens=dateopens, datecloses=datecloses,
511+ secrecy=PollSecrecy.SECRET, allowspoilt=True,
512+ check_permissions=False))
513+ return polls
514+
515+ def test_find_all(self):
516+ polls = list(IStore(Poll).find(Poll)) + self.makePolls()
517+ webservice = webservice_for_person(
518+ self.person, permission=OAuthPermission.READ_PUBLIC)
519+ webservice.default_api_version = "devel"
520+ logout()
521+ response = webservice.named_get("/+polls", "find")
522+ login_person(self.person)
523+ self.assertEqual(200, response.status)
524+ self.assertThat(response.jsonBody()["entries"], MatchesListwise([
525+ MatchesPollAPI(webservice, poll)
526+ for poll in sorted(polls, key=attrgetter("id"), reverse=True)
527+ ]))
528+
529+ def test_find_all_ordered(self):
530+ polls = list(IStore(Poll).find(Poll)) + self.makePolls()
531+ webservice = webservice_for_person(
532+ self.person, permission=OAuthPermission.READ_PUBLIC)
533+ webservice.default_api_version = "devel"
534+ logout()
535+ response = webservice.named_get(
536+ "/+polls", "find", order_by="by opening date")
537+ login_person(self.person)
538+ self.assertEqual(200, response.status)
539+ self.assertThat(response.jsonBody()["entries"], MatchesListwise([
540+ MatchesPollAPI(webservice, poll)
541+ for poll in sorted(polls, key=attrgetter("dateopens", "id"))
542+ ]))
543+
544+ def test_find_by_team(self):
545+ polls = self.makePolls()
546+ team_url = api_url(polls[0].team)
547+ webservice = webservice_for_person(
548+ self.person, permission=OAuthPermission.READ_PUBLIC)
549+ webservice.default_api_version = "devel"
550+ logout()
551+ response = webservice.named_get("/+polls", "find", team=team_url)
552+ login_person(self.person)
553+ self.assertEqual(200, response.status)
554+ self.assertThat(response.jsonBody()["entries"], MatchesListwise([
555+ MatchesPollAPI(webservice, poll)
556+ for poll in sorted(polls[:3], key=attrgetter("id"), reverse=True)
557+ ]))
558+
559+ def test_find_by_team_and_status(self):
560+ polls = self.makePolls()
561+ team_url = api_url(polls[0].team)
562+ webservice = webservice_for_person(
563+ self.person, permission=OAuthPermission.READ_PUBLIC)
564+ webservice.default_api_version = "devel"
565+ logout()
566+ response = webservice.named_get(
567+ "/+polls", "find", team=team_url,
568+ status=["open", "not-yet-opened"])
569+ login_person(self.person)
570+ self.assertEqual(200, response.status)
571+ self.assertThat(response.jsonBody()["entries"], MatchesListwise([
572+ MatchesPollAPI(webservice, poll)
573+ for poll in sorted(polls[1:3], key=attrgetter("id"), reverse=True)
574+ ]))
575+ logout()
576+ response = webservice.named_get(
577+ "/+polls", "find", team=team_url, status=["closed", "open"])
578+ login_person(self.person)
579+ self.assertEqual(200, response.status)
580+ self.assertThat(response.jsonBody()["entries"], MatchesListwise([
581+ MatchesPollAPI(webservice, poll)
582+ for poll in sorted(polls[:2], key=attrgetter("id"), reverse=True)
583+ ]))
584diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl
585index 331f09b..ee5e2e6 100644
586--- a/lib/lp/services/webservice/wadl-to-refhtml.xsl
587+++ b/lib/lp/services/webservice/wadl-to-refhtml.xsl
588@@ -475,6 +475,12 @@
589 <xsl:when test="@id = 'pillars'">
590 <xsl:text>/pillars</xsl:text>
591 </xsl:when>
592+ <xsl:when test="@id = 'poll'">
593+ <xsl:text>/~</xsl:text>
594+ <var>&lt;team.name&gt;</var>
595+ <xsl:text>/+poll/</xsl:text>
596+ <var>&lt;poll.name&gt;</var>
597+ </xsl:when>
598 <xsl:when test="@id = 'processor'">
599 <xsl:text>/+processors/</xsl:text>
600 <var>&lt;processor.name&gt;</var>
601@@ -732,6 +738,9 @@
602 <xsl:template name="find-root-object-uri">
603 <xsl:value-of select="$base"/>
604 <xsl:choose>
605+ <xsl:when test="@id = 'polls'">
606+ <xsl:text>/+polls</xsl:text>
607+ </xsl:when>
608 <xsl:when test="@id = 'snap_bases'">
609 <xsl:text>/+snap-bases</xsl:text>
610 </xsl:when>

Subscribers

People subscribed via source and target branches

to status/vote changes: