launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #02270
[Merge] lp:~sinzui/launchpad/revenge-of-the-polls into lp:launchpad
Curtis Hovey has proposed merging lp:~sinzui/launchpad/revenge-of-the-polls into lp:launchpad.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
#697290 Restore team polls :(
https://bugs.launchpad.net/bugs/697290
For more details, see:
https://code.launchpad.net/~sinzui/launchpad/revenge-of-the-polls/+merge/45139
Restore team polls.
Launchpad bug:
https://bugs.launchpad.net/bugs/697290
Pre-implementation: flacoste
Test command: ./bin/test -vv \
-t stories/team -t doc/poll -r tests/.*poll
Team polls are used by a handful of important teams a few times a year.
Restore the team poll feature. There will be a discussion about how to remove
this feature again, or to convince the community to fix this eyesore.
--------------------------------------------------------------------
RULES
* Use a reverse merge to restore polls.
QA
* Visit a team you admin and create a poll.
LINT
lib/canonical/launchpad/pagetitles.py
lib/canonical/launchpad/security.py
lib/canonical/launchpad/browser/__init__.py
lib/canonical/launchpad/pagetests/basics/notfound-traversals.txt
lib/canonical/launchpad/tests/test_poll.py
lib/lp/registry/adapters.py
lib/lp/registry/configure.zcml
lib/lp/registry/browser/configure.zcml
lib/lp/registry/browser/person.py
lib/lp/registry/browser/poll.py
lib/lp/registry/browser/tests/poll-views.txt
lib/lp/registry/browser/tests/poll-views_0.txt
lib/lp/registry/browser/tests/test_breadcrumbs.py
lib/lp/registry/browser/tests/test_poll.py
lib/lp/registry/doc/person-merge.txt
lib/lp/registry/doc/poll-preconditions.txt
lib/lp/registry/doc/poll.txt
lib/lp/registry/doc/team-nav-menus.txt
lib/lp/registry/interfaces/poll.py
lib/lp/registry/model/person.py
lib/lp/registry/model/poll.py
lib/lp/registry/stories/team-polls/
lib/lp/registry/stories/team/xx-team-home.txt
lib/lp/registry/stories/team-polls/create-poll-options.txt
lib/lp/registry/stories/team-polls/create-polls.txt
lib/lp/registry/stories/team-polls/edit-options.txt
lib/lp/registry/stories/team-polls/edit-poll.txt
lib/lp/registry/stories/team-polls/vote-poll.txt
lib/lp/registry/stories/team-polls/xx-poll-condorcet-voting.txt
lib/lp/registry/stories/team-polls/xx-poll-confirm-vote.txt
lib/lp/registry/stories/team-polls/xx-poll-results.txt
lib/lp/registry/templates/poll-edit.pt
lib/lp/registry/templates/poll-index.pt
lib/lp/registry/templates/poll-newoption.pt
lib/lp/registry/templates/poll-portlet-details.pt
lib/lp/registry/templates/poll-portlet-options.pt
lib/lp/registry/templates/poll-vote-condorcet.pt
lib/lp/registry/templates/poll-vote-simple.pt
lib/lp/registry/templates/polloption-edit.pt
lib/lp/registry/templates/team-index.pt
lib/lp/registry/templates/team-newpoll.pt
lib/lp/registry/templates/team-polls.pt
lib/lp/registry/templates/team-portlet-polls.pt
lib/lp/testing/factory.py
^ Lint hates the old tests. I am not inclinded to fix them because I still
believe removing the feature is more important.
IMPLEMENTATION
* Used reverse merge to restore the code
* Resolved a conflict in lp/registry/doc/person-merge.txt.
* Sent the code to ec2 to verify the restoration did not break the
test runner
--
https://code.launchpad.net/~sinzui/launchpad/revenge-of-the-polls/+merge/45139
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~sinzui/launchpad/revenge-of-the-polls into lp:launchpad.
=== modified file 'lib/canonical/launchpad/browser/__init__.py'
--- lib/canonical/launchpad/browser/__init__.py 2010-12-16 14:42:36 +0000
+++ lib/canonical/launchpad/browser/__init__.py 2011-01-04 16:38:41 +0000
@@ -48,6 +48,7 @@
from lp.registry.browser.mailinglists import *
from lp.registry.browser.objectreassignment import *
from lp.registry.browser.peoplemerge import *
+from lp.registry.browser.poll import *
from lp.registry.browser.team import *
from lp.registry.browser.teammembership import *
# XXX flacoste 2009/03/18 We should use specific imports instead of
@@ -62,3 +63,5 @@
from lp.soyuz.browser.publishing import *
from lp.soyuz.browser.queue import *
from lp.soyuz.browser.sourcepackagerelease import *
+
+
=== modified file 'lib/canonical/launchpad/pagetests/basics/notfound-traversals.txt'
--- lib/canonical/launchpad/pagetests/basics/notfound-traversals.txt 2010-12-30 20:08:50 +0000
+++ lib/canonical/launchpad/pagetests/basics/notfound-traversals.txt 2011-01-04 16:38:41 +0000
@@ -369,6 +369,7 @@
>>> check("/~name18/+imports", host='translations.launchpad.dev')
>>> check("/~name18/+teamlist")
>>> check("/~name18/+polls")
+>>> check("/~name18/+newpoll", auth=True)
>>> check("/~name16/+rdf")
>>> check("/~name18/+rdf")
=== modified file 'lib/canonical/launchpad/pagetitles.py'
--- lib/canonical/launchpad/pagetitles.py 2010-12-15 22:05:43 +0000
+++ lib/canonical/launchpad/pagetitles.py 2011-01-04 16:38:41 +0000
@@ -285,6 +285,20 @@
person_translations_to_review = ContextDisplayName(
'Translations for review by %s')
+poll_edit = ContextTitle(smartquote('Edit poll "%s"'))
+
+poll_index = ContextTitle(smartquote('Poll: "%s"'))
+
+poll_newoption = ContextTitle(smartquote('New option for poll "%s"'))
+
+def polloption_edit(context, view):
+ """Return the page title to edit a poll's option."""
+ return 'Edit option: %s' % context.title
+
+poll_vote_condorcet = ContextTitle(smartquote('Vote in poll "%s"'))
+
+poll_vote_simple = ContextTitle(smartquote('Vote in poll "%s"'))
+
product_cvereport = ContextTitle('CVE reports for %s')
product_index = ContextTitle('%s in Launchpad')
=== modified file 'lib/canonical/launchpad/security.py'
--- lib/canonical/launchpad/security.py 2010-12-22 12:05:12 +0000
+++ lib/canonical/launchpad/security.py 2011-01-04 16:38:41 +0000
@@ -130,6 +130,11 @@
PersonVisibility,
)
from lp.registry.interfaces.pillar import IPillar
+from lp.registry.interfaces.poll import (
+ IPoll,
+ IPollOption,
+ IPollSubset,
+ )
from lp.registry.interfaces.product import (
IProduct,
IProductSet,
@@ -858,6 +863,26 @@
return False
+class EditPollByTeamOwnerOrTeamAdminsOrAdmins(
+ EditTeamMembershipByTeamOwnerOrTeamAdminsOrAdmins):
+ permission = 'launchpad.Edit'
+ usedfor = IPoll
+
+
+class EditPollSubsetByTeamOwnerOrTeamAdminsOrAdmins(
+ EditPollByTeamOwnerOrTeamAdminsOrAdmins):
+ permission = 'launchpad.Edit'
+ usedfor = IPollSubset
+
+
+class EditPollOptionByTeamOwnerOrTeamAdminsOrAdmins(AuthorizationBase):
+ permission = 'launchpad.Edit'
+ usedfor = IPollOption
+
+ def checkAuthenticated(self, user):
+ return can_edit_team(self.obj.poll.team, user)
+
+
class AdminDistribution(AdminByAdminsTeam):
"""Soyuz involves huge chunks of data in the archive and librarian,
so for the moment we are locking down admin and edit on distributions
=== added file 'lib/canonical/launchpad/tests/test_poll.py'
--- lib/canonical/launchpad/tests/test_poll.py 1970-01-01 00:00:00 +0000
+++ lib/canonical/launchpad/tests/test_poll.py 2011-01-04 16:38:41 +0000
@@ -0,0 +1,34 @@
+# Copyright 2009 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from datetime import (
+ datetime,
+ timedelta,
+ )
+import unittest
+
+import pytz
+
+from canonical.launchpad.ftests import login
+from canonical.testing.layers import LaunchpadFunctionalLayer
+from lp.testing import TestCaseWithFactory
+
+
+class TestPoll(TestCaseWithFactory):
+ layer = LaunchpadFunctionalLayer
+
+ def test_getWinners_handle_polls_with_only_spoilt_votes(self):
+ login('mark@xxxxxxxxxxx')
+ owner = self.factory.makePerson()
+ team = self.factory.makeTeam(owner)
+ poll = self.factory.makePoll(team, 'name', 'title', 'proposition')
+ # Force opening of poll so that we can vote.
+ poll.dateopens = datetime.now(pytz.UTC) - timedelta(minutes=2)
+ poll.storeSimpleVote(owner, None)
+ # Force closing of the poll so that we can call getWinners().
+ poll.datecloses = datetime.now(pytz.UTC)
+ self.failUnless(poll.getWinners() is None, poll.getWinners())
+
+
+def test_suite():
+ return unittest.TestLoader().loadTestsFromName(__name__)
=== modified file 'lib/lp/registry/adapters.py'
--- lib/lp/registry/adapters.py 2010-12-15 22:05:43 +0000
+++ lib/lp/registry/adapters.py 2011-01-04 16:38:41 +0000
@@ -6,15 +6,24 @@
__metaclass__ = type
__all__ = [
- 'distroseries_to_distribution',
- 'person_from_principal',
+ 'distroseries_to_launchpadusage',
+ 'distroseries_to_serviceusage',
+ 'PollSubset',
'productseries_to_product',
]
+from zope.component import getUtility
from zope.component.interfaces import ComponentLookupError
+from zope.interface import implements
from canonical.launchpad.webapp.interfaces import ILaunchpadPrincipal
+from lp.registry.interfaces.poll import (
+ IPollSet,
+ IPollSubset,
+ PollAlgorithm,
+ PollStatus,
+ )
def distroseries_to_distribution(distroseries):
@@ -42,6 +51,60 @@
raise ComponentLookupError
+class PollSubset:
+ """Adapt an `IPoll` to an `IPollSubset`."""
+ implements(IPollSubset)
+
+ title = 'Team polls'
+
+ def __init__(self, team=None):
+ self.team = team
+
+ def new(self, name, title, proposition, dateopens, datecloses,
+ secrecy, allowspoilt, poll_type=PollAlgorithm.SIMPLE):
+ """See IPollSubset."""
+ assert self.team is not None, (
+ 'team cannot be None to call this method.')
+ return getUtility(IPollSet).new(
+ self.team, name, title, proposition, dateopens,
+ datecloses, secrecy, allowspoilt, poll_type)
+
+ def getByName(self, name, default=None):
+ """See IPollSubset."""
+ assert self.team is not None, (
+ 'team cannot be None to call this method.')
+ pollset = getUtility(IPollSet)
+ return pollset.getByTeamAndName(self.team, name, default)
+
+ def getAll(self):
+ """See IPollSubset."""
+ assert self.team is not None, (
+ 'team cannot be None to call this method.')
+ return getUtility(IPollSet).selectByTeam(self.team)
+
+ def getOpenPolls(self, when=None):
+ """See IPollSubset."""
+ assert self.team is not None, (
+ 'team cannot be None to call this method.')
+ return getUtility(IPollSet).selectByTeam(
+ self.team, [PollStatus.OPEN], orderBy='datecloses', 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).selectByTeam(
+ self.team, [PollStatus.CLOSED], orderBy='datecloses', when=when)
+
+ def getNotYetOpenedPolls(self, when=None):
+ """See IPollSubset."""
+ assert self.team is not None, (
+ 'team cannot be None to call this method.')
+ return getUtility(IPollSet).selectByTeam(
+ self.team, [PollStatus.NOT_YET_OPENED],
+ orderBy='dateopens', when=when)
+
+
def productseries_to_product(productseries):
"""Adapts `IProductSeries` object to `IProduct`.
=== modified file 'lib/lp/registry/browser/configure.zcml'
--- lib/lp/registry/browser/configure.zcml 2010-12-23 12:53:49 +0000
+++ lib/lp/registry/browser/configure.zcml 2011-01-04 16:38:41 +0000
@@ -600,6 +600,75 @@
name="karmacontext-macros"
template="../templates/karmacontext-macros.pt"/>
</facet>
+ <facet
+ facet="overview">
+ <browser:menus
+ module="lp.registry.browser.poll"
+ classes="
+ PollOverviewMenu
+ PollActionNavigationMenu
+ PollEditNavigationMenu"/>
+ <browser:defaultView
+ for="lp.registry.interfaces.poll.IPoll"
+ name="+index"/>
+ <browser:navigation
+ module="lp.registry.browser.poll"
+ classes="
+ PollNavigation"/>
+ <browser:url
+ for="lp.registry.interfaces.poll.IPoll"
+ path_expression="string:+poll/${name}"
+ attribute_to_parent="team"/>
+ <browser:pages
+ for="lp.registry.interfaces.poll.IPoll"
+ permission="zope.Public"
+ class="lp.registry.browser.poll.PollView">
+ <browser:page
+ name="+index"
+ template="../templates/poll-index.pt"/>
+ </browser:pages>
+ <browser:pages
+ for="lp.registry.interfaces.poll.IPoll"
+ permission="zope.Public"
+ class="lp.registry.browser.poll.BasePollView">
+ <browser:page
+ name="+portlet-details"
+ template="../templates/poll-portlet-details.pt"/>
+ <browser:page
+ name="+portlet-options"
+ template="../templates/poll-portlet-options.pt"/>
+ </browser:pages>
+ <browser:page
+ name="+vote"
+ for="lp.registry.interfaces.poll.IPoll"
+ permission="launchpad.AnyPerson"
+ class="lp.registry.browser.poll.PollVoteView"/>
+ <browser:page
+ name="+edit"
+ for="lp.registry.interfaces.poll.IPoll"
+ class="lp.registry.browser.poll.PollEditView"
+ permission="launchpad.Edit"
+ template="../templates/poll-edit.pt"/>
+ <browser:page
+ name="+newoption"
+ for="lp.registry.interfaces.poll.IPoll"
+ class="lp.registry.browser.poll.PollOptionAddView"
+ permission="launchpad.Edit"
+ template="../templates/poll-newoption.pt"/>
+ <browser:defaultView
+ for="lp.registry.interfaces.poll.IPollOption"
+ name="+edit"/>
+ <browser:url
+ for="lp.registry.interfaces.poll.IPollOption"
+ path_expression="string:+option/${id}"
+ attribute_to_parent="poll"/>
+ <browser:page
+ name="+edit"
+ for="lp.registry.interfaces.poll.IPollOption"
+ class="lp.registry.browser.poll.PollOptionEditView"
+ permission="launchpad.Edit"
+ template="../templates/polloption-edit.pt"/>
+ </facet>
<browser:url
for="lp.registry.interfaces.announcement.IAnnouncement"
path_expression="string:+announcement/${id}"
@@ -1011,6 +1080,12 @@
template="../templates/team-index.pt"/>
<browser:page
for="lp.registry.interfaces.person.ITeam"
+ class="lp.registry.browser.person.TeamIndexView"
+ permission="zope.Public"
+ name="+portlet-polls"
+ template="../templates/team-portlet-polls.pt"/>
+ <browser:page
+ for="lp.registry.interfaces.person.ITeam"
permission="zope.Public"
class="lp.registry.browser.team.TeamMapView"
name="+map"
@@ -1123,6 +1198,12 @@
name="+polls"
template="../templates/team-polls.pt"/>
<browser:page
+ name="+newpoll"
+ for="lp.registry.interfaces.person.ITeam"
+ class="canonical.launchpad.browser.PollAddView"
+ permission="launchpad.Edit"
+ template="../templates/team-newpoll.pt"/>
+ <browser:page
name="+members"
for="lp.registry.interfaces.person.ITeam"
permission="zope.Public"
=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py 2010-12-20 15:06:51 +0000
+++ lib/lp/registry/browser/person.py 2011-01-04 16:38:41 +0000
@@ -284,6 +284,10 @@
)
from lp.registry.interfaces.personproduct import IPersonProductFactory
from lp.registry.interfaces.pillar import IPillarNameSet
+from lp.registry.interfaces.poll import (
+ IPollSet,
+ IPollSubset,
+ )
from lp.registry.interfaces.product import IProduct
from lp.registry.interfaces.ssh import (
ISSHKeySet,
@@ -557,6 +561,10 @@
usedfor = ITeam
+ @stepthrough('+poll')
+ def traverse_poll(self, name):
+ return getUtility(IPollSet).getByTeamAndName(self.context, name)
+
@stepthrough('+invitation')
def traverse_invitation(self, name):
# Return the found membership regardless of its status as we know
@@ -1325,6 +1333,17 @@
text = 'Show member photos'
return Link(target, text, icon='team')
+ def polls(self):
+ target = '+polls'
+ text = 'Show polls'
+ return Link(target, text, icon='info')
+
+ @enabled_with_permission('launchpad.Edit')
+ def add_poll(self):
+ target = '+newpoll'
+ text = 'Create a poll'
+ return Link(target, text, icon='add')
+
@enabled_with_permission('launchpad.Edit')
def editemail(self):
target = '+contactaddress'
@@ -1409,6 +1428,8 @@
'moderate_mailing_list',
'editlanguages',
'map',
+ 'polls',
+ 'add_poll',
'join',
'leave',
'add_my_teams',
@@ -1429,7 +1450,7 @@
usedfor = ITeam
facet = 'overview'
- links = ['profile', 'members', 'ppas']
+ links = ['profile', 'polls', 'members', 'ppas']
class TeamMembershipView(LaunchpadView):
@@ -2919,6 +2940,21 @@
return ''
@cachedproperty
+ def openpolls(self):
+ assert self.context.isTeam()
+ return IPollSubset(self.context).getOpenPolls()
+
+ @cachedproperty
+ def closedpolls(self):
+ assert self.context.isTeam()
+ return IPollSubset(self.context).getClosedPolls()
+
+ @cachedproperty
+ def notyetopenedpolls(self):
+ assert self.context.isTeam()
+ return IPollSubset(self.context).getNotYetOpenedPolls()
+
+ @cachedproperty
def contributions(self):
"""Cache the results of getProjectsAndCategoriesContributedTo()."""
return self.context.getProjectsAndCategoriesContributedTo(
@@ -3087,6 +3123,19 @@
else:
raise AssertionError('Unknown group to contact.')
+ @property
+ def should_show_polls_portlet(self):
+ menu = TeamOverviewMenu(self.context)
+ return (
+ self.has_current_polls or self.closedpolls
+ or menu.add_poll().enabled)
+
+ @property
+ def has_current_polls(self):
+ """Return True if this team has any non-closed polls."""
+ assert self.context.isTeam()
+ return bool(self.openpolls) or bool(self.notyetopenedpolls)
+
def userIsOwner(self):
"""Return True if the user is the owner of this Team."""
if self.user is None:
=== added file 'lib/lp/registry/browser/poll.py'
--- lib/lp/registry/browser/poll.py 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/browser/poll.py 2011-01-04 16:38:41 +0000
@@ -0,0 +1,469 @@
+# Copyright 2009 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+__all__ = [
+ 'BasePollView',
+ 'PollAddView',
+ 'PollEditNavigationMenu',
+ 'PollEditView',
+ 'PollNavigation',
+ 'PollOptionAddView',
+ 'PollOptionEditView',
+ 'PollOverviewMenu',
+ 'PollView',
+ 'PollVoteView',
+ 'PollBreadcrumb',
+ ]
+
+from z3c.ptcompat import ViewPageTemplateFile
+from zope.app.form.browser import TextWidget
+from zope.component import getUtility
+from zope.event import notify
+from zope.interface import (
+ implements,
+ Interface,
+ )
+from zope.lifecycleevent import ObjectCreatedEvent
+
+from canonical.launchpad.helpers import shortlist
+from canonical.launchpad.webapp import (
+ ApplicationMenu,
+ canonical_url,
+ enabled_with_permission,
+ LaunchpadView,
+ Link,
+ Navigation,
+ NavigationMenu,
+ stepthrough,
+ )
+from canonical.launchpad.webapp.breadcrumb import TitleBreadcrumb
+from lp.app.browser.launchpadform import (
+ action,
+ custom_widget,
+ LaunchpadEditFormView,
+ LaunchpadFormView,
+ )
+from lp.registry.interfaces.poll import (
+ IPoll,
+ IPollOption,
+ IPollOptionSet,
+ IPollSubset,
+ IVoteSet,
+ PollAlgorithm,
+ PollSecrecy,
+ )
+
+
+class PollEditLinksMixin:
+
+ @enabled_with_permission('launchpad.Edit')
+ def addnew(self):
+ text = 'Add new option'
+ return Link('+newoption', text, icon='add')
+
+ @enabled_with_permission('launchpad.Edit')
+ def edit(self):
+ text = 'Change details'
+ return Link('+edit', text, icon='edit')
+
+
+class PollOverviewMenu(ApplicationMenu, PollEditLinksMixin):
+ usedfor = IPoll
+ facet = 'overview'
+ links = ['addnew']
+
+
+class IPollEditMenu(Interface):
+ """A marker interface for the edit navigation menu."""
+
+
+class PollEditNavigationMenu(NavigationMenu, PollEditLinksMixin):
+ usedfor = IPollEditMenu
+ facet = 'overview'
+ links = ['addnew', 'edit']
+
+
+class IPollActionMenu(Interface):
+ """A marker interface for the action menu."""
+
+
+class PollActionNavigationMenu(PollEditNavigationMenu):
+ usedfor = IPollActionMenu
+ links = ['edit']
+
+
+class PollNavigation(Navigation):
+
+ usedfor = IPoll
+
+ @stepthrough('+option')
+ def traverse_option(self, name):
+ return getUtility(IPollOptionSet).getByPollAndId(
+ self.context, int(name))
+
+
+class BasePollView(LaunchpadView):
+ """A base view class to be used in other poll views."""
+
+ token = None
+ gotTokenAndVotes = False
+ feedback = ""
+
+ def setUpTokenAndVotes(self):
+ """Set up the token and votes to be displayed."""
+ if not self.userVoted():
+ return
+
+ # For secret polls we can only display the votes after the token
+ # is submitted.
+ if self.request.method == 'POST' and self.isSecret():
+ self.setUpTokenAndVotesForSecretPolls()
+ elif not self.isSecret():
+ self.setUpTokenAndVotesForNonSecretPolls()
+
+ def setUpTokenAndVotesForNonSecretPolls(self):
+ """Get the votes of the logged in user in this poll.
+
+ Set the votes in instance variables and also set self.gotTokenAndVotes
+ to True, so the templates know they can display the vote.
+
+ This method should be used only on non-secret polls and if the logged
+ in user has voted on this poll.
+ """
+ assert not self.isSecret() and self.userVoted()
+ votes = self.context.getVotesByPerson(self.user)
+ assert votes, (
+ "User %r hasn't voted on poll %r" % (self.user, self.context))
+ if self.isSimple():
+ # Here we have only one vote.
+ self.currentVote = votes[0]
+ self.token = self.currentVote.token
+ elif self.isCondorcet():
+ # Here we have multiple votes, and the token is the same in
+ # all of them.
+ self.currentVotes = sorted(votes, key=lambda v: v.preference)
+ self.token = self.currentVotes[0].token
+ self.gotTokenAndVotes = True
+
+ def setUpTokenAndVotesForSecretPolls(self):
+ """Get the votes with the token provided in the form.
+
+ Set the votes, together with the token in instance variables. Also
+ set self.gotTokenAndVotes to True, so the templates know they can
+ display the vote.
+
+ Return True if there's any vote with the given token and the votes
+ are on this poll.
+
+ This method should be used only on secret polls and if the logged
+ in user has voted on this poll.
+ """
+ assert self.isSecret() and self.userVoted()
+ token = self.request.form.get('token')
+ # Only overwrite self.token if the request contains a 'token'
+ # variable.
+ if token is not None:
+ self.token = token
+ votes = getUtility(IVoteSet).getByToken(self.token)
+ if not votes:
+ self.feedback = ("There's no vote associated with the token %s"
+ % self.token)
+ return False
+
+ # All votes with a given token must be on the same poll. That means
+ # checking the poll of the first vote is enough.
+ if votes[0].poll != self.context:
+ self.feedback = ("The vote associated with the token %s is not "
+ "a vote on this poll." % self.token)
+ return False
+
+ if self.isSimple():
+ # A simple poll has only one vote, because you can choose only one
+ # option.
+ self.currentVote = votes[0]
+ elif self.isCondorcet():
+ self.currentVotes = sorted(votes, key=lambda v: v.preference)
+ self.gotTokenAndVotes = True
+ return True
+
+ def userCanVote(self):
+ """Return True if the user is/was eligible to vote on this poll."""
+ return (self.user and self.user.inTeam(self.context.team))
+
+ def userVoted(self):
+ """Return True if the user voted on this poll."""
+ return (self.user and self.context.personVoted(self.user))
+
+ def isCondorcet(self):
+ """Return True if this poll's type is Condorcet."""
+ return self.context.type == PollAlgorithm.CONDORCET
+
+ def isSimple(self):
+ """Return True if this poll's type is Simple."""
+ return self.context.type == PollAlgorithm.SIMPLE
+
+ def isSecret(self):
+ """Return True if this is a secret poll."""
+ return self.context.secrecy == PollSecrecy.SECRET
+
+
+class PollBreadcrumb(TitleBreadcrumb):
+ """Breadcrumb for polls."""
+
+
+class PollView(BasePollView):
+ """A view class to display the results of a poll."""
+ implements(IPollActionMenu)
+
+ def initialize(self):
+ super(PollView, self).initialize()
+ request = self.request
+ if (self.userCanVote() and self.context.isOpen() and
+ self.context.getActiveOptions()):
+ vote_url = canonical_url(self.context, view_name='+vote')
+ request.response.redirect(vote_url)
+
+ def getVotesByOption(self, option):
+ """Return the number of votes the given option received."""
+ return getUtility(IVoteSet).getVotesByOption(option)
+
+ def getPairwiseMatrixWithHeaders(self):
+ """Return the pairwise matrix, with headers being the option's
+ names.
+ """
+ # XXX: kiko 2006-03-13:
+ # The list() call here is necessary because, lo and behold,
+ # it gives us a non-security-proxied list object! Someone come
+ # in and fix this!
+ pairwise_matrix = list(self.context.getPairwiseMatrix())
+ headers = [None]
+ for idx, option in enumerate(self.context.getAllOptions()):
+ headers.append(option.title)
+ # Get a mutable row.
+ row = list(pairwise_matrix[idx])
+ row.insert(0, option.title)
+ pairwise_matrix[idx] = row
+ pairwise_matrix.insert(0, headers)
+ return pairwise_matrix
+
+
+class PollVoteView(BasePollView):
+ """A view class to where the user can vote on a poll.
+
+ If the user already voted, the current vote is displayed and the user can
+ change it. Otherwise he can register his vote.
+ """
+
+ default_template = ViewPageTemplateFile(
+ '../templates/poll-vote-simple.pt')
+ condorcet_template = ViewPageTemplateFile(
+ '../templates/poll-vote-condorcet.pt')
+
+ @property
+ def template(self):
+ if self.isCondorcet():
+ return self.condorcet_template
+ else:
+ return self.default_template
+
+ def initialize(self):
+ """Process the form, if it was submitted."""
+ super(PollVoteView, self).initialize()
+ if not self.isSecret() and self.userVoted():
+ # For non-secret polls, the user's vote is always displayed
+ self.setUpTokenAndVotesForNonSecretPolls()
+
+ if self.request.method != 'POST':
+ return
+
+ if self.isSecret() and self.userVoted():
+ if not self.setUpTokenAndVotesForSecretPolls():
+ # Not possible to get the votes. Probably the token was wrong.
+ return
+
+ if 'showvote' in self.request.form:
+ # The user only wants to see the vote.
+ return
+
+ if not self.context.isOpen():
+ self.feedback = "This poll is not open."
+ return
+
+ if self.isSimple():
+ self.processSimpleVotingForm()
+ else:
+ self.processCondorcetVotingForm()
+
+ # User may have voted, so we need to setup the vote to display again.
+ self.setUpTokenAndVotes()
+
+ def processSimpleVotingForm(self):
+ """Process the simple-voting form to change a user's vote or register
+ a new one.
+
+ This method must not be called if the poll is not open.
+ """
+ assert self.context.isOpen()
+ context = self.context
+ newoption_id = self.request.form.get('newoption')
+ if newoption_id == 'donotchange':
+ self.feedback = "Your vote was not changed."
+ return
+ elif newoption_id == 'donotvote':
+ self.feedback = "You chose not to vote yet."
+ return
+ elif newoption_id == 'none':
+ newoption = None
+ else:
+ newoption = getUtility(IPollOptionSet).getByPollAndId(
+ context, int(newoption_id))
+
+ if self.userVoted():
+ self.currentVote.option = newoption
+ self.feedback = "Your vote was changed successfully."
+ else:
+ self.currentVote = context.storeSimpleVote(self.user, newoption)
+ self.token = self.currentVote.token
+ self.currentVote = self.currentVote
+ if self.isSecret():
+ self.feedback = (
+ "Your vote has been recorded. If you want to view or "
+ "change it later you must write down this key: %s"
+ % self.token)
+ else:
+ self.feedback = (
+ "Your vote was stored successfully. You can come back to "
+ "this page at any time before this poll closes to view "
+ "or change your vote, if you want.")
+
+ def processCondorcetVotingForm(self):
+ """Process the condorcet-voting form to change a user's vote or
+ register a new one.
+
+ This method must not be called if the poll is not open.
+ """
+ assert self.context.isOpen()
+ form = self.request.form
+ activeoptions = shortlist(self.context.getActiveOptions())
+ newvotes = {}
+ for option in activeoptions:
+ try:
+ preference = int(form.get('option_%d' % option.id))
+ except ValueError:
+ # XXX: Guilherme Salgado 2005-09-14:
+ # User tried to specify a value which we can't convert to
+ # an integer. Better thing to do would be to notify the user
+ # and ask him to fix it.
+ preference = None
+ newvotes[option] = preference
+
+ if self.userVoted():
+ # This is a vote change.
+ # For now it's not possible to have votes in an inactive option,
+ # but it'll be in the future as we'll allow people to make options
+ # inactive after a poll opens.
+ assert len(activeoptions) == len(self.currentVotes)
+ for vote in self.currentVotes:
+ vote.preference = newvotes.get(vote.option)
+ self.currentVotes.sort(key=lambda v: v.preference)
+ self.feedback = "Your vote was changed successfully."
+ else:
+ # This is a new vote.
+ votes = self.context.storeCondorcetVote(self.user, newvotes)
+ self.token = votes[0].token
+ self.currentVotes = sorted(votes, key=lambda v: v.preference)
+ if self.isSecret():
+ self.feedback = (
+ "Your vote has been recorded. If you want to view or "
+ "change it later you must write down this key: %s"
+ % self.token)
+ else:
+ self.feedback = (
+ "Your vote was stored successfully. You can come back to "
+ "this page at any time before this poll closes to view "
+ "or change your vote, if you want.")
+
+
+class PollAddView(LaunchpadFormView):
+ """The view class to create a new poll in a given team."""
+
+ schema = IPoll
+ field_names = ["name", "title", "proposition", "allowspoilt", "dateopens",
+ "datecloses"]
+
+ @property
+ def cancel_url(self):
+ """See `LaunchpadFormView`."""
+ return canonical_url(self.context)
+
+ @action("Continue", name="continue")
+ def continue_action(self, action, data):
+ # XXX: salgado, 2008-10-08: Only secret polls can be created until we
+ # fix https://launchpad.net/bugs/80596.
+ secrecy = PollSecrecy.SECRET
+ poll = IPollSubset(self.context).new(
+ data['name'], data['title'], data['proposition'],
+ data['dateopens'], data['datecloses'], secrecy,
+ data['allowspoilt'])
+ self.next_url = canonical_url(poll)
+ notify(ObjectCreatedEvent(poll))
+
+
+class PollEditView(LaunchpadEditFormView):
+
+ implements(IPollEditMenu)
+ schema = IPoll
+ label = "Edit poll details"
+ field_names = ["name", "title", "proposition", "allowspoilt", "dateopens",
+ "datecloses"]
+
+ @property
+ def cancel_url(self):
+ """See `LaunchpadFormView`."""
+ return canonical_url(self.context)
+
+ @action("Save", name="save")
+ def save_action(self, action, data):
+ self.updateContextFromData(data)
+ self.next_url = canonical_url(self.context)
+
+
+class PollOptionEditView(LaunchpadEditFormView):
+ """Edit one of a poll's options."""
+
+ schema = IPollOption
+ label = "Edit option details"
+ field_names = ["name", "title"]
+ custom_widget("title", TextWidget, width=30)
+
+ @property
+ def cancel_url(self):
+ """See `LaunchpadFormView`."""
+ return canonical_url(self.context.poll)
+
+ @action("Save", name="save")
+ def save_action(self, action, data):
+ self.updateContextFromData(data)
+ self.next_url = canonical_url(self.context.poll)
+
+
+class PollOptionAddView(LaunchpadFormView):
+ """Create a new option in a given poll."""
+
+ schema = IPollOption
+ label = "Create new poll option"
+ field_names = ["name", "title"]
+ custom_widget("title", TextWidget, width=30)
+
+ @property
+ def cancel_url(self):
+ """See `LaunchpadFormView`."""
+ return canonical_url(self.context)
+
+ @action("Create", name="create")
+ def create_action(self, action, data):
+ polloption = self.context.newOption(data['name'], data['title'])
+ self.next_url = canonical_url(self.context)
+ notify(ObjectCreatedEvent(polloption))
=== added file 'lib/lp/registry/browser/tests/poll-views.txt'
--- lib/lp/registry/browser/tests/poll-views.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/browser/tests/poll-views.txt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,134 @@
+Poll views
+----------
+
+The polls portlet shows the state of current polls, and links to past
+polls.
+
+ >>> from canonical.launchpad.testing.pages import extract_text
+
+ >>> user = factory.makePerson()
+ >>> team = factory.makeTeam(name='team')
+ >>> owner = team.teamowner
+
+ >>> def create_team_view(team, name=None, principal=None):
+ ... # XRDS inheritance requires a lot of setup.
+ ... path_info = '/~%s' % team.name
+ ... server_url = 'http://launchpad.dev'
+ ... view = create_view(
+ ... team, name=name, principal=principal,
+ ... server_url=server_url, path_info=path_info)
+ ... view.initialize()
+ ... return view
+
+The portlet does not render any markup when there are no polls...
+
+ >>> login_person(user)
+ >>> view = create_team_view(team, name='+portlet-polls', principal=user)
+ >>> view.has_current_polls
+ False
+
+ >>> view.should_show_polls_portlet
+ False
+
+ >>> print extract_text(view.render())
+ <BLANKLINE>
+
+Unless the user is a team owner.
+
+ >>> login_person(owner)
+ >>> view = create_team_view(team, name='+portlet-polls', principal=owner)
+ >>> view.has_current_polls
+ False
+
+ >>> view.should_show_polls_portlet
+ True
+
+ >>> print extract_text(view.render())
+ Polls
+ No current polls.
+ Show polls
+ Create a poll
+
+The portlet shows a link to polls to all users when there is a poll, but it
+has not opened.
+
+ >>> import pytz
+ >>> from datetime import datetime, timedelta
+ >>> from lp.registry.interfaces.poll import IPollSubset, PollSecrecy
+
+ >>> open_date = datetime.now().replace(tzinfo=pytz.timezone('UTC'))
+ >>> close_date = open_date + timedelta(weeks=1)
+ >>> poll_subset = IPollSubset(team)
+ >>> poll = poll_subset.new(
+ ... 'name', 'title', 'proposition', open_date, close_date,
+ ... PollSecrecy.OPEN, False)
+
+ >>> login_person(user)
+ >>> view = create_team_view(team, name='+portlet-polls', principal=user)
+ >>> view.has_current_polls
+ True
+
+ >>> view.should_show_polls_portlet
+ True
+
+ >>> print extract_text(view.render())
+ Polls
+ Show polls
+
+The portlet shows more details to the poll owner.
+
+ >>> login_person(owner)
+ >>> view = create_team_view(team, name='+portlet-polls', principal=owner)
+ >>> view.has_current_polls
+ True
+
+ >>> view.should_show_polls_portlet
+ True
+
+ >>> print extract_text(view.render())
+ Polls
+ title - opens in 5 hours
+ Show polls
+ Create a poll
+
+Current polls are listed in the portlet, the only difference between a user
+and an owner is the owner has a link to create more polls.
+
+ >>> poll.dateopens = open_date - timedelta(weeks=2)
+
+ >>> login_person(user)
+ >>> view = create_team_view(team, name='+portlet-polls', principal=user)
+ >>> print extract_text(view.render())
+ Polls
+ title - closes on ...
+ You have seven days left to vote in this poll.
+ Show polls
+
+ >>> login_person(owner)
+ >>> view = create_team_view(team, name='+portlet-polls', principal=owner)
+ >>> print extract_text(view.render())
+ Polls
+ title - closes on ...
+ You have seven days left to vote in this poll.
+ Show polls
+ Create a poll
+
+When all the polls are closed, the portlet states the case and has a link to
+see the polls.
+
+ >>> poll.datecloses = close_date - timedelta(weeks=2)
+
+ >>> login_person(user)
+ >>> view = create_team_view(team, name='+portlet-polls', principal=user)
+ >>> print extract_text(view.render())
+ Polls
+ No current polls.
+ Show polls
+
+ >>> login_person(owner)
+ >>> view = create_team_view(team, name='+portlet-polls', principal=owner)
+ >>> print extract_text(view.render())
+ Polls
+ No current polls.
+ Show polls
+ Create a poll
=== added file 'lib/lp/registry/browser/tests/poll-views_0.txt'
--- lib/lp/registry/browser/tests/poll-views_0.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/browser/tests/poll-views_0.txt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,92 @@
+= Poll Pages =
+
+First import some stuff and setup some things we'll use in this test.
+
+ >>> from zope.component import getUtility, getMultiAdapter
+ >>> from zope.publisher.browser import TestRequest
+ >>> from canonical.launchpad.webapp.servers import LaunchpadTestRequest
+ >>> from lp.registry.interfaces.person import IPersonSet
+ >>> from lp.registry.interfaces.poll import IPollSet
+ >>> from datetime import datetime, timedelta
+ >>> login("test@xxxxxxxxxxxxx")
+ >>> ubuntu_team = getUtility(IPersonSet).getByName('ubuntu-team')
+
+
+== Creating new polls ==
+
+When creating a new poll, its start date must be at least 12h after it is
+created.
+
+First we attempt to create a poll which starts 11h from now. That will fail
+with a proper explanation of why it failed.
+
+ >>> eleven_hours_from_now = datetime.now() + timedelta(hours=11)
+ >>> eleven_hours_from_now = eleven_hours_from_now.strftime(
+ ... '%Y-%m-%d %H:%M:%S')
+ >>> form = {
+ ... 'field.name': 'test-poll',
+ ... 'field.title': 'test-poll',
+ ... 'field.proposition': 'test-poll',
+ ... 'field.allowspoilt': '1',
+ ... 'field.secrecy': 'SECRET',
+ ... 'field.dateopens': eleven_hours_from_now,
+ ... 'field.datecloses': '2025-06-04',
+ ... 'field.actions.continue': 'Continue'}
+ >>> request = LaunchpadTestRequest(method='POST', form=form)
+ >>> new_poll = getMultiAdapter((ubuntu_team, request), name="+newpoll")
+ >>> new_poll.initialize()
+ >>> print "\n".join(new_poll.errors)
+ A poll cannot open less than 12 hours after it's created.
+
+Now we successfully create a poll which starts 12h from now.
+
+ >>> twelve_hours_from_now = datetime.now() + timedelta(hours=12)
+ >>> twelve_hours_from_now = twelve_hours_from_now.strftime(
+ ... '%Y-%m-%d %H:%M:%S')
+ >>> form['field.dateopens'] = twelve_hours_from_now
+ >>> request = LaunchpadTestRequest(method='POST', form=form)
+ >>> new_poll = getMultiAdapter((ubuntu_team, request), name="+newpoll")
+ >>> new_poll.initialize()
+ >>> new_poll.errors
+ []
+
+
+== Displaying results of condorcet polls ==
+
+ >>> poll = getUtility(IPollSet).getByTeamAndName(ubuntu_team, 'director-2004')
+ >>> poll.type.title
+ 'Condorcet Voting'
+
+Although condorcet polls are disabled now, everything is implemented and we're
+using a pairwise matrix to display the results. It's very trick to create this
+matrix on page templates, so the view provides a method wich return this
+matrix as a python list, with the necessary headers (the option's names).
+
+ >>> poll_results = getMultiAdapter((poll, TestRequest()), name="+index")
+ >>> for row in poll_results.getPairwiseMatrixWithHeaders():
+ ... print row
+ [None, u'A', u'B', u'C', u'D']
+ [u'A', None, 2L, 2L, 2L]
+ [u'B', 2L, None, 2L, 2L]
+ [u'C', 1L, 1L, None, 1L]
+ [u'D', 2L, 1L, 2L, None]
+
+== Voting on closed polls ==
+
+This is not allowed, and apart from not linking to the +vote page and not
+even displaying its content for a closed poll, we also have some lower
+level checks.
+
+ >>> request = TestRequest(form={'changevote': 'Change Vote'})
+ >>> request.method = 'POST'
+ >>> voting_page = getMultiAdapter((poll, request), name="+vote")
+ >>> form_processed = False
+ >>> def form_processing():
+ ... form_processed = True
+ >>> voting_page.processCondorcetVotingForm = form_processing
+ >>> voting_page.initialize()
+
+ >>> form_processed
+ False
+ >>> voting_page.feedback
+ 'This poll is not open.'
=== modified file 'lib/lp/registry/browser/tests/test_breadcrumbs.py'
--- lib/lp/registry/browser/tests/test_breadcrumbs.py 2010-12-24 14:50:12 +0000
+++ lib/lp/registry/browser/tests/test_breadcrumbs.py 2011-01-04 16:38:41 +0000
@@ -105,6 +105,26 @@
self.assertEqual(self.milestone.name, last_crumb.text)
+class TestPollBreadcrumb(BaseBreadcrumbTestCase):
+ """Test breadcrumbs for an `IPoll`."""
+
+ def setUp(self):
+ super(TestPollBreadcrumb, self).setUp()
+ self.team = self.factory.makeTeam(displayname="Poll Team")
+ name = "pollo-poll"
+ title = "Marco Pollo"
+ proposition = "Be mine"
+ self.poll = self.factory.makePoll(
+ team=self.team,
+ name=name,
+ title=title,
+ proposition=proposition)
+
+ def test_poll(self):
+ crumbs = self.getBreadcrumbsForObject(self.poll)
+ last_crumb = crumbs[-1]
+ self.assertEqual(self.poll.title, last_crumb.text)
+
from lp.registry.interfaces.nameblacklist import INameBlacklistSet
=== added file 'lib/lp/registry/browser/tests/test_poll.py'
--- lib/lp/registry/browser/tests/test_poll.py 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/browser/tests/test_poll.py 2011-01-04 16:38:41 +0000
@@ -0,0 +1,38 @@
+# Copyright 2010 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for IPoll views."""
+
+__metaclass__ = type
+
+import os
+from canonical.testing.layers import DatabaseFunctionalLayer
+from lp.registry.interfaces.poll import PollAlgorithm
+from lp.testing import TestCaseWithFactory
+from lp.testing.views import create_view
+
+
+class TestPollVoteView(TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(TestPollVoteView, self).setUp()
+ self.team = self.factory.makeTeam()
+
+ def test_simple_poll_template(self):
+ poll = self.factory.makePoll(
+ self.team, 'name', 'title', 'proposition',
+ poll_type=PollAlgorithm.SIMPLE)
+ view = create_view(poll, name='+vote')
+ self.assertEqual(
+ 'poll-vote-simple.pt', os.path.basename(view.template.filename))
+
+ def test_condorcet_poll_template(self):
+ poll = self.factory.makePoll(
+ self.team, 'name', 'title', 'proposition',
+ poll_type=PollAlgorithm.CONDORCET)
+ view = create_view(poll, name='+vote')
+ self.assertEqual(
+ 'poll-vote-condorcet.pt',
+ os.path.basename(view.template.filename))
=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml 2010-12-23 02:04:07 +0000
+++ lib/lp/registry/configure.zcml 2011-01-04 16:38:41 +0000
@@ -689,6 +689,109 @@
interface="lp.registry.interfaces.karma.IKarmaActionSet"/>
</securedutility>
</facet>
+ <facet
+ facet="overview">
+
+ <!-- Poll -->
+
+ <class
+ class="lp.registry.model.poll.Poll">
+ <allow
+ interface="lp.registry.interfaces.poll.IPoll"/>
+ <require
+ permission="launchpad.Edit"
+ set_schema="lp.registry.interfaces.poll.IPoll"/>
+ </class>
+
+ <adapter
+ provides="canonical.launchpad.webapp.interfaces.IBreadcrumb"
+ for="lp.registry.interfaces.poll.IPoll"
+ factory="lp.registry.browser.poll.PollBreadcrumb"
+ permission="zope.Public"/>
+
+ <!-- PollOption -->
+
+ <class
+ class="lp.registry.model.poll.PollOption">
+ <allow
+ interface="lp.registry.interfaces.poll.IPollOption"/>
+ <require
+ permission="launchpad.Edit"
+ set_schema="lp.registry.interfaces.poll.IPollOption"/>
+ </class>
+
+ <!-- Vote -->
+
+
+ <!-- Can't require launchpad.Edit to set_attributes because in most cases
+ the vote won't be associated with a person, and thus we can't check it
+ against the logged in user. -->
+
+ <class
+ class="lp.registry.model.poll.Vote">
+ <allow
+ interface="lp.registry.interfaces.poll.IVote"/>
+ <require
+ permission="zope.Public"
+ set_attributes="option preference"/>
+ </class>
+
+ <!-- VoteCast -->
+
+ <class
+ class="lp.registry.model.poll.VoteCast">
+ <allow
+ interface="lp.registry.interfaces.poll.IVoteCast"/>
+ </class>
+
+ <!-- PollSet -->
+
+ <class
+ class="lp.registry.model.poll.PollSet">
+ <allow
+ interface="lp.registry.interfaces.poll.IPollSet"/>
+ </class>
+ <securedutility
+ class="lp.registry.model.poll.PollSet"
+ provides="lp.registry.interfaces.poll.IPollSet">
+ <allow
+ interface="lp.registry.interfaces.poll.IPollSet"/>
+ </securedutility>
+
+ <!-- PollSubset -->
+
+ <adapter
+ for="lp.registry.interfaces.person.ITeam"
+ provides="lp.registry.interfaces.poll.IPollSubset"
+ factory="lp.registry.adapters.PollSubset"
+ permission="zope.Public"/>
+
+ <!-- PollOptionSet -->
+
+ <class
+ class="lp.registry.model.poll.PollOptionSet">
+ <allow
+ interface="lp.registry.interfaces.poll.IPollOptionSet"/>
+ </class>
+ <securedutility
+ class="lp.registry.model.poll.PollOptionSet"
+ provides="lp.registry.interfaces.poll.IPollOptionSet">
+ <allow
+ interface="lp.registry.interfaces.poll.IPollOptionSet"/>
+ </securedutility>
+ <securedutility
+ class="lp.registry.model.poll.VoteSet"
+ provides="lp.registry.interfaces.poll.IVoteSet">
+ <allow
+ interface="lp.registry.interfaces.poll.IVoteSet"/>
+ </securedutility>
+ <securedutility
+ class="lp.registry.model.poll.VoteCastSet"
+ provides="lp.registry.interfaces.poll.IVoteCastSet">
+ <allow
+ interface="lp.registry.interfaces.poll.IVoteCastSet"/>
+ </securedutility>
+ </facet>
<!-- Announcement -->
=== modified file 'lib/lp/registry/doc/person-merge.txt'
--- lib/lp/registry/doc/person-merge.txt 2010-12-20 15:06:51 +0000
+++ lib/lp/registry/doc/person-merge.txt 2011-01-04 16:38:41 +0000
@@ -1,5 +1,4 @@
-Merging
-=======
+= Merging =
For many reasons (i.e. a gina run) we could have duplicated accounts in
Launchpad. Once a duplicated account is identified, we need to allow the user
@@ -20,8 +19,7 @@
>>> marilize = personset.getByName('marilize')
-Sanity checks
--------------
+== Sanity checks ==
We can't merge an account that still has email addresses attached to it
@@ -31,8 +29,7 @@
AssertionError: ...
-Preparing test person for the merge
------------------------------------
+== Preparing test person for the merge ==
Merging people involves updating the merged person relationships. Let's
put the person we will merge into some of those.
@@ -60,11 +57,10 @@
marilize
>>> sampleperson_old_karma = sample.karma
-Branches whose owner is being merged are uniquified by appending '-N'
-where N is a unique integer. We create "peoplemerge" and
-"peoplemerge-1" branches owned by marilize, and a "peoplemerge" and
-"peoplemerge-1" branches owned by 'Sample Person' to test that branch
-name uniquifying works.
+Branches whose owner is being merged are uniquified by appending '-N' where N
+is a unique integer. We create "peoplemerge" and "peoplemerge-1" branches owned
+by marilize, and a "peoplemerge" and "peoplemerge-1" branches owned by 'Sample
+Person' to test that branch name uniquifying works.
Branches with smaller IDs will be processed first, so we create "peoplemerge"
first, and it will be renamed "peoplemerge-2". The extant "peoplemerge-1"
@@ -85,9 +81,9 @@
>>> peoplemerge11 = factory.makePersonalBranch(
... name='peoplemerge-1', owner=marilize)
-'Sample Person' is a deactivated member of the 'Ubuntu Translators'
-team, while marilize is an active member. After the merge, 'Sample
-Person' will be an active member of that team.
+'Sample Person' is a deactivated member of the 'Ubuntu Translators' team,
+while marilize is an active member. After the merge, 'Sample Person' will be an
+active member of that team.
>>> sample in ubuntu_translators.inactivemembers
True
@@ -106,8 +102,7 @@
u'Marilize Coetzee'
-Do the merge!
--------------
+== Do the merge! ==
# Now we remove the only email address marilize had, so that we can merge
# it. First we need to change its status, though, because we can't delete
@@ -124,8 +119,7 @@
>>> personset.merge(marilize, sample)
-Merge results
--------------
+== Merge results ==
Check that 'Sample Person' has indeed become an active member of 'Ubuntu
Translators'
@@ -342,30 +336,37 @@
loser, winner,
-Merging teams
--------------
+== Merging teams ==
Merging of teams is also possible and uses the same API used for merging
-people. Team memberships are carried over just like when merging people.
+people. Note, though, that when merging teams, its polls will not be
+carried over to the remaining team. Team memberships, on the other hand,
+are carried over just like when merging people.
>>> from datetime import datetime, timedelta
>>> import pytz
+ >>> from lp.registry.interfaces.poll import IPollSubset, PollSecrecy
>>> test_team = personset.newTeam(sample, 'test-team', 'Test team')
>>> launchpad_devs = personset.getByName('launchpad')
>>> ignored = launchpad_devs.addMember(
... test_team, reviewer=launchpad_devs.teamowner, force_team_add=True)
- >>> today = datetime.now(pytz.UTC)
+ >>> today = datetime.now(pytz.timezone('UTC'))
>>> tomorrow = today + timedelta(days=1)
+ >>> poll = IPollSubset(test_team).new(
+ ... 'test-poll', 'Title', 'Proposition', today, tomorrow,
+ ... PollSecrecy.OPEN, allowspoilt=True)
- # test_team has a superteam and one active member.
+ # test_team has a superteam, one active member and a poll.
>>> [team.name for team in test_team.super_teams]
[u'launchpad']
>>> test_team.teamowner.name
u'name12'
>>> [member.name for member in test_team.allmembers]
[u'name12']
+ >>> list(IPollSubset(test_team).getAll())
+ [<Poll at ...]
- # Landscape-developers has no super teams and two members.
+ # Landscape-developers has no super teams, two members and no polls.
>>> landscape = personset.getByName('landscape-developers')
>>> [team.name for team in landscape.super_teams]
[]
@@ -373,6 +374,8 @@
u'name12'
>>> [member.name for member in landscape.allmembers]
[u'salgado', u'name12']
+ >>> list(IPollSubset(landscape).getAll())
+ []
Now we try to merge them, but since test_team has active members it can't be
merged.
@@ -426,14 +429,17 @@
>>> personset.merge(test_team, landscape)
- # The resulting Landscape-developers no new super teams and its
- # members are still the same two from before the merge.
+ # The resulting Landscape-developers no new super teams, has
+ # no polls and its members are still the same two from before the
+ # merge.
>>> landscape.teamowner.name
u'name12'
>>> [member.name for member in landscape.allmembers]
[u'salgado', u'name12']
>>> [team.name for team in landscape.super_teams]
[]
+ >>> list(IPollSubset(landscape).getAll())
+ []
A person with a PPA can't be merged.
=== added file 'lib/lp/registry/doc/poll-preconditions.txt'
--- lib/lp/registry/doc/poll-preconditions.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/doc/poll-preconditions.txt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,74 @@
+Poll preconditions
+==================
+
+There's some preconditions that we need to meet to vote in polls and remove
+options from them, Not meeting these preconditions is a programming error and
+should be threated as so.
+
+ >>> from zope.component import getUtility
+ >>> from canonical.database.sqlbase import flush_database_updates
+ >>> from canonical.launchpad.ftests import login
+ >>> from datetime import timedelta
+ >>> from lp.registry.interfaces.person import IPersonSet
+ >>> from lp.registry.interfaces.poll import IPollSet
+
+ >>> ubuntu_team = getUtility(IPersonSet).get(17)
+ >>> ubuntu_team_member = getUtility(IPersonSet).get(1)
+ >>> ubuntu_team_nonmember = getUtility(IPersonSet).get(12)
+
+ >>> pollset = getUtility(IPollSet)
+ >>> director_election = pollset.getByTeamAndName(ubuntu_team,
+ ... 'director-2004')
+ >>> director_options = director_election.getActiveOptions()
+ >>> leader_election = pollset.getByTeamAndName(ubuntu_team, 'leader-2004')
+ >>> leader_options = leader_election.getActiveOptions()
+ >>> opendate = leader_election.dateopens
+ >>> onesec = timedelta(seconds=1)
+
+
+If the poll is already opened, it's impossible to remove an option.
+
+ >>> leader_election.removeOption(leader_options[0], when=opendate)
+ Traceback (most recent call last):
+ ...
+ AssertionError
+
+
+Trying to vote two times is a programming error.
+
+ >>> votes = leader_election.storeSimpleVote(
+ ... ubuntu_team_member, leader_options[0], when=opendate)
+
+ >>> votes = leader_election.storeSimpleVote(
+ ... ubuntu_team_member, leader_options[0], when=opendate)
+ Traceback (most recent call last):
+ ...
+ AssertionError: Can't vote twice in the same poll
+
+
+It's not possible for a non-member to vote, neither to vote when the poll is
+not open.
+
+ >>> votes = leader_election.storeSimpleVote(
+ ... ubuntu_team_nonmember, leader_options[0], when=opendate)
+ Traceback (most recent call last):
+ ...
+ AssertionError: Person ... is not a member of this poll's team.
+
+ >>> votes = leader_election.storeSimpleVote(
+ ... ubuntu_team_member, leader_options[0], when=opendate - onesec)
+ Traceback (most recent call last):
+ ...
+ AssertionError: This poll is not open
+
+
+It's not possible to vote on an option that doesn't belong to the poll you're
+voting in.
+
+ >>> options = {leader_options[0]: 1}
+ >>> votes = director_election.storeCondorcetVote(
+ ... ubuntu_team_member, options, when=opendate)
+ Traceback (most recent call last):
+ ...
+ AssertionError: The option ... doesn't belong to this poll
+
=== added file 'lib/lp/registry/doc/poll.txt'
--- lib/lp/registry/doc/poll.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/doc/poll.txt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,140 @@
+Polls
+=====
+
+In Launchpad, we have teams as a way to group free software
+developers/contributors usually based on the free software
+product/project/distribution they're involved in. This is the case with teams
+like the 'Gnome Team' and the 'Ubuntu Team'. These teams often have leaders
+whose ellection depends on the vote of all members, and this is one of the
+reasons why we teams can have polls attached to them.
+
+ >>> import pytz
+ >>> from datetime import datetime, timedelta
+ >>> from zope.component import getUtility
+ >>> from canonical.database.sqlbase import flush_database_updates
+ >>> from canonical.launchpad.ftests import login
+ >>> from lp.registry.interfaces.person import IPersonSet
+ >>> from lp.registry.interfaces.poll import (
+ ... IPollSubset,
+ ... PollAlgorithm,
+ ... PollSecrecy,
+ ... )
+
+ >>> team = getUtility(IPersonSet).getByName('ubuntu-team')
+ >>> member = getUtility(IPersonSet).getByName('stevea')
+ >>> member2 = getUtility(IPersonSet).getByName('jdub')
+ >>> member3 = getUtility(IPersonSet).getByName('kamion')
+ >>> member4 = getUtility(IPersonSet).getByName('name16')
+ >>> member5 = getUtility(IPersonSet).getByName('limi')
+ >>> nonmember = getUtility(IPersonSet).getByName('justdave')
+ >>> now = datetime.now(pytz.timezone('UTC'))
+ >>> onesec = timedelta(seconds=1)
+
+We need to login with one of the administrators of the team named
+'ubuntu-team' to be able to create/edit polls.
+ >>> login('colin.watson@xxxxxxxxxxxxxxx')
+
+First we get an object implementing IPollSubset, which is the set of polls for
+a given team (in our case, the 'Ubuntu Team')
+ >>> pollsubset = IPollSubset(team)
+
+Now we create a new poll on this team.
+ >>> opendate = datetime(2005, 01, 01, tzinfo=pytz.timezone('UTC'))
+ >>> closedate = opendate + timedelta(weeks=2)
+ >>> title = "2005 Leader's Elections"
+ >>> proposition = "Who's going to be the next leader?"
+ >>> type = PollAlgorithm.SIMPLE
+ >>> secrecy = PollSecrecy.SECRET
+ >>> allowspoilt = True
+ >>> poll = pollsubset.new("leader-election", title, proposition, opendate,
+ ... closedate, secrecy, allowspoilt, type)
+
+Now we test the if the poll is open or closed in some specific dates.
+ >>> poll.isOpen(when=opendate)
+ True
+ >>> poll.isOpen(when=opendate - onesec)
+ False
+ >>> poll.isOpen(when=closedate)
+ True
+ >>> poll.isOpen(when=closedate + onesec)
+ False
+
+To know what polls are open/closed/not-yet-opened in a team, you can use the
+methods of PollSubset.
+Here we'll query using three different dates:
+
+Query for open polls in the exact second the poll is opening.
+ >>> [p.name for p in pollsubset.getOpenPolls(when=opendate)]
+ [u'leader-election', u'never-closes', u'never-closes2', u'never-closes3',
+ u'never-closes4']
+
+Query for closed polls, one second after the poll closes.
+ >>> [p.name for p in pollsubset.getClosedPolls(when=closedate + onesec)]
+ [u'director-2004', u'leader-2004', u'leader-election']
+
+Query for not-yet-opened polls, one second before the poll opens.
+ >>> [p.name for p in pollsubset.getNotYetOpenedPolls(when=opendate - onesec)]
+ [u'leader-election', u'not-yet-opened']
+
+All polls must have a set of options for people to choose, and they'll always
+start with zero options. We're responsible for adding new ones.
+ >>> poll.getAllOptions().count()
+ 0
+
+Let's add some options to this poll, so people can start voting. :)
+ >>> will = poll.newOption('wgraham', 'Will Graham')
+ >>> jack = poll.newOption('jcrawford', 'Jack Crawford')
+ >>> francis = poll.newOption('fd', 'Francis Dolarhyde')
+ >>> [o.title for o in poll.getActiveOptions()]
+ [u'Francis Dolarhyde', u'Jack Crawford', u'Will Graham']
+
+Now, what happens if the poll is already open and, let's say, Francis Dolarhyde
+is convicted and thus becomes ineligible? We'll have to mark that option as
+inactive, so people can't vote on it.
+ >>> francis.active = False
+ >>> flush_database_updates()
+ >>> [o.title for o in poll.getActiveOptions()]
+ [u'Jack Crawford', u'Will Graham']
+
+If the poll is not yet opened, it's possible to simply remove a given option.
+ >>> poll.removeOption(will, when=opendate - onesec)
+ >>> [o.title for o in poll.getAllOptions()]
+ [u'Francis Dolarhyde', u'Jack Crawford']
+
+Any member of the team this poll refers to is eligible to vote, if the poll is
+still open.
+
+ >>> vote1 = poll.storeSimpleVote(member, jack, when=opendate)
+ >>> vote2 = poll.storeSimpleVote(member2, None, when=opendate)
+
+
+Now we create a Condorcet poll on this team and add some options to it, so
+people can start voting.
+
+ >>> title = "2005 Director's Elections"
+ >>> proposition = "Who's going to be the next director?"
+ >>> type = PollAlgorithm.CONDORCET
+ >>> secrecy = PollSecrecy.SECRET
+ >>> allowspoilt = True
+ >>> poll2 = pollsubset.new("director-election", title, proposition, opendate,
+ ... closedate, secrecy, allowspoilt, type)
+ >>> a = poll2.newOption('A', 'Option A')
+ >>> b = poll2.newOption('B', 'Option B')
+ >>> c = poll2.newOption('C', 'Option C')
+ >>> d = poll2.newOption('D', 'Option D')
+
+ >>> options = {b: 1, d: 2, c: 3}
+ >>> votes = poll2.storeCondorcetVote(member, options, when=opendate)
+ >>> options = {d: 1, b: 2}
+ >>> votes = poll2.storeCondorcetVote(member2, options, when=opendate)
+ >>> options = {a: 1, c: 2, b: 3}
+ >>> votes = poll2.storeCondorcetVote(member3, options, when=opendate)
+ >>> options = {a: 1}
+ >>> votes = poll2.storeCondorcetVote(member4, options, when=opendate)
+ >>> for row in poll2.getPairwiseMatrix():
+ ... print row
+ [None, 2L, 2L, 2L]
+ [2L, None, 2L, 2L]
+ [1L, 1L, None, 1L]
+ [2L, 1L, 2L, None]
+
=== modified file 'lib/lp/registry/doc/team-nav-menus.txt'
--- lib/lp/registry/doc/team-nav-menus.txt 2010-12-16 14:42:36 +0000
+++ lib/lp/registry/doc/team-nav-menus.txt 2011-01-04 16:38:41 +0000
@@ -31,6 +31,9 @@
link members
url: .../~name18/+members
...
+ link polls
+ url: .../~name18/+polls
+ ...
link profile
url: .../~name18
...
=== added file 'lib/lp/registry/interfaces/poll.py'
--- lib/lp/registry/interfaces/poll.py 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/interfaces/poll.py 2011-01-04 16:38:41 +0000
@@ -0,0 +1,500 @@
+# Copyright 2009 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+# pylint: disable-msg=E0211,E0213
+
+__all__ = [
+ 'IPoll',
+ 'IPollSet',
+ 'IPollSubset',
+ 'IPollOption',
+ 'IPollOptionSet',
+ 'IVote',
+ 'IVoteCast',
+ 'PollAlgorithm',
+ 'PollSecrecy',
+ 'PollStatus',
+ 'IVoteSet',
+ 'IVoteCastSet',
+ 'OptionIsNotFromSimplePoll'
+ ]
+
+from datetime import (
+ datetime,
+ timedelta,
+ )
+
+from lazr.enum import (
+ DBEnumeratedType,
+ DBItem,
+ )
+import pytz
+from zope.component import getUtility
+from zope.interface import (
+ Attribute,
+ Interface,
+ )
+from zope.interface.exceptions import Invalid
+from zope.interface.interface import invariant
+from zope.schema import (
+ Bool,
+ Choice,
+ Datetime,
+ Int,
+ Text,
+ TextLine,
+ )
+
+from canonical.launchpad import _
+from canonical.launchpad.validators.name import name_validator
+from lp.registry.interfaces.person import ITeam
+from lp.services.fields import ContentNameField
+
+
+class PollNameField(ContentNameField):
+
+ errormessage = _("%s is already in use by another poll in this team.")
+
+ @property
+ def _content_iface(self):
+ return IPoll
+
+ def _getByName(self, name):
+ team = ITeam(self.context, None)
+ if team is None:
+ team = self.context.team
+ return getUtility(IPollSet).getByTeamAndName(team, name)
+
+
+class PollAlgorithm(DBEnumeratedType):
+ """The algorithm used to accept and calculate the results."""
+
+ SIMPLE = DBItem(1, """
+ Simple Voting
+
+ The most simple method for voting; you just choose a single option.
+ """)
+
+ CONDORCET = DBItem(2, """
+ Condorcet Voting
+
+ One of various methods used for calculating preferential votes. See
+ http://www.electionmethods.org/CondorcetEx.htm for more information.
+ """)
+
+
+class PollSecrecy(DBEnumeratedType):
+ """The secrecy of a given Poll."""
+
+ OPEN = DBItem(1, """
+ Public Votes (Anyone can see a person's vote)
+
+ Everyone who wants will be able to see a person's vote.
+ """)
+
+ ADMIN = DBItem(2, """
+ Semi-secret Votes (Only team administrators can see a person's vote)
+
+ All team owners and administrators will be able to see a person's vote.
+ """)
+
+ SECRET = DBItem(3, """
+ Secret Votes (It's impossible to track a person's vote)
+
+ We don't store the option a person voted in our database,
+ """)
+
+
+class PollStatus:
+ """This class stores the constants used when searching for polls."""
+
+ OPEN = 'open'
+ CLOSED = 'closed'
+ NOT_YET_OPENED = 'not-yet-opened'
+ ALL = frozenset([OPEN, CLOSED, NOT_YET_OPENED])
+
+
+class IPoll(Interface):
+ """A poll for a given proposition in a team."""
+
+ id = Int(title=_('The unique ID'), required=True, readonly=True)
+
+ team = Int(
+ title=_('The team that this poll refers to.'), required=True,
+ readonly=True)
+
+ name = 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)
+
+ title = TextLine(
+ title=_('The title of this poll'), required=True, readonly=False)
+
+ dateopens = Datetime(
+ title=_('The date and time when this poll opens'), required=True,
+ readonly=False)
+
+ datecloses = Datetime(
+ title=_('The date and time when this poll closes'), required=True,
+ readonly=False)
+
+ proposition = Text(
+ title=_('The proposition that is going to be voted'), required=True,
+ readonly=False)
+
+ type = Choice(
+ title=_('The type of this poll'), required=True,
+ readonly=False, vocabulary=PollAlgorithm,
+ default=PollAlgorithm.CONDORCET)
+
+ allowspoilt = 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)
+
+ secrecy = Choice(
+ title=_('The secrecy of the Poll'), required=True,
+ readonly=False, vocabulary=PollSecrecy,
+ default=PollSecrecy.SECRET)
+
+ @invariant
+ def saneDates(poll):
+ """Ensure the poll's dates are sane.
+
+ A poll's end date must be after its start date and its start date must
+ be at least 12h from now.
+ """
+ if poll.dateopens >= poll.datecloses:
+ raise Invalid(
+ "A poll cannot close at the time (or before) it opens.")
+ now = datetime.now(pytz.UTC)
+ twelve_hours_ahead = now + timedelta(hours=12)
+ start_date = poll.dateopens.astimezone(pytz.UTC)
+ if start_date < twelve_hours_ahead:
+ raise Invalid(
+ "A poll cannot open less than 12 hours after it's created.")
+
+ def isOpen(when=None):
+ """Return True if this Poll is still open.
+
+ The optional :when argument is used only by our tests, to test if the
+ poll is/was/will be open at a specific date.
+ """
+
+ def isClosed(when=None):
+ """Return True if this Poll is already closed.
+
+ The optional :when argument is used only by our tests, to test if the
+ poll is/was/will be closed at a specific date.
+ """
+
+ def isNotYetOpened(when=None):
+ """Return True if this Poll is not yet opened.
+
+ The optional :when argument is used only by our tests, to test if the
+ poll is/was/will be not-yet-opened at a specific date.
+ """
+
+ def closesIn():
+ """Return a timedelta object of the interval between now and the date
+ when this poll closes."""
+
+ def opensIn():
+ """Return a timedelta object of the interval between now and the date
+ when this poll opens."""
+
+ def newOption(name, title=None, active=True):
+ """Create a new PollOption for this poll.
+
+ If title is None it'll be the same as name.
+ """
+
+ def getActiveOptions():
+ """Return all PollOptions of this poll that are active."""
+
+ def getAllOptions():
+ """Return all Options of this poll."""
+
+ def personVoted(person):
+ """Return True if :person has already voted in this poll."""
+
+ def getVotesByPerson(person):
+ """Return the votes of the given person in this poll.
+
+ The return value will always be a list of Vote objects. That's for
+ consistency because on simple polls there'll be always a single vote,
+ but for condorcet poll, there'll always be a list.
+ """
+
+ def getTotalVotes():
+ """Return the total number of votes this poll had.
+
+ This must be used only on closed polls.
+ """
+
+ def getWinners():
+ """Return the options which won this poll.
+
+ This should be used only on closed polls.
+ """
+
+ def removeOption(option, when=None):
+ """Remove the given option from this poll.
+
+ A ValueError is raised if the given option doesn't belong to this poll.
+ This method can be used only on polls that are not yet opened.
+ The optional :when argument is used only by our tests, to test if the
+ poll is/was/will be not-yet-opened at a specific date.
+ """
+
+ def getOptionByName(name):
+ """Return the PollOption by the given name."""
+
+ def storeSimpleVote(person, option, when=None):
+ """Store and return the vote of a given person in a this poll.
+
+ This method can be used only if this poll is still open and if this is
+ a Simple-style poll.
+
+ :option: The choosen option.
+
+ :when: Optional argument used only by our tests, to test if the poll
+ is/was/will be open at a specific date.
+ """
+
+ def storeCondorcetVote(person, options, when=None):
+ """Store and return the votes of a given person in this poll.
+
+ This method can be used only if this poll is still open and if this is
+ a Condorcet-style poll.
+
+ :options: A dictionary, where the options are the keys and the
+ preferences of each option are the values.
+
+ :when: Optional argument used only by our tests, to test if the poll
+ is/was/will be open at a specific date.
+ """
+
+ def getPairwiseMatrix():
+ """Return the pairwise matrix for this poll.
+
+ This method is only available for condorcet-style polls.
+ See http://www.electionmethods.org/CondorcetEx.htm for an example of a
+ pairwise matrix.
+ """
+
+
+class IPollSet(Interface):
+ """The set of Poll objects."""
+
+ def new(team, name, title, proposition, dateopens, datecloses,
+ secrecy, allowspoilt, poll_type=PollAlgorithm.SIMPLE):
+ """Create a new Poll for the given team."""
+
+ def selectByTeam(team, status=PollStatus.ALL, orderBy=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.
+
+ :orderBy: can be either a string with the column name you want to sort
+ or a list of column names as strings.
+ If no orderBy is specified the results will be ordered using the
+ default ordering specified in Poll._defaultOrder.
+
+ The optional :when argument is used only by our tests, to test if the
+ poll is/was/will-be open at a specific date.
+ """
+
+ def getByTeamAndName(team, name, default=None):
+ """Return the Poll for the given team with the given name.
+
+ Return :default if there's no Poll with this name for that team.
+ """
+
+
+class IPollSubset(Interface):
+ """The set of Poll objects for a given team."""
+
+ team = Attribute(_("The team of these polls."))
+
+ title = Attribute('Polls Page Title')
+
+ def new(name, title, proposition, dateopens, datecloses, secrecy,
+ allowspoilt, poll_type=PollAlgorithm.SIMPLE):
+ """Create a new Poll for this team."""
+
+ def getAll():
+ """Return all Polls of this team."""
+
+ def getOpenPolls(when=None):
+ """Return all Open Polls for this team ordered by the date they'll
+ close.
+
+ The optional :when argument is used only by our tests, to test if the
+ poll is/was/will be open at a specific date.
+ """
+
+ def getNotYetOpenedPolls(when=None):
+ """Return all Not-Yet-Opened Polls for this team ordered by the date
+ they'll open.
+
+ The optional :when argument is used only by our tests, to test if the
+ poll is/was/will be open at a specific date.
+ """
+
+ def getClosedPolls(when=None):
+ """Return all Closed Polls for this team ordered by the date they
+ closed.
+
+ The optional :when argument is used only by our tests, to test if the
+ poll is/was/will be open at a specific date.
+ """
+
+
+class PollOptionNameField(ContentNameField):
+
+ errormessage = _("%s is already in use by another option in this poll.")
+
+ @property
+ def _content_iface(self):
+ return IPollOption
+
+ def _getByName(self, name):
+ if IPollOption.providedBy(self.context):
+ poll = self.context.poll
+ else:
+ poll = self.context
+ return poll.getOptionByName(name)
+
+
+class IPollOption(Interface):
+ """An option to be voted in a given Poll."""
+
+ id = Int(title=_('The unique ID'), required=True, readonly=True)
+
+ poll = Int(
+ title=_('The Poll to which this option refers to.'), required=True,
+ readonly=True)
+
+ name = PollOptionNameField(
+ title=_('Name'), required=True, readonly=False)
+
+ title = TextLine(
+ title=_('Title'),
+ description=_(
+ 'The title of this option. A single brief sentence that '
+ 'summarises the outcome for which people are voting if '
+ 'they select this option.'),
+ required=True, readonly=False)
+
+ active = Bool(
+ title=_('Is this option active?'), required=True, readonly=False,
+ default=True)
+
+ def destroySelf():
+ """Remove this option from the database."""
+
+
+class IPollOptionSet(Interface):
+ """The set of PollOption objects."""
+
+ def new(poll, name, title, active=True):
+ """Create a new PollOption."""
+
+ def selectByPoll(poll, only_active=False):
+ """Return all PollOptions of the given poll.
+
+ If :only_active is True, then return only the active polls.
+ """
+
+ def getByPollAndId(poll, id, default=None):
+ """Return the PollOption with the given id.
+
+ Return :default if there's no PollOption with the given id or if that
+ PollOption is not in the given poll.
+ """
+
+
+class IVoteCast(Interface):
+ """Here we store who voted in a Poll, but not their votes."""
+
+ id = Int(title=_('The unique ID'), required=True, readonly=True)
+
+ person = Int(
+ title=_('The Person that voted.'), required=False, readonly=True)
+
+ poll = Int(
+ title=_('The Poll in which the person voted.'), required=True,
+ readonly=True)
+
+
+class IVoteCastSet(Interface):
+ """The set of all VoteCast objects."""
+
+ def new(poll, person):
+ """Create a new VoteCast."""
+
+
+class IVote(Interface):
+ """Here we store the vote itself, linked to a special token.
+
+ This token is given to the user when he votes, so he can change his vote
+ later.
+ """
+
+ id = Int(
+ title=_('The unique ID'), required=True, readonly=True)
+
+ person = Int(
+ title=_('The Person that voted.'), required=False, readonly=True)
+
+ poll = Int(
+ title=_('The Poll in which the person voted.'), required=True,
+ readonly=True)
+
+ option = Int(
+ title=_('The PollOption choosen.'), required=True, readonly=False)
+
+ preference = Int(
+ title=_('The preference of the choosen PollOption'), required=True,
+ readonly=False)
+
+ token = Text(
+ title=_('The token we give to the user.'),
+ required=True, readonly=True)
+
+
+class OptionIsNotFromSimplePoll(Exception):
+ """Someone tried use an option from a non-SIMPLE poll as if it was from a
+ SIMPLE one."""
+
+
+class IVoteSet(Interface):
+ """The set of all Vote objects."""
+
+ def newToken():
+ """Return a token that was never used in the Vote table."""
+
+ def new(poll, option, preference, token, person):
+ """Create a new Vote."""
+
+ def getByToken(token):
+ """Return the list of votes with the given token.
+
+ For polls whose type is SIMPLE, this list will contain a single vote,
+ because in SIMPLE poll only one option can be choosen.
+ """
+
+ def getVotesByOption(option):
+ """Return the number of votes the given option received.
+
+ Raises a TypeError if the given option doesn't belong to a
+ simple-style poll.
+ """
+
=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py 2010-12-23 11:35:12 +0000
+++ lib/lp/registry/model/person.py 2011-01-04 16:38:41 +0000
@@ -3826,17 +3826,12 @@
('personlanguage', 'person'),
('person', 'merged'),
('emailaddress', 'person'),
+ # Polls are not carried over when merging teams.
+ ('poll', 'team'),
# We can safely ignore the mailinglist table as there's a sanity
# check above which prevents teams with associated mailing lists
# from being merged.
('mailinglist', 'team'),
- ('translationrelicensingagreement', 'person'),
- # Polls are not carried over when merging teams.
- # XXX: BradCrittenden 2010-12-16 bug=691105:
- # Even though polls have been removed as a feature and from the
- # data model, they still exist in the database and must be skipped
- # here to avoid violating uniqueness constraints.
- ('poll', 'team'),
# I don't think we need to worry about the votecast and vote
# tables, because a real human should never have two profiles
# in Launchpad that are active members of a given team and voted
@@ -3845,6 +3840,7 @@
# closed -- StuartBishop 20060602
('votecast', 'person'),
('vote', 'person'),
+ ('translationrelicensingagreement', 'person'),
]
references = list(postgresql.listReferences(cur, 'person', 'id'))
=== added file 'lib/lp/registry/model/poll.py'
--- lib/lp/registry/model/poll.py 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/model/poll.py 2011-01-04 16:38:41 +0000
@@ -0,0 +1,440 @@
+# Copyright 2009 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+# pylint: disable-msg=E0611,W0212
+
+__metaclass__ = type
+__all__ = [
+ 'Poll',
+ 'PollOption',
+ 'PollOptionSet',
+ 'PollSet',
+ 'VoteCast',
+ 'Vote',
+ 'VoteSet',
+ 'VoteCastSet',
+ ]
+
+from datetime import datetime
+import random
+
+import pytz
+from sqlobject import (
+ AND,
+ BoolCol,
+ ForeignKey,
+ IntCol,
+ OR,
+ SQLObjectNotFound,
+ StringCol,
+ )
+from storm.store import Store
+from zope.component import getUtility
+from zope.interface import implements
+
+from canonical.database.datetimecol import UtcDateTimeCol
+from canonical.database.enumcol import EnumCol
+from canonical.database.sqlbase import (
+ SQLBase,
+ sqlvalues,
+ )
+from lp.registry.interfaces.person import validate_public_person
+from lp.registry.interfaces.poll import (
+ IPoll,
+ IPollOption,
+ IPollOptionSet,
+ IPollSet,
+ IVote,
+ IVoteCast,
+ IVoteCastSet,
+ IVoteSet,
+ OptionIsNotFromSimplePoll,
+ PollAlgorithm,
+ PollSecrecy,
+ PollStatus,
+ )
+
+
+class Poll(SQLBase):
+ """See IPoll."""
+
+ implements(IPoll)
+ _table = 'Poll'
+ sortingColumns = ['title', 'id']
+ _defaultOrder = sortingColumns
+
+ team = ForeignKey(
+ dbName='team', foreignKey='Person',
+ storm_validator=validate_public_person, notNull=True)
+
+ name = StringCol(dbName='name', notNull=True)
+
+ title = StringCol(dbName='title', notNull=True, unique=True)
+
+ dateopens = UtcDateTimeCol(dbName='dateopens', notNull=True)
+
+ datecloses = UtcDateTimeCol(dbName='datecloses', notNull=True)
+
+ proposition = StringCol(dbName='proposition', notNull=True)
+
+ type = EnumCol(dbName='type', enum=PollAlgorithm,
+ default=PollAlgorithm.SIMPLE)
+
+ allowspoilt = BoolCol(dbName='allowspoilt', default=True, notNull=True)
+
+ secrecy = EnumCol(dbName='secrecy', enum=PollSecrecy,
+ default=PollSecrecy.SECRET)
+
+ def newOption(self, name, title, active=True):
+ """See IPoll."""
+ return getUtility(IPollOptionSet).new(self, name, title, active)
+
+ def isOpen(self, when=None):
+ """See IPoll."""
+ if when is None:
+ when = datetime.now(pytz.timezone('UTC'))
+ return (self.datecloses >= when and self.dateopens <= when)
+
+ @property
+ def closesIn(self):
+ """See IPoll."""
+ return self.datecloses - datetime.now(pytz.timezone('UTC'))
+
+ @property
+ def opensIn(self):
+ """See IPoll."""
+ return self.dateopens - datetime.now(pytz.timezone('UTC'))
+
+ def isClosed(self, when=None):
+ """See IPoll."""
+ if when is None:
+ when = datetime.now(pytz.timezone('UTC'))
+ return self.datecloses <= when
+
+ def isNotYetOpened(self, when=None):
+ """See IPoll."""
+ if when is None:
+ when = datetime.now(pytz.timezone('UTC'))
+ return self.dateopens > when
+
+ def getAllOptions(self):
+ """See IPoll."""
+ return getUtility(IPollOptionSet).selectByPoll(self)
+
+ def getActiveOptions(self):
+ """See IPoll."""
+ return getUtility(IPollOptionSet).selectByPoll(self, only_active=True)
+
+ def getVotesByPerson(self, person):
+ """See IPoll."""
+ return Vote.selectBy(person=person, poll=self)
+
+ def personVoted(self, person):
+ """See IPoll."""
+ results = VoteCast.selectBy(person=person, poll=self)
+ return bool(results.count())
+
+ def removeOption(self, option, when=None):
+ """See IPoll."""
+ assert self.isNotYetOpened(when=when)
+ if option.poll != self:
+ raise ValueError(
+ "Can't remove an option that doesn't belong to this poll")
+ option.destroySelf()
+
+ def getOptionByName(self, name):
+ """See IPoll."""
+ return PollOption.selectOneBy(poll=self, name=name)
+
+ def _assertEverythingOkAndGetVoter(self, person, when=None):
+ """Use assertions to Make sure all pre-conditions for a person to vote
+ are met.
+
+ Return the person if this is not a secret poll or None if it's a
+ secret one.
+ """
+ assert self.isOpen(when=when), "This poll is not open"
+ assert not self.personVoted(person), "Can't vote twice in the same poll"
+ assert person.inTeam(self.team), (
+ "Person %r is not a member of this poll's team." % person)
+
+ # We only associate the option with the person if the poll is not a
+ # SECRET one.
+ if self.secrecy == PollSecrecy.SECRET:
+ voter = None
+ else:
+ voter = person
+ return voter
+
+ def storeCondorcetVote(self, person, options, when=None):
+ """See IPoll."""
+ voter = self._assertEverythingOkAndGetVoter(person, when=when)
+ assert self.type == PollAlgorithm.CONDORCET
+ voteset = getUtility(IVoteSet)
+
+ token = voteset.newToken()
+ votes = []
+ activeoptions = self.getActiveOptions()
+ for option, preference in options.items():
+ assert option.poll == self, (
+ "The option %r doesn't belong to this poll" % option)
+ assert option.active, "Option %r is not active" % option
+ votes.append(voteset.new(self, option, preference, token, voter))
+
+ # Store a vote with preference = None for each active option of this
+ # poll that wasn't in the options argument.
+ for option in activeoptions:
+ if option not in options:
+ votes.append(voteset.new(self, option, None, token, voter))
+
+ getUtility(IVoteCastSet).new(self, person)
+ return votes
+
+ def storeSimpleVote(self, person, option, when=None):
+ """See IPoll."""
+ voter = self._assertEverythingOkAndGetVoter(person, when=when)
+ assert self.type == PollAlgorithm.SIMPLE
+ voteset = getUtility(IVoteSet)
+
+ if option is None and not self.allowspoilt:
+ raise ValueError("This poll doesn't allow spoilt votes.")
+ elif option is not None:
+ assert option.poll == self, (
+ "The option %r doesn't belong to this poll" % option)
+ assert option.active, "Option %r is not active" % option
+ token = voteset.newToken()
+ # This is a simple-style poll, so you can vote only on a single option
+ # and this option's preference must be 1
+ preference = 1
+ vote = voteset.new(self, option, preference, token, voter)
+ getUtility(IVoteCastSet).new(self, person)
+ return vote
+
+ def getTotalVotes(self):
+ """See IPoll."""
+ assert self.isClosed()
+ return Vote.selectBy(poll=self).count()
+
+ def getWinners(self):
+ """See IPoll."""
+ assert self.isClosed()
+ # XXX: GuilhermeSalgado 2005-08-24:
+ # For now, this method works only for SIMPLE-style polls. This is
+ # not a problem as CONDORCET-style polls are disabled.
+ assert self.type == PollAlgorithm.SIMPLE
+ query = """
+ SELECT option
+ FROM Vote
+ WHERE poll = %d AND option IS NOT NULL
+ GROUP BY option
+ HAVING COUNT(*) = (
+ SELECT COUNT(*)
+ FROM Vote
+ WHERE poll = %d
+ GROUP BY option
+ ORDER BY COUNT(*) DESC LIMIT 1
+ )
+ """ % (self.id, self.id)
+ results = Store.of(self).execute(query).get_all()
+ if not results:
+ return None
+ return [PollOption.get(id) for (id,) in results]
+
+ def getPairwiseMatrix(self):
+ """See IPoll."""
+ assert self.type == PollAlgorithm.CONDORCET
+ options = list(self.getAllOptions())
+ pairwise_matrix = []
+ for option1 in options:
+ pairwise_row = []
+ for option2 in options:
+ points_query = """
+ SELECT COUNT(*) FROM Vote as v1, Vote as v2 WHERE
+ v1.token = v2.token AND
+ v1.option = %s AND v2.option = %s AND
+ (
+ (
+ v1.preference IS NOT NULL AND
+ v2.preference IS NOT NULL AND
+ v1.preference < v2.preference
+ )
+ OR
+ (
+ v1.preference IS NOT NULL AND
+ v2.preference IS NULL
+ )
+ )
+ """ % sqlvalues(option1.id, option2.id)
+ if option1 == option2:
+ pairwise_row.append(None)
+ else:
+ points = Store.of(self).execute(points_query).get_one()[0]
+ pairwise_row.append(points)
+ pairwise_matrix.append(pairwise_row)
+ return pairwise_matrix
+
+
+class PollSet:
+ """See IPollSet."""
+
+ implements(IPollSet)
+
+ def new(self, team, name, title, proposition, dateopens, datecloses,
+ secrecy, allowspoilt, poll_type=PollAlgorithm.SIMPLE):
+ """See IPollSet."""
+ return Poll(team=team, name=name, title=title,
+ proposition=proposition, dateopens=dateopens,
+ datecloses=datecloses, secrecy=secrecy,
+ allowspoilt=allowspoilt, type=poll_type)
+
+ def selectByTeam(self, team, status=PollStatus.ALL, orderBy=None, when=None):
+ """See IPollSet."""
+ if when is None:
+ when = datetime.now(pytz.timezone('UTC'))
+
+ if orderBy is None:
+ orderBy = Poll.sortingColumns
+
+
+ status = set(status)
+ status_clauses = []
+ if PollStatus.OPEN in status:
+ status_clauses.append(AND(Poll.q.dateopens <= when,
+ Poll.q.datecloses > when))
+ if PollStatus.CLOSED in status:
+ status_clauses.append(Poll.q.datecloses <= when)
+ if PollStatus.NOT_YET_OPENED in status:
+ status_clauses.append(Poll.q.dateopens > when)
+
+ assert len(status_clauses) > 0, "No poll statuses were selected"
+
+ results = Poll.select(AND(Poll.q.teamID == team.id,
+ OR(*status_clauses)))
+
+ return results.orderBy(orderBy)
+
+ def getByTeamAndName(self, team, name, default=None):
+ """See IPollSet."""
+ query = AND(Poll.q.teamID == team.id, Poll.q.name == name)
+ try:
+ return Poll.selectOne(query)
+ except SQLObjectNotFound:
+ return default
+
+
+class PollOption(SQLBase):
+ """See IPollOption."""
+
+ implements(IPollOption)
+ _table = 'PollOption'
+ _defaultOrder = ['title', 'id']
+
+ poll = ForeignKey(dbName='poll', foreignKey='Poll', notNull=True)
+
+ name = StringCol(notNull=True)
+
+ title = StringCol(notNull=True)
+
+ active = BoolCol(notNull=True, default=False)
+
+
+class PollOptionSet:
+ """See IPollOptionSet."""
+
+ implements(IPollOptionSet)
+
+ def new(self, poll, name, title, active=True):
+ """See IPollOptionSet."""
+ return PollOption(poll=poll, name=name, title=title, active=active)
+
+ def selectByPoll(self, poll, only_active=False):
+ """See IPollOptionSet."""
+ query = PollOption.q.pollID == poll.id
+ if only_active:
+ query = AND(query, PollOption.q.active == True)
+ return PollOption.select(query)
+
+ def getByPollAndId(self, poll, option_id, default=None):
+ """See IPollOptionSet."""
+ query = AND(PollOption.q.pollID == poll.id,
+ PollOption.q.id == option_id)
+ try:
+ return PollOption.selectOne(query)
+ except SQLObjectNotFound:
+ return default
+
+
+class VoteCast(SQLBase):
+ """See IVoteCast."""
+
+ implements(IVoteCast)
+ _table = 'VoteCast'
+ _defaultOrder = 'id'
+
+ person = ForeignKey(
+ dbName='person', foreignKey='Person',
+ storm_validator=validate_public_person, notNull=True)
+
+ poll = ForeignKey(dbName='poll', foreignKey='Poll', notNull=True)
+
+
+class VoteCastSet:
+ """See IVoteCastSet."""
+
+ implements(IVoteCastSet)
+
+ def new(self, poll, person):
+ """See IVoteCastSet."""
+ return VoteCast(poll=poll, person=person)
+
+
+class Vote(SQLBase):
+ """See IVote."""
+
+ implements(IVote)
+ _table = 'Vote'
+ _defaultOrder = ['preference', 'id']
+
+ person = ForeignKey(
+ dbName='person', foreignKey='Person',
+ storm_validator=validate_public_person)
+
+ poll = ForeignKey(dbName='poll', foreignKey='Poll', notNull=True)
+
+ option = ForeignKey(dbName='option', foreignKey='PollOption')
+
+ preference = IntCol(dbName='preference')
+
+ token = StringCol(dbName='token', notNull=True, unique=True)
+
+
+class VoteSet:
+ """See IVoteSet."""
+
+ implements(IVoteSet)
+
+ def newToken(self):
+ """See IVoteSet."""
+ chars = '23456789bcdfghjkmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ'
+ length = 10
+ token = ''.join([random.choice(chars) for c in range(length)])
+ while self.getByToken(token):
+ token = ''.join([random.choice(chars) for c in range(length)])
+ return token
+
+ def new(self, poll, option, preference, token, person):
+ """See IVoteSet."""
+ return Vote(poll=poll, option=option, preference=preference,
+ token=token, person=person)
+
+ def getByToken(self, token):
+ """See IVoteSet."""
+ return Vote.selectBy(token=token)
+
+ def getVotesByOption(self, option):
+ """See IVoteSet."""
+ if option.poll.type != PollAlgorithm.SIMPLE:
+ raise OptionIsNotFromSimplePoll(
+ '%r is not an option of a simple-style poll.' % option)
+ return Vote.selectBy(option=option).count()
+
=== added directory 'lib/lp/registry/stories/team-polls'
=== added file 'lib/lp/registry/stories/team-polls/create-poll-options.txt'
--- lib/lp/registry/stories/team-polls/create-poll-options.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/stories/team-polls/create-poll-options.txt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,85 @@
+= Poll options =
+
+A poll can have any number of options, but these must be created
+before the poll has opened.
+
+First we create a new poll to use throughout this test.
+
+ >>> login('jeff.waugh@xxxxxxxxxxxxxxx')
+ >>> from zope.component import getUtility
+ >>> from lp.registry.interfaces.person import IPersonSet
+ >>> factory.makePoll(getUtility(IPersonSet).getByName('ubuntu-team'),
+ ... 'dpl-2080', 'dpl-2080', 'dpl-2080')
+ <Poll...
+ >>> logout()
+
+Our poll is not yet open, so new options can be added to it.
+
+ >>> team_admin_browser = setupBrowser(
+ ... auth='Basic jeff.waugh@xxxxxxxxxxxxxxx:jdub')
+ >>> team_admin_browser.open(
+ ... 'http://launchpad.dev/~ubuntu-team/+poll/dpl-2080')
+ >>> team_admin_browser.getLink('Add new option').click()
+ >>> team_admin_browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/dpl-2080/+newoption'
+
+ >>> bill_name = (
+ ... 'bill-amazingly-huge-middle-name-almost-impossible-to-read-graham')
+ >>> team_admin_browser.getControl('Name').value = bill_name
+ >>> team_admin_browser.getControl('Title').value = 'Bill Graham'
+ >>> team_admin_browser.getControl('Create').click()
+
+After adding an options we're taken back to the poll's home page.
+
+ >>> team_admin_browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/dpl-2080'
+
+And here we see the option listed as one of the active options for this
+poll.
+
+ >>> print extract_text(
+ ... find_tag_by_id(team_admin_browser.contents, 'options'))
+ Name Title Active
+ bill... Bill Graham Yes
+
+If we try to add a new option without providing a title, we'll get an error
+message because the title is required.
+
+ >>> team_admin_browser.getLink('Add new option').click()
+ >>> team_admin_browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/dpl-2080/+newoption'
+
+ >>> will_name = (
+ ... 'will-amazingly-huge-middle-name-almost-impossible-to-read-graham')
+ >>> team_admin_browser.getControl('Name').value = will_name
+ >>> team_admin_browser.getControl('Title').value = ''
+ >>> team_admin_browser.getControl('Create').click()
+
+ >>> print "\n".join(get_feedback_messages(team_admin_browser.contents))
+ There is 1 error.
+ Required input is missing.
+
+If we try to add a new option with the same name of a existing option, we
+should get a nice error message
+
+ >>> team_admin_browser.getControl('Name').value = bill_name
+ >>> team_admin_browser.getControl('Title').value = 'Bill Again'
+ >>> team_admin_browser.getControl('Create').click()
+
+ >>> print "\n".join(get_feedback_messages(team_admin_browser.contents))
+ There is 1 error.
+ ...is already in use by another option in this poll.
+
+It's not possible to add/edit a poll option after a poll is open or closed.
+That's only possible when the poll is not yet open.
+
+ >>> team_admin_browser.open(
+ ... 'http://launchpad.dev/~ubuntu-team/+poll/director-2004/+newoption')
+
+ >>> "\n".join(get_feedback_messages(team_admin_browser.contents))
+ u'You can’t add new options because the poll is already closed.'
+
+ >>> team_admin_browser.open(
+ ... 'http://launchpad.dev/~ubuntu-team/+poll/never-closes/+newoption')
+ >>> "\n".join(get_feedback_messages(team_admin_browser.contents))
+ u'You can’t add new options because the poll is already open.'
=== added file 'lib/lp/registry/stories/team-polls/create-polls.txt'
--- lib/lp/registry/stories/team-polls/create-polls.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/stories/team-polls/create-polls.txt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,163 @@
+Let's first setup some objects that we'll need.
+
+ >>> no_priv_browser = setupBrowser(
+ ... auth='Basic no-priv@xxxxxxxxxxxxx:test')
+ >>> team_admin_browser = setupBrowser(
+ ... 'Basic jeff.waugh@xxxxxxxxxxxxxxx:jdub')
+
+If you're not logged in and go to the +polls page of the "Ubuntu Team"
+you'll see a link to login as a team administrator.
+
+ >>> anon_browser.open('http://launchpad.dev/~ubuntu-team')
+ >>> anon_browser.getLink('Show polls').click()
+ >>> anon_browser.url
+ 'http://launchpad.dev/~ubuntu-team/+polls'
+ >>> anon_browser.getLink('Log in as an admin to set up a new poll').url
+ 'http://launchpad.dev/~ubuntu-team/+login'
+
+Try to create a new poll logged in as 'no-priv', which is not a team
+administrator. There's no link leading to the +newpoll page, but the user can
+easily guess it.
+
+ >>> no_priv_browser.open('http://launchpad.dev/~ubuntu-team/+newpoll')
+ Traceback (most recent call last):
+ ...
+ Unauthorized:...
+
+Now we're logged in as Jeff Waugh which is a team administrator and thus can
+create a new poll.
+
+ >>> team_admin_browser.open('http://launchpad.dev/~ubuntu-team')
+ >>> team_admin_browser.getLink('Show polls').click()
+ >>> team_admin_browser.getLink('Set up a new poll').click()
+ >>> team_admin_browser.url
+ 'http://launchpad.dev/~ubuntu-team/+newpoll'
+
+ >>> team_admin_browser.title
+ 'New poll for team Ubuntu Team...
+
+First we try to create a poll with a invalid name to
+test the name field validator.
+
+ >>> team_admin_browser.getControl(
+ ... 'The unique name of this poll').value = 'election_2100'
+ >>> team_admin_browser.getControl(
+ ... 'The title of this poll').value = 'Presidential Election 2100'
+ >>> proposition = 'Who is going to be the next president?'
+ >>> team_admin_browser.getControl(
+ ... 'The proposition that is going to be voted').value = proposition
+ >>> team_admin_browser.getControl(
+ ... 'Users can spoil their votes?').selected = True
+ >>> team_admin_browser.getControl(
+ ... name='field.dateopens').value = '2100-06-04 02:00:00+00:00'
+ >>> team_admin_browser.getControl(
+ ... name='field.datecloses').value = '2100-07-04 02:00:00+00:00'
+ >>> team_admin_browser.getControl('Continue').click()
+
+ >>> print "\n".join(get_feedback_messages(team_admin_browser.contents))
+ There is 1 error.
+ Invalid name 'election_2100'. Names must be at least two characters ...
+
+We fix the name, but swap the dates. Again a nice error message.
+
+ >>> team_admin_browser.getControl(
+ ... 'The unique name of this poll').value = 'election-2100'
+ >>> team_admin_browser.getControl(
+ ... name='field.dateopens').value = '2100-07-04 02:00:00+00:00'
+ >>> team_admin_browser.getControl(
+ ... name='field.datecloses').value = '2100-06-04 02:00:00+00:00'
+ >>> team_admin_browser.getControl('Continue').click()
+
+ >>> print "\n".join(get_feedback_messages(team_admin_browser.contents))
+ There is 1 error.
+ A poll cannot close at the time (or before) it opens.
+
+Now we get it right.
+
+ >>> team_admin_browser.getControl(
+ ... name='field.dateopens').value = '2100-06-04 02:00:00+00:00'
+ >>> team_admin_browser.getControl(
+ ... name='field.datecloses').value = '2100-07-04 02:00:00+00:00'
+ >>> team_admin_browser.getControl('Continue').click()
+
+We're redirected to the newly created poll page.
+
+ >>> team_admin_browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/election-2100'
+
+Create a new poll that starts in 2025-06-04 and will last until 2035.
+
+ >>> team_admin_browser.open(
+ ... 'http://launchpad.dev/~ubuntu-team/+newpoll')
+ >>> team_admin_browser.getControl(
+ ... 'The unique name of this poll').value = 'dpl-2080'
+ >>> team_admin_browser.getControl(
+ ... 'The title of this poll').value = 'Debian Project Leader Election 2080'
+ >>> proposition = 'The next debian project leader'
+ >>> team_admin_browser.getControl(
+ ... 'The proposition that is going to be voted').value = proposition
+ >>> team_admin_browser.getControl(
+ ... 'Users can spoil their votes?').selected = True
+ >>> team_admin_browser.getControl(
+ ... name='field.dateopens').value = '2025-06-04 02:00:00+00:00'
+ >>> team_admin_browser.getControl(
+ ... name='field.datecloses').value = '2035-06-04 02:00:00+00:00'
+ >>> team_admin_browser.getControl('Continue').click()
+
+We're redirected to the newly created poll
+
+ >>> team_admin_browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/dpl-2080'
+ >>> team_admin_browser.title
+ 'Debian Project Leader Election 2080 : \xe2\x80\x9cUbuntu Team\xe2\x80\x9d team'
+ >>> print_location(team_admin_browser.contents)
+ Hierarchy: ?Ubuntu Team? team > Debian Project Leader Election 2080
+ Tabs:
+ * Overview (selected) - http://launchpad.dev/~ubuntu-team
+ * Code - http://code.launchpad.dev/~ubuntu-team
+ * Bugs - http://bugs.launchpad.dev/~ubuntu-team
+ * Blueprints - http://blueprints.launchpad.dev/~ubuntu-team
+ * Translations - http://translations.launchpad.dev/~ubuntu-team
+ * Answers - http://answers.launchpad.dev/~ubuntu-team
+ Main heading: Debian Project Leader Election 2080
+ >>> team_admin_browser.getLink('add an option').url
+ 'http://launchpad.dev/%7Eubuntu-team/+poll/dpl-2080/+newoption'
+
+Now lets try to insert a poll with the name of a existing one.
+
+# XXX matsubara 2006-07-17 bug=53302:
+# There's no link to get back to +polls.
+
+ >>> team_admin_browser.open(
+ ... 'http://launchpad.dev/~ubuntu-team/+newpoll')
+ >>> team_admin_browser.getControl(
+ ... 'The unique name of this poll').value = 'dpl-2080'
+ >>> team_admin_browser.getControl(
+ ... 'The title of this poll').value = 'Debian Project Leader Election 2080'
+ >>> proposition = 'The next debian project leader'
+ >>> team_admin_browser.getControl(
+ ... 'The proposition that is going to be voted').value = proposition
+ >>> team_admin_browser.getControl(
+ ... 'Users can spoil their votes?').selected = True
+ >>> team_admin_browser.getControl(
+ ... name='field.dateopens').value = '2025-06-04 02:00:00+00:00'
+ >>> team_admin_browser.getControl(
+ ... name='field.datecloses').value = '2035-06-04 02:00:00+00:00'
+ >>> team_admin_browser.getControl('Continue').click()
+
+ >>> print "\n".join(get_feedback_messages(team_admin_browser.contents))
+ There is 1 error.
+ dpl-2080 is already in use by another poll in this team.
+
+When creating a new poll, its start date must be at least 12 hours from
+now, so that the user creating it has a chance to add some options before
+the poll opens -- at that point new options cannot be added.
+
+ >>> team_admin_browser.getControl('The unique name').value = 'today'
+ >>> from datetime import datetime
+ >>> today = datetime.today().strftime('%Y-%m-%d')
+ >>> team_admin_browser.getControl(name='field.dateopens').value = today
+ >>> team_admin_browser.getControl('Continue').click()
+ >>> print "\n".join(get_feedback_messages(team_admin_browser.contents))
+ There is 1 error.
+ A poll cannot open less than 12 hours after it's created.
=== added file 'lib/lp/registry/stories/team-polls/edit-options.txt'
--- lib/lp/registry/stories/team-polls/edit-options.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/stories/team-polls/edit-options.txt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,59 @@
+= Editing poll options =
+
+Changing the poll options detail is not possible if you are not one of the
+team's administrators:
+
+ >>> user_browser.open('http://launchpad.dev/~ubuntu-team/+polls')
+ >>> user_browser.getLink('A public poll that never closes').click()
+ >>> user_browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/never-closes4'
+ >>> print extract_text(find_tag_by_id(user_browser.contents, 'options'))
+ Name Title Active
+ OptionA OptionA Yes
+ ...
+ >>> user_browser.getLink('[Edit]')
+ Traceback (most recent call last):
+ ...
+ LinkNotFoundError
+
+And when the poll already started, administrators cannot change the options
+either:
+
+ # Need to craft the URL manually because there's no link to it -- the
+ # option can't be changed, after all.
+ >>> browser = setupBrowser(
+ ... auth='Basic jeff.waugh@xxxxxxxxxxxxxxx:jdub')
+ >>> browser.open('http://launchpad.dev/~ubuntu-team/+poll/never-closes4/'
+ ... '+option/20')
+ >>> print "\n".join(get_feedback_messages(browser.contents))
+ You can’t edit any options because the poll is already open.
+
+Since Jeff is an administrator of ubuntu-team and we have a poll that hasn't
+been opened yet, he should be able to edit its options.
+
+ >>> browser.open('http://launchpad.dev/~ubuntu-team/+polls')
+ >>> browser.getLink('A public poll that has not opened yet').click()
+ >>> browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/not-yet-opened'
+
+ >>> browser.getLink('[Edit]').click()
+ >>> browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/not-yet-opened/+option/...'
+
+ >>> browser.getControl('Name').value
+ 'OptionX'
+ >>> browser.getControl('Title').value
+ 'OptionX'
+ >>> browser.getControl('Name').value = 'option-z'
+ >>> browser.getControl('Title').value = 'Option Z'
+ >>> browser.getControl('Save').click()
+
+ >>> browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/not-yet-opened'
+ >>> print find_portlet(browser.contents, 'Voting options').renderContents()
+ <BLANKLINE>
+ <h2>Voting options</h2>
+ ...
+ ...option-z...
+ ...
+
=== added file 'lib/lp/registry/stories/team-polls/edit-poll.txt'
--- lib/lp/registry/stories/team-polls/edit-poll.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/stories/team-polls/edit-poll.txt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,97 @@
+= Editing a poll =
+
+All attributes of a poll can be changed as long as the poll has not opened
+yet.
+
+First we create a new poll to use throughout this test.
+
+ >>> login('jeff.waugh@xxxxxxxxxxxxxxx')
+ >>> from zope.component import getUtility
+ >>> from lp.registry.interfaces.person import IPersonSet
+ >>> factory.makePoll(getUtility(IPersonSet).getByName('ubuntu-team'),
+ ... 'dpl-2080', 'dpl-2080', 'dpl-2080')
+ <Poll...
+ >>> logout()
+
+Now we'll try to change its name to something that is already in use.
+
+ >>> team_admin_browser = setupBrowser(
+ ... auth='Basic jeff.waugh@xxxxxxxxxxxxxxx:jdub')
+ >>> team_admin_browser.open(
+ ... 'http://launchpad.dev/~ubuntu-team/+poll/dpl-2080')
+ >>> team_admin_browser.getLink('Change details').click()
+
+ >>> team_admin_browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/dpl-2080/+edit'
+
+ >>> team_admin_browser.getControl(
+ ... 'The unique name of this poll').value = 'never-closes'
+ >>> team_admin_browser.getControl('Save').click()
+
+ >>> print "\n".join(get_feedback_messages(team_admin_browser.contents))
+ There is 1 error.
+ ...never-closes is already in use by another poll in this team.
+
+Entering an end date that precedes the start date returns a nice error
+message.
+
+ >>> team_admin_browser.getControl(
+ ... 'The unique name of this poll').value = 'dpl-2080'
+ >>> team_admin_browser.getControl(
+ ... name='field.dateopens').value = '3000-11-01 00:00:00+00:00'
+ >>> team_admin_browser.getControl(
+ ... name='field.datecloses').value = '3000-01-01 00:00:00+00:00'
+ >>> team_admin_browser.getControl('Save').click()
+
+ >>> print "\n".join(get_feedback_messages(team_admin_browser.contents))
+ There is 1 error.
+ A poll cannot close at the time (or before) it opens.
+
+We successfully change the polls name
+
+ >>> team_admin_browser.getControl(
+ ... 'The unique name of this poll').value = 'election-3000'
+ >>> team_admin_browser.getControl(
+ ... name='field.dateopens').value = '3000-01-01 00:00:00+00:00'
+ >>> team_admin_browser.getControl(
+ ... name='field.datecloses').value = '3000-11-01 00:00:00+00:00'
+ >>> team_admin_browser.getControl('Save').click()
+
+ >>> team_admin_browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/election-3000'
+
+Trying to edit a poll that's already open isn't possible.
+
+ >>> team_admin_browser.open('http://launchpad.dev/~ubuntu-team/')
+ >>> team_admin_browser.getLink('Show polls').click()
+ >>> team_admin_browser.url
+ 'http://launchpad.dev/~ubuntu-team/+polls'
+
+ >>> team_admin_browser.getLink('A random poll that never closes').click()
+ >>> team_admin_browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/never-closes/+vote'
+
+ >>> team_admin_browser.open(
+ ... 'http://launchpad.dev/~ubuntu-team/+poll/never-closes/+edit')
+ >>> print extract_text(
+ ... find_tag_by_id(team_admin_browser.contents, 'not-editable'))
+ This poll can't be edited...
+
+It's also not possible to edit a poll that's already closed.
+
+ >>> team_admin_browser.open('http://launchpad.dev/~ubuntu-team/')
+ >>> team_admin_browser.getLink('Show polls').click()
+ >>> team_admin_browser.url
+ 'http://launchpad.dev/~ubuntu-team/+polls'
+
+ >>> team_admin_browser.getLink("2004 Director's Elections").click()
+ >>> team_admin_browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/director-2004'
+
+ >>> 'Voting has closed' in team_admin_browser.contents
+ True
+
+ >>> team_admin_browser.getLink('Change details').click()
+ >>> print extract_text(
+ ... find_tag_by_id(team_admin_browser.contents, 'not-editable'))
+ This poll can't be edited...
=== added file 'lib/lp/registry/stories/team-polls/vote-poll.txt'
--- lib/lp/registry/stories/team-polls/vote-poll.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/stories/team-polls/vote-poll.txt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,167 @@
+= Voting on polls =
+
+Foo Bar (a member of the ubuntu-team) wants to vote on the 'never-closes'
+poll, which is a poll with secret votes, which means he'll get a token that he
+must use to see/change his vote afterwards.
+
+ >>> browser = setupBrowser(auth='Basic foo.bar@xxxxxxxxxxxxx:test')
+ >>> browser.open('http://launchpad.dev/~ubuntu-team/+polls')
+ >>> browser.getLink('A random poll that never closes').click()
+ >>> browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/never-closes/+vote'
+
+ >>> print find_tag_by_id(browser.contents, 'your-vote').renderContents()
+ <BLANKLINE>
+ ...
+ <h2>Your current vote</h2>
+ ...You have not yet voted in this poll...
+ <h2>Vote now</h2>
+ ...
+
+ >>> browser.getControl('None of these options').selected = True
+ >>> browser.getControl('Continue').click()
+
+ >>> browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/never-closes/+vote'
+
+ >>> tags = find_tags_by_class(browser.contents, "informational message")
+ >>> for tag in tags:
+ ... print tag.renderContents()
+ Your vote has been recorded. If you want to view or change it later you
+ must write down this key: ...
+
+ >>> print find_tag_by_id(browser.contents, 'your-vote').renderContents()
+ <BLANKLINE>
+ ...
+ <h2>Your current vote</h2>
+ ...Your current vote is for <b> none of the options. </b>...
+ ...
+
+Foo Bar will now vote on a poll with public votes.
+
+ >>> browser.open('http://launchpad.dev/~ubuntu-team/+polls')
+ >>> browser.getLink('A public poll that never closes').click()
+ >>> browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/never-closes4/+vote'
+
+ >>> print find_tag_by_id(browser.contents, 'your-vote').renderContents()
+ <BLANKLINE>
+ ...
+ <h2>Your current vote</h2>
+ ...You have not yet voted in this poll...
+ <h2>Vote now</h2>
+ ...
+
+ >>> browser.getControl('OptionB').selected = True
+ >>> browser.getControl('Continue').click()
+
+ >>> browser.url
+ 'http://launchpad.dev/~ubuntu-team/+poll/never-closes4/+vote'
+
+ >>> tags = find_tags_by_class(browser.contents, "informational message")
+ >>> for tag in tags:
+ ... print tag.renderContents()
+ Your vote was stored successfully. You can come back to this page at any
+ time before this poll closes to view or change your vote, if you want.
+
+ >>> print find_tag_by_id(browser.contents, 'your-vote').renderContents()
+ <BLANKLINE>
+ ...
+ <h2>Your current vote</h2>
+ ...Your current vote is for <b>OptionB</b>...
+ ...
+
+
+For convenience we provide an option for when the user doesn't want to vote
+yet.
+
+ >>> team_admin_browser = setupBrowser(
+ ... auth='Basic jeff.waugh@xxxxxxxxxxxxxxx:jdub')
+ >>> team_admin_browser.open(
+ ... 'http://launchpad.dev/~ubuntu-team/+poll/never-closes/+vote')
+ >>> not_yet_voted_message = 'You have not yet voted in this poll.'
+ >>> not_yet_voted_message in team_admin_browser.contents
+ True
+
+ >>> team_admin_browser.getControl(name='newoption').value = ["donotvote"]
+ >>> team_admin_browser.getControl(name='continue').click()
+
+ >>> contents = team_admin_browser.contents
+ >>> for tag in find_tags_by_class(contents, "informational message"):
+ ... print tag.renderContents()
+ You chose not to vote yet.
+
+ >>> print find_tag_by_id(contents, 'your-vote').renderContents()
+ <BLANKLINE>
+ ...
+ <h2>Your current vote</h2>
+ ...You have not yet voted in this poll...
+ ...
+
+
+== No permission to vote ==
+
+Only members of a given team can vote on that team's polls. Other users can't,
+even if they guess the URL for the voting page.
+
+ >>> non_member_browser = setupBrowser(
+ ... auth='Basic test@xxxxxxxxxxxxx:test')
+ >>> non_member_browser.open(
+ ... 'http://launchpad.dev/~ubuntu-team/+poll/never-closes/+vote')
+ >>> for tag in find_tags_by_class(
+ ... non_member_browser.contents, "informational message"):
+ ... print tag.renderContents()
+ You can’t vote in this poll because you’re not a member
+ of Ubuntu Team.
+
+
+== Closed polls ==
+
+It's not possible to vote on closed polls, even if we manually craft the URL.
+
+ >>> team_admin_browser.open(
+ ... 'http://launchpad.dev/~ubuntu-team/+poll/leader-2004')
+ >>> print find_tag_by_id(
+ ... team_admin_browser.contents, 'maincontent').renderContents()
+ <BLANKLINE>
+ ...
+ <h2>Voting has closed</h2>
+ ...
+
+ >>> team_admin_browser.open(
+ ... 'http://launchpad.dev/~ubuntu-team/+poll/leader-2004/+vote')
+ >>> print find_tag_by_id(
+ ... team_admin_browser.contents, 'maincontent').renderContents()
+ <BLANKLINE>
+ ...
+ <p class="informational message">
+ This poll is already closed.
+ </p>
+ ...
+
+ >>> team_admin_browser.getControl(name='continue')
+ Traceback (most recent call last):
+ ...
+ LookupError: name 'continue'
+
+The same is true for condorcet polls too.
+
+ >>> team_admin_browser.open(
+ ... 'http://launchpad.dev/~ubuntu-team/+poll/director-2004')
+ >>> print find_tag_by_id(
+ ... team_admin_browser.contents, 'maincontent').renderContents()
+ <BLANKLINE>
+ ...
+ <h2>Voting has closed</h2>
+ ...
+
+ >>> team_admin_browser.getControl(name='continue')
+ Traceback (most recent call last):
+ ...
+ LookupError: name 'continue'
+
+ >>> team_admin_browser.open(
+ ... 'http://launchpad.dev/~ubuntu-team/+poll/director-2004/+vote')
+ >>> for message in get_feedback_messages(team_admin_browser.contents):
+ ... print message
+ This poll is already closed.
=== added file 'lib/lp/registry/stories/team-polls/xx-poll-condorcet-voting.txt'
--- lib/lp/registry/stories/team-polls/xx-poll-condorcet-voting.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/stories/team-polls/xx-poll-condorcet-voting.txt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,229 @@
+# XXX Guilherme Salgado, 2006-01-19:
+# Merge this test with team-polls/xx-votepoll.txt
+
+ Go to a condorcet-style poll (which is still open) and check that apart
+ from seeing our vote we can also change it.
+
+ >>> print http(r"""
+ ... GET /~ubuntu-team/+poll/never-closes2 HTTP/1.1
+ ... Accept-Language: en-us,en;q=0.5
+ ... Authorization: Basic foo.bar@xxxxxxxxxxxxx:test
+ ... """)
+ HTTP/1.1 303 See Other
+ ...
+ Location: http://localhost/~ubuntu-team/+poll/never-closes2/+vote
+ ...
+
+ >>> print http(r"""
+ ... GET /~ubuntu-team/+poll/never-closes2/+vote HTTP/1.1
+ ... Authorization: Basic foo.bar@xxxxxxxxxxxxx:test
+ ... """)
+ HTTP/1.1 200 Ok
+ ...
+ ...You must enter your vote key...
+ ...This is a secret poll...
+ ...your vote is identified only by the key you...
+ ...were given when you voted. To view or change your vote you must enter...
+ ...your key:...
+ ...
+
+
+ If a non-member (Sample Person) guesses the voting URL and tries to vote,
+ he won't be allowed.
+
+ >>> print http(r"""
+ ... GET /~ubuntu-team/+poll/never-closes2/+vote HTTP/1.1
+ ... Authorization: Basic dGVzdEBjYW5vbmljYWwuY29tOnRlc3Q=
+ ... """)
+ HTTP/1.1 200 Ok
+ ...You can’t vote in this poll because you’re not...
+ ...a member of Ubuntu Team...
+
+
+ By providing the token we will be able to see our current vote.
+
+ >>> print http(r"""
+ ... POST /~ubuntu-team/+poll/never-closes2/+vote HTTP/1.1
+ ... Authorization: Basic foo.bar@xxxxxxxxxxxxx:test
+ ... Content-Type: application/x-www-form-urlencoded
+ ...
+ ... token=xn9FDCTp4m&showvote=Show+My+Vote&option_12=&option_13=&option_14=&option_15=""")
+ HTTP/1.1 200 Ok
+ ...
+ <p>Your current vote is as follows:</p>
+ <p>
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>1</b>.
+ Option 1
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>2</b>.
+ Option 2
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>3</b>.
+ Option 4
+ <BLANKLINE>
+ </p>
+ ...
+
+
+ It's also possible to change the vote, if wanted.
+
+ >>> print http(r"""
+ ... POST /~ubuntu-team/+poll/never-closes2/+vote HTTP/1.1
+ ... Authorization: Basic foo.bar@xxxxxxxxxxxxx:test
+ ... Content-Type: application/x-www-form-urlencoded
+ ...
+ ... token=xn9FDCTp4m&option_12=2&option_13=3&option_14=4&option_15=1&changevote=Change+Vote""")
+ HTTP/1.1 200 Ok
+ ...
+ ...Your vote was changed successfully.</p>
+ ...
+ <p>Your current vote is as follows:</p>
+ <p>
+ <BLANKLINE>
+ <b>1</b>.
+ Option 4
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>2</b>.
+ Option 1
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>3</b>.
+ Option 2
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>4</b>.
+ Option 3
+ <BLANKLINE>
+ </p>
+ ...
+
+
+ Now we go to another poll in which name16 voted. But this time it's a public
+ one, so there's no need to provide the token to see the current vote.
+
+ >>> print http(r"""
+ ... GET /~ubuntu-team/+poll/never-closes3 HTTP/1.1
+ ... Authorization: Basic foo.bar@xxxxxxxxxxxxx:test
+ ... """)
+ HTTP/1.1 303 See Other
+ ...
+ Location: http://localhost/~ubuntu-team/+poll/never-closes3/+vote
+ ...
+
+ >>> print http(r"""
+ ... GET /~ubuntu-team/+poll/never-closes3/+vote HTTP/1.1
+ ... Authorization: Basic foo.bar@xxxxxxxxxxxxx:test
+ ... """)
+ HTTP/1.1 200 Ok
+ ...
+ <p>Your current vote is as follows:</p>
+ <p>
+ <BLANKLINE>
+ <b>1</b>.
+ Option 1
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>2</b>.
+ Option 2
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>3</b>.
+ Option 3
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>4</b>.
+ Option 4
+ <BLANKLINE>
+ </p>
+ ...
+
+
+ Now we change the vote and we see the new vote displayed as our current
+ vote.
+
+ >>> print http(r"""
+ ... POST /~ubuntu-team/+poll/never-closes3/+vote HTTP/1.1
+ ... Authorization: Basic foo.bar@xxxxxxxxxxxxx:test
+ ... Content-Type: application/x-www-form-urlencoded
+ ...
+ ... option_16=4&option_17=2&option_18=1&option_19=3&changevote=Change+Vote""")
+ HTTP/1.1 200 Ok
+ ...
+ <p>Your current vote is as follows:</p>
+ <p>
+ <BLANKLINE>
+ <b>1</b>.
+ Option 3
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>2</b>.
+ Option 2
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>3</b>.
+ Option 4
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>4</b>.
+ Option 1
+ <BLANKLINE>
+ </p>
+ ...
+
+
+ Logged in as mark@xxxxxxxxxxx (which is a member of ubuntu-team), go to a public
+ condorcet-style poll that's still open and get redirected to a page where
+ it's possible to vote (and see the current vote).
+
+ >>> print http(r"""
+ ... GET /~ubuntu-team/+poll/never-closes3 HTTP/1.1
+ ... Authorization: Basic mark@xxxxxxxxxxx:test
+ ... """)
+ HTTP/1.1 303 See Other
+ ...
+ Location: http://localhost/~ubuntu-team/+poll/never-closes3/+vote
+ ...
+
+
+ And here we'll see the form which says you haven't voted yet and allows you
+ to vote.
+
+ >>> print http(r"""
+ ... GET /~ubuntu-team/+poll/never-closes3/+vote HTTP/1.1
+ ... Authorization: Basic mark@xxxxxxxxxxx:test
+ ... """)
+ HTTP/1.1 200 Ok
+ ...
+ ...Your current vote...
+ ...You have not yet voted in this poll...
+ ...Rank options in order...
+ ...
=== added file 'lib/lp/registry/stories/team-polls/xx-poll-confirm-vote.txt'
--- lib/lp/registry/stories/team-polls/xx-poll-confirm-vote.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/stories/team-polls/xx-poll-confirm-vote.txt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,89 @@
+ Logged in as 'jdub' (which voted in the director-2004 poll), let's see the
+ results of the director-2004 poll.
+
+ >>> print http(r"""
+ ... GET /~ubuntu-team/+poll/director-2004 HTTP/1.1
+ ... Authorization: Basic amVmZi53YXVnaEB1YnVudHVsaW51eC5jb206amR1Yg==
+ ... """)
+ HTTP/1.1 200 Ok
+ ...
+ ...2004 Director's Elections...
+ ...
+ ...This was a secret poll: your vote is identified only by the key...
+ ...you were given when you voted. To view your vote you must enter...
+ ...your key:...
+ ...Results...
+ ...This is the pairwise matrix for this poll...
+ ...
+
+
+ Now let's see if jdub's vote was stored correctly, by entering the token he
+ got when voting.
+
+ >>> print http(r"""
+ ... POST /~ubuntu-team/+poll/director-2004 HTTP/1.1
+ ... Authorization: Basic amVmZi53YXVnaEB1YnVudHVsaW51eC5jb206amR1Yg==
+ ... Content-Type: application/x-www-form-urlencoded
+ ...
+ ... token=9WjxQq2V9p&showvote=Show+My+Vote""")
+ HTTP/1.1 200 Ok
+ ...
+ <p>Your vote was as follows:</p>
+ <p>
+ <BLANKLINE>
+ <b>1</b>.
+ D
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>2</b>.
+ B
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>3</b>.
+ A
+ <BLANKLINE>
+ </p>
+ <p>
+ <BLANKLINE>
+ <b>3</b>.
+ C
+ <BLANKLINE>
+ </p>
+ ...
+
+
+ Now we'll see the results of the leader-2004 poll, in which jdub also
+ voted.
+
+ >>> print http(r"""
+ ... GET /~ubuntu-team/+poll/leader-2004 HTTP/1.1
+ ... Authorization: Basic amVmZi53YXVnaEB1YnVudHVsaW51eC5jb206amR1Yg==
+ ... """)
+ HTTP/1.1 200 Ok
+ ...
+ ...2004 Leader's Elections...
+ ...
+ ...This was a secret poll: your vote is identified only by the key...
+ ...you were given when you voted. To view your vote you must enter...
+ ...your key:...
+ ...
+
+
+ And now we confirm his vote on this poll too.
+
+ >>> print http(r"""
+ ... POST /~ubuntu-team/+poll/leader-2004 HTTP/1.1
+ ... Authorization: Basic amVmZi53YXVnaEB1YnVudHVsaW51eC5jb206amR1Yg==
+ ... Content-Type: application/x-www-form-urlencoded
+ ...
+ ... token=W7gR5mjNrX&showvote=Show+My+Vote""")
+ HTTP/1.1 200 Ok
+ ...
+ <p>Your vote was for
+ <BLANKLINE>
+ <b>Jack Crawford</b></p>
+ ...
=== added file 'lib/lp/registry/stories/team-polls/xx-poll-results.txt'
--- lib/lp/registry/stories/team-polls/xx-poll-results.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/stories/team-polls/xx-poll-results.txt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,69 @@
+First we check all polls of 'ubuntu-team'.
+
+ >>> anon_browser.open("http://launchpad.dev/~ubuntu-team")
+ >>> anon_browser.getLink('Show polls').click()
+ >>> print find_main_content(anon_browser.contents)
+ <...
+ ...Current polls...
+ ...A random poll that never closes...
+ ...A second random poll that never closes...
+ ...A third random poll that never closes...
+ ...Closed polls...
+ ...2004 Director's Elections...
+ ...2004 Leader's Elections...
+
+
+ Check the results of a closed simple-style poll.
+
+ >>> anon_browser.open("http://launchpad.dev/~ubuntu-team/+poll/leader-2004")
+ >>> print find_main_content(anon_browser.contents)
+ <...
+ ...Who's going to be the next leader?...
+ ...Results...
+ ...
+ <td>
+ Francis Dolarhyde
+ <BLANKLINE>
+ </td>
+ <td>1</td>
+ ...
+ <td>
+ Jack Crawford
+ <BLANKLINE>
+ </td>
+ <td>1</td>
+ ...
+ <td>
+ Will Graham
+ <BLANKLINE>
+ </td>
+ <td>2</td>
+ ...
+
+
+ Check the results of a closed condorcet-style poll.
+
+ >>> anon_browser.open("http://launchpad.dev/~ubuntu-team/+poll/director-2004")
+ >>> print find_main_content(anon_browser.contents)
+ <...
+ ...Who's going to be the next director?...
+ ...Results...
+ ...
+ ...A...
+ ...2...
+ ...2...
+ ...2...
+ ...B...
+ ...2...
+ ...2...
+ ...2...
+ ...C...
+ ...1...
+ ...1...
+ ...1...
+ ...D...
+ ...2...
+ ...1...
+ ...2...
+ ...
+
=== modified file 'lib/lp/registry/stories/team/xx-team-home.txt'
--- lib/lp/registry/stories/team/xx-team-home.txt 2010-12-15 22:11:11 +0000
+++ lib/lp/registry/stories/team/xx-team-home.txt 2011-01-04 16:38:41 +0000
@@ -1,5 +1,4 @@
-A team's home page
-==================
+= A team's home page =
The home page of a public team is visible to everyone.
@@ -60,6 +59,17 @@
Languages:
English
+The polls portlet is only shown if current polls exist.
+
+ >>> print extract_text(find_tag_by_id(browser.contents, 'polls'))
+ Polls
+ A random poll that never closes...
+ Show polls
+
+ >>> browser.open('http://launchpad.dev/~launchpad')
+ >>> print find_tag_by_id(browser.contents, 'polls')
+ None
+
The subteam-of portlet is not shown if the team is not a subteam.
>>> browser.open('http://launchpad.dev/~ubuntu-team')
@@ -180,8 +190,7 @@
...
-Team admins
------------
+== Team admins ==
Team owners and admins can see a link to approve and decline applicants.
@@ -198,8 +207,7 @@
<Link text='Approve or decline members' url='.../+editproposedmembers'>
-Non members
------------
+== Non members ==
No Privileges Person is not a member of the Ubuntu team.
@@ -212,8 +220,7 @@
He can see the contact address, and the link explains the email
will actually go to the team's administrators.
- >>> print extract_text(
- ... find_tag_by_id(user_browser.contents, 'contact-email'))
+ >>> print extract_text(find_tag_by_id(user_browser.contents, 'contact-email'))
Email:
support@xxxxxxxxxx
>>> content = find_tag_by_id(user_browser.contents, 'contact-user')
=== added file 'lib/lp/registry/templates/poll-edit.pt'
--- lib/lp/registry/templates/poll-edit.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/templates/poll-edit.pt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,34 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad"
+>
+
+<body>
+
+ <div metal:fill-slot="main">
+
+ <div tal:condition="context/isNotYetOpened">
+ <div metal:use-macro="context/@@launchpad_form/form">
+ <h1 metal:fill-slot="heading">
+ Edit poll “<span tal:replace="context/title" />”
+ </h1>
+ </div>
+ </div>
+
+ <div tal:condition="not: context/isNotYetOpened" id="not-editable">
+ <h1>This poll can't be edited</h1>
+
+ <p>Only polls that are not yet opened can be edited. As soon as a poll
+ opens it can't be edited anymore.</p>
+ </div>
+
+ <tal:menu replace="structure view/@@+related-pages" />
+
+ </div>
+
+</body>
+</html>
=== added file 'lib/lp/registry/templates/poll-index.pt'
--- lib/lp/registry/templates/poll-index.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/templates/poll-index.pt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,207 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_side"
+ i18n:domain="launchpad"
+>
+<body>
+
+<tal:heading metal:fill-slot="heading">
+ <h1 tal:content="context/title">Mozilla</h1>
+</tal:heading>
+
+<div metal:fill-slot="main">
+
+ <tal:do-this-first replace="view/setUpTokenAndVotes" />
+
+ <div
+ class="highlighted"
+ tal:content="structure context/proposition/fmt:text-to-html"
+ />
+ <br />
+
+ <p tal:condition="not: context/getActiveOptions">
+ This poll does not yet have any voting options. Please <a
+ href="+newoption">add an option</a>. Note, you need more than one option
+ for a real poll, of course :-)
+ </p>
+
+ <div class="two-column-list">
+ <dl>
+ <dt>Opens:</dt>
+ <dd
+ tal:attributes="title context/dateopens/fmt:datetime"
+ tal:content="context/dateopens/fmt:approximatedate" />
+ </dl>
+
+ <dl>
+ <dt>Type:</dt>
+ <dd tal:content="context/type/title" />
+ </dl>
+
+ <dl>
+ <dt>Closes:</dt>
+ <dd
+ tal:attributes="title context/datecloses/fmt:datetime"
+ tal:content="context/datecloses/fmt:approximatedate" />
+ </dl>
+
+ <dl>
+ <dt>Secrecy:</dt>
+ <dd tal:content="context/secrecy/title" />
+ </dl>
+ </div>
+ <br />
+
+ <tal:details replace="structure context/@@+portlet-options" />
+ <br />
+
+ <tal:is_open condition="context/isOpen">
+ <p tal:condition="not: request/lp:person">
+ You need to <a href="+login">login to vote</a>.
+ </p>
+ </tal:is_open>
+
+ <tal:block condition="context/isClosed">
+
+ <h2>Voting has closed</h2>
+
+ <p>Voting closed
+ <span
+ tal:attributes="title context/datecloses/fmt:datetime"
+ tal:content="context/datecloses/fmt:displaydate" />.
+ </p>
+
+ <tal:block condition="view/userVoted">
+ <tal:block condition="view/isSecret">
+ <tal:block condition="not: view/gotTokenAndVotes">
+ <p>
+ This was a secret poll: your vote is identified only by the key
+ you were given when you voted. To view your vote you must enter
+ your key:
+ </p>
+ <form action="" method="POST">
+ <input type="text" name="token" />
+ <input type="submit" value="Show My Vote" name="showvote" />
+ </form>
+ </tal:block>
+ </tal:block>
+
+ <tal:block condition="view/gotTokenAndVotes">
+ <tal:block condition="view/isSimple">
+ <p>Your vote was for
+ <b tal:condition="not: view/currentVote/option">
+ none of the options.
+ </b>
+ <b tal:condition="view/currentVote/option"
+ tal:content="view/currentVote/option/name" /></p>
+ </tal:block>
+
+ <tal:block condition="view/isCondorcet">
+ <tal:block condition="view/currentVotes">
+ <p>Your vote was as follows:</p>
+ <p tal:repeat="vote view/currentVotes">
+ <tal:block tal:condition="vote/preference">
+ <b tal:content="vote/preference" />.
+ <span tal:replace="vote/option/name" />
+ </tal:block>
+ </p>
+ </tal:block>
+
+ <tal:block condition="not: view/currentVotes">
+ <p>You haven't voted for any of the existing options.</p>
+ </tal:block>
+ </tal:block>
+
+ </tal:block>
+ </tal:block>
+
+ <h2>Results</h2>
+
+ <tal:block condition="view/isSimple">
+ <tal:block define="winners context/getWinners">
+ <p tal:condition="winners">The winner(s) of this poll is(are)
+ <tal:block repeat="winner winners">
+ <b tal:content="winner/title"
+ /><span tal:condition="not: repeat/winner/end">,</span>
+ </tal:block>
+ </p>
+
+ <p tal:condition="not: winners">This poll has no winner(s).</p>
+ </tal:block>
+
+ <p>Here are the number of votes each option received.</p>
+ <table class="listing">
+ <thead>
+ <tr>
+ <th>Option</th>
+ <th>Votes</th>
+ </tr>
+ </thead>
+
+ <tr tal:repeat="option context/getAllOptions">
+ <tal:block define="votes python: view.getVotesByOption(option)">
+ <td>
+ <span tal:replace="option/title" />
+ <tal:block tal:condition="not: option/active">
+ (Inactive)
+ </tal:block>
+ </td>
+ <td tal:content="votes">
+ </td>
+ </tal:block>
+ </tr>
+ </table>
+ </tal:block>
+
+ <tal:block condition="view/isCondorcet">
+ <p>This is the pairwise matrix for this poll.</p>
+
+ <table border="2"
+ tal:define="pairwise_matrix view/getPairwiseMatrixWithHeaders">
+ <tr tal:repeat="row pairwise_matrix">
+ <tal:block repeat="column pairwise_matrix">
+ <tal:block tal:define="x repeat/row/index; y repeat/column/index">
+ <td tal:condition="python: x == y"
+ style="background-color: black" />
+
+ <tal:block condition="python: x != y">
+ <td tal:condition="python: x != 0 and y != 0"
+ style="text-align: right">
+ <span tal:replace="python: pairwise_matrix[x][y]" />
+ </td>
+ <td tal:condition="python: x == 0 or y == 0">
+ <span tal:replace="python: pairwise_matrix[x][y]" />
+ </td>
+ </tal:block>
+ </tal:block>
+ </tal:block>
+ </tr>
+ </table>
+ </tal:block>
+
+ </tal:block>
+
+ <tal:block condition="context/isNotYetOpened">
+ <h2>Voting hasn't opened yet</h2>
+
+ <p>
+ The vote will commence
+ <span
+ tal:attributes="title context/dateopens/fmt:datetime"
+ tal:content="context/dateopens/fmt:displaydate" />.
+ </p>
+ </tal:block>
+
+</div>
+
+<div metal:fill-slot="side">
+ <div id="object-actions" class="top-portlet">
+ <tal:menu replace="structure view/@@+global-actions" />
+ </div>
+</div>
+
+</body>
+</html>
=== added file 'lib/lp/registry/templates/poll-newoption.pt'
--- lib/lp/registry/templates/poll-newoption.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/templates/poll-newoption.pt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,36 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad"
+>
+
+<body>
+
+ <div metal:fill-slot="main">
+
+ <tal:block condition="context/isNotYetOpened">
+ <div metal:use-macro="context/@@launchpad_form/form">
+
+ <h1 metal:fill-slot="heading">
+ Add a poll option
+ </h1>
+
+ </div>
+ </tal:block>
+
+ <tal:block condition="not: context/isNotYetOpened">
+ <p class="error message" tal:condition="context/isClosed">
+ You can’t add new options because the poll is already closed.
+ </p>
+ <p class="error message" tal:condition="context/isOpen">
+ You can’t add new options because the poll is already open.
+ </p>
+ </tal:block>
+
+ </div>
+
+</body>
+</html>
=== added file 'lib/lp/registry/templates/poll-portlet-details.pt'
--- lib/lp/registry/templates/poll-portlet-details.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/templates/poll-portlet-details.pt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,38 @@
+<tal:root
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ omit-tag="">
+
+<div class="portlet" id="portlet-details">
+ <h2><span tal:replace="context/name" /></h2>
+
+ <div class="portletBody portletContent">
+
+ <b>Title:</b>
+ <span tal:replace="context/title" /><br />
+
+ <b>Voting team:</b>
+ <a tal:attributes="href context/team/fmt:url"
+ tal:content="context/team/displayname" /><br />
+
+ <b>Opens:</b>
+ <span
+ tal:attributes="title context/dateopens/fmt:datetime"
+ tal:content="context/dateopens/fmt:approximatedate" /><br />
+
+ <b>Closes:</b>
+ <span
+ tal:attributes="title context/datecloses/fmt:datetime"
+ tal:content="context/datecloses/fmt:approximatedate" /><br />
+
+ <b>Type:</b>
+ <span tal:replace="context/type/title" /><br />
+
+ <b>Secrecy:</b>
+ <span tal:replace="context/secrecy/title" /><br />
+
+ </div>
+
+</div>
+</tal:root>
=== added file 'lib/lp/registry/templates/poll-portlet-options.pt'
--- lib/lp/registry/templates/poll-portlet-options.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/templates/poll-portlet-options.pt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,46 @@
+<tal:root
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ omit-tag="">
+
+<div class="portlet" id="portlet-options">
+
+ <h2>Voting options</h2>
+ <tal:block condition="context/getAllOptions">
+ <table class="listing" id="options">
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th>Title</th>
+ <th>Active</th>
+ <th tal:condition="context/required:launchpad.Edit"></th>
+ </tr>
+ </thead>
+ <tr tal:repeat="polloption context/getAllOptions">
+ <td tal:content="polloption/name">mjg59</td>
+ <td tal:content="polloption/title/fmt:break-long-words">
+ This guy rocks!
+ </td>
+ <td>
+ <tal:is_active condition="polloption/active">Yes</tal:is_active>
+ <tal:inactive condition="not: polloption/active">No</tal:inactive>
+ </td>
+ <td tal:condition="context/required:launchpad.Edit">
+ <a tal:attributes="href polloption/fmt:url"
+ ><img src="/@@/edit" alt="[Edit]"
+ title="Change this option details" /></a>
+ </td>
+ </tr>
+ </table>
+ </tal:block>
+
+ <p class="warning message" tal:condition="not: context/getAllOptions">
+ This poll doesn't have any options for people to vote on yet.
+ Make sure you add some options before the poll opens!
+ </p>
+
+ <tal:new-option replace="structure context/menu:overview/addnew/render" />
+
+</div>
+</tal:root>
=== added file 'lib/lp/registry/templates/poll-vote-condorcet.pt'
--- lib/lp/registry/templates/poll-vote-condorcet.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/templates/poll-vote-condorcet.pt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,130 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad"
+>
+<body>
+
+ <tal:heading metal:fill-slot="heading">
+ <h1 tal:content="context/title">Mozilla</h1>
+ </tal:heading>
+
+ <div metal:fill-slot="main">
+
+ <tal:open-poll condition="context/isOpen">
+ <tal:can-vote condition="view/userCanVote">
+ <p
+ tal:condition="view/feedback"
+ tal:content="view/feedback"
+ class="informational message"
+ />
+
+ <div class="highlighted" style="font-size: 80%;">
+ <tal:proposition replace="structure context/proposition/fmt:text-to-html">
+ The proposition goes here.
+ </tal:proposition>
+ </div>
+
+ <form action="" method="POST">
+
+ <tal:block condition="view/userVoted">
+ <tal:block condition="view/isSecret">
+ <h2>You must enter your vote key</h2>
+
+ <p>This is a secret poll —
+ your vote is identified only by the key you
+ were given when you voted. To view or change your vote you must enter
+ your key:</p>
+
+ <input type="text" name="token"
+ tal:attributes="value view/token|nothing" />
+ <br />
+ <br />
+ </tal:block>
+ </tal:block>
+
+ <table cols="2" id="your-vote">
+ <tr>
+ <td>
+ <h2>Your current vote</h2>
+ <tal:block condition="not: view/userVoted">
+ <p>You have not yet voted in this poll.</p>
+ </tal:block>
+
+ <tal:block condition="view/userVoted">
+ <tal:block condition="view/gotTokenAndVotes">
+ <tal:block condition="view/currentVotes">
+ <p>Your current vote is as follows:</p>
+ <p tal:repeat="vote view/currentVotes">
+ <tal:block tal:condition="vote/preference">
+ <b tal:content="vote/preference" />.
+ <span tal:replace="vote/option/name" />
+ </tal:block>
+ </p>
+ </tal:block>
+
+ <tal:block condition="not: view/currentVotes">
+ <p>You haven't manifested preference for any of the existing
+ options.</p>
+ </tal:block>
+ </tal:block>
+
+ <tal:block condition="not: view/gotTokenAndVotes">
+ <p>You have voted in this poll. Launchpad can display your vote
+ once you have entered your vote key.</p>
+
+ <input type="submit" value="Show My Vote" name="showvote" />
+ </tal:block>
+ </tal:block>
+ </td>
+ <td>
+ <tal:block condition="not: view/userVoted">
+ <h2>Rank options in order of preference</h2>
+ </tal:block>
+
+ <tal:block condition="view/userVoted">
+ <h2>Change your vote</h2>
+ </tal:block>
+
+ <p>Enter 1 next to your most preferred option, 2 next to your second
+ preference, and so on. You may mark two or more options equally, or
+ leave some options unmarked, if desired.</p>
+
+ <tal:block repeat="option context/getActiveOptions">
+ <input type="text" size="2"
+ tal:attributes="name string:option_${option/id}" />
+ <span tal:replace="option/name" />
+ <br />
+ </tal:block>
+ <br />
+
+ <tal:block condition="view/userVoted">
+ <input type="submit" value="Change Vote" name="changevote" />
+ </tal:block>
+
+ <tal:block condition="not: view/userVoted">
+ <input type="submit" value="Vote" name="vote" />
+ </tal:block>
+ or <a tal:attributes="href context/team/fmt:url/+polls">Cancel</a>
+ </td>
+ </tr>
+ </table>
+ </form>
+ </tal:can-vote>
+
+ <p tal:condition="not: view/userCanVote" class="informational message">
+ You can’t vote in this poll because you’re not
+ a member of <span tal:replace="context/team/displayname" />.
+ </p>
+ </tal:open-poll>
+
+ <p tal:condition="not: context/isOpen" class="informational message">
+ This poll is already closed.
+ </p>
+
+ </div>
+</body>
+</html>
=== added file 'lib/lp/registry/templates/poll-vote-simple.pt'
--- lib/lp/registry/templates/poll-vote-simple.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/templates/poll-vote-simple.pt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,141 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad"
+>
+<body>
+
+ <tal:heading metal:fill-slot="heading">
+ <h1 tal:content="context/title">Mozilla</h1>
+ </tal:heading>
+
+ <div metal:fill-slot="main">
+
+ <tal:open-poll condition="context/isOpen">
+
+ <tal:can-vote condition="view/userCanVote">
+ <p
+ tal:condition="view/feedback"
+ tal:content="view/feedback"
+ class="informational message"
+ />
+
+ <div
+ class="highlighted"
+ tal:content="structure context/proposition/fmt:text-to-html"
+ />
+
+ <form action="" method="POST">
+
+ <tal:block condition="view/userVoted">
+ <tal:block condition="view/isSecret">
+ <h2>You must enter your vote key</h2>
+
+ <p>This is a secret poll —
+ your vote is identified only by the key you
+ were given when you voted. To view or change your vote you must enter
+ your key:</p>
+
+ <input type="text" name="token"
+ tal:attributes="value view/token|nothing" />
+ <br />
+ <br />
+ </tal:block>
+ </tal:block>
+
+ <table cols="2" id="your-vote">
+ <tr>
+ <td>
+ <h2>Your current vote</h2>
+ <tal:block condition="not: view/userVoted">
+ <p>You have not yet voted in this poll.</p>
+ </tal:block>
+
+ <tal:block condition="view/userVoted">
+ <tal:block condition="view/gotTokenAndVotes">
+ <p>Your current vote is for
+ <b tal:condition="not: view/currentVote/option">
+ none of the options.
+ </b>
+ <b tal:condition="view/currentVote/option"
+ tal:content="view/currentVote/option/name" />
+ </p>
+ </tal:block>
+
+ <tal:block condition="not: view/gotTokenAndVotes">
+ <p>You have voted in this poll. Launchpad can display your vote
+ once you have entered your vote key.</p>
+
+ <input type="submit" value="Show My Vote" name="showvote" />
+ </tal:block>
+ </tal:block>
+ </td>
+ <td>
+ <tal:block condition="not: view/userVoted">
+ <h2>Vote now</h2>
+ <p>Choose one option</p>
+
+ <label>
+ <input type="radio" name="newoption" value="donotvote"
+ checked="checked" />
+ I'm not voting yet
+ </label>
+ <br />
+ </tal:block>
+
+ <tal:block condition="view/userVoted">
+ <h2>Change your vote</h2>
+ <p>Choose one option</p>
+
+ <label>
+ <input type="radio" name="newoption" value="donotchange"
+ checked="checked" />
+ Don't change my vote
+ </label>
+ <br />
+ </tal:block>
+
+ <tal:block repeat="option context/getActiveOptions">
+ <label>
+ <input type="radio" name="newoption"
+ tal:attributes="value option/id" />
+ <span tal:replace="option/name" />
+ </label>
+ <br />
+ </tal:block>
+
+ <tal:block condition="context/allowspoilt">
+ <label>
+ <input type="radio" name="newoption" value="none" />
+ None of these options
+ </label>
+ <br />
+ </tal:block>
+ <br />
+
+ <input type="submit" value="Continue" name="continue" />
+ or <a tal:attributes="href context/team/fmt:url/+polls">Cancel</a>
+ </td>
+ </tr>
+ </table>
+ </form>
+ </tal:can-vote>
+
+ <p tal:condition="not: view/userCanVote" class="informational message">
+ You can’t vote in this poll because you’re not
+ a member of <span tal:replace="context/team/displayname" />.
+ </p>
+
+ </tal:open-poll>
+
+ <p tal:condition="not: context/isOpen" class="informational message">
+ This poll is already closed.
+ </p>
+
+ </div>
+
+</body>
+</html>
=== added file 'lib/lp/registry/templates/polloption-edit.pt'
--- lib/lp/registry/templates/polloption-edit.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/templates/polloption-edit.pt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,37 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad"
+>
+
+<body>
+
+ <div metal:fill-slot="main">
+
+ <tal:block condition="context/poll/isNotYetOpened">
+ <div metal:use-macro="context/@@launchpad_form/form">
+
+ <h1 metal:fill-slot="heading">
+ Edit option
+ “<span tal:replace="context/name" />”
+ </h1>
+
+ </div>
+ </tal:block>
+
+ <tal:block condition="not: context/poll/isNotYetOpened">
+ <p class="error message" tal:condition="context/poll/isClosed">
+ You can’t edit any options because the poll is already closed.
+ </p>
+ <p class="error message" tal:condition="context/poll/isOpen">
+ You can’t edit any options because the poll is already open.
+ </p>
+ </tal:block>
+
+ </div>
+
+</body>
+</html>
=== modified file 'lib/lp/registry/templates/team-index.pt'
--- lib/lp/registry/templates/team-index.pt 2010-12-15 22:05:43 +0000
+++ lib/lp/registry/templates/team-index.pt 2011-01-04 16:38:41 +0000
@@ -35,6 +35,7 @@
</metal:contact>
<tal:menu replace="structure view/@@+global-actions" />
+ <tal:polls replace="structure context/@@+portlet-polls" />
</div>
=== added file 'lib/lp/registry/templates/team-newpoll.pt'
--- lib/lp/registry/templates/team-newpoll.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/templates/team-newpoll.pt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,25 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad"
+>
+
+<body>
+
+ <h1 metal:fill-slot="heading">
+ Create a new poll
+ </h1>
+
+ <div metal:fill-slot="main">
+
+ <div metal:use-macro="context/@@launchpad_form/form">
+
+ </div>
+
+ </div>
+
+</body>
+</html>
=== modified file 'lib/lp/registry/templates/team-polls.pt'
--- lib/lp/registry/templates/team-polls.pt 2010-12-16 14:42:36 +0000
+++ lib/lp/registry/templates/team-polls.pt 2011-01-04 16:38:41 +0000
@@ -8,19 +8,81 @@
>
<body>
+ <metal:heading fill-slot="heading">
+ <h1>Polls for <span tal:replace="context/displayname" /></h1>
+ </metal:heading>
+
<div metal:fill-slot="main">
- <h1>Polls no longer supported</h1>
+ <h2>Current polls</h2>
- <p>
- Launchpad no longer supports team polls. We have archived the data from
- all previously conducted polls, which can be found as a comma-separated
- values file at
- <a href="http://dev.launchpad.net/PollFeatureRemoved">
- http://dev.launchpad.net/PollFeatureRemoved</a>.
+ <p tal:condition="not: view/has_current_polls">
+ This team has no open polls nor polls that are not yet opened.
</p>
+ <ul tal:condition="view/has_current_polls">
+ <li tal:repeat="poll view/openpolls">
+ <a tal:attributes="href poll/fmt:url">
+ <span tal:replace="poll/title" />
+ </a> - closes
+ <span
+ tal:attributes="title poll/datecloses/fmt:datetime"
+ tal:content="poll/datecloses/fmt:displaydate" />.
+
+ <tal:block define="user request/lp:person" condition="user">
+ <tal:block condition="python: poll.personVoted(user)">
+ You have
+ <span tal:replace="poll/closesIn/fmt:approximateduration" />
+ to change your vote if you wish.
+ </tal:block>
+
+ <tal:block condition="python: not poll.personVoted(user)">
+ You have
+ <span tal:replace="poll/closesIn/fmt:approximateduration" />
+ left to vote in this poll.
+ </tal:block>
+ </tal:block>
+
+ </li>
+
+ <li tal:repeat="poll view/notyetopenedpolls">
+ <a tal:attributes="href poll/fmt:url">
+ <span tal:replace="poll/title" />
+ </a> - opens
+ <span
+ tal:attributes="title poll/dateopens/fmt:datetime"
+ tal:content="poll/dateopens/fmt:displaydate" />
+ </li>
+ </ul>
+
+ <tal:block condition="view/closedpolls" >
+ <h2>Closed polls</h2>
+
+ <ul>
+ <li tal:repeat="poll view/closedpolls">
+ <a tal:attributes="href poll/fmt:url">
+ <span tal:replace="poll/title" />
+ </a> - closed
+ <span
+ tal:attributes="title poll/datecloses/fmt:datetime"
+ tal:content="poll/datecloses/fmt:displaydate" />
+ </li>
+ </ul>
+ </tal:block>
+
+ <br />
+ <tal:block tal:condition="request/lp:person">
+ <ul tal:condition="context/required:launchpad.Edit">
+ <li><a class="sprite add" href="+newpoll">Set up a new poll</a></li>
+ </ul>
+ </tal:block>
+
+ <tal:block tal:condition="not: request/lp:person">
+ <a href="+login">Log in as an admin to set up a new poll</a>
+ </tal:block>
+
</div>
</body>
</html>
+
=== added file 'lib/lp/registry/templates/team-portlet-polls.pt'
--- lib/lp/registry/templates/team-portlet-polls.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/templates/team-portlet-polls.pt 2011-01-04 16:38:41 +0000
@@ -0,0 +1,56 @@
+<tal:root
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ omit-tag="">
+
+ <div id="polls" class="portlet"
+ tal:define="overview_menu context/menu:overview"
+ tal:condition="view/should_show_polls_portlet">
+ <h2>Polls</h2>
+ <p tal:condition="not: view/has_current_polls">
+ No current polls.
+ </p>
+
+ <ul tal:condition="view/has_current_polls">
+ <li tal:repeat="poll view/openpolls">
+ <a tal:attributes="href poll/fmt:url">
+ <span tal:replace="poll/title" />
+ </a> - closes
+ <span
+ tal:attributes="title poll/datecloses/fmt:datetime"
+ tal:content="poll/datecloses/fmt:displaydate" />.
+
+ <tal:block define="user request/lp:person" condition="user">
+ <tal:block condition="python: poll.personVoted(user)">
+ You have
+ <span tal:replace="poll/closesIn/fmt:approximateduration" />
+ to change your vote if you wish.
+ </tal:block>
+
+ <tal:block condition="python: not poll.personVoted(user)">
+ You have
+ <span tal:replace="poll/closesIn/fmt:approximateduration" />
+ left to vote in this poll.
+ </tal:block>
+ </tal:block>
+
+ </li>
+
+ <li tal:condition="view/userIsOwner"
+ tal:repeat="poll view/notyetopenedpolls">
+ <a tal:attributes="href poll/fmt:url">
+ <span tal:replace="poll/title" />
+ </a> - opens
+ <span
+ tal:attributes="title poll/dateopens/fmt:datetime"
+ tal:content="poll/dateopens/fmt:displaydate" />
+ </li>
+ </ul>
+
+ <a tal:condition="view/should_show_polls_portlet"
+ tal:replace="structure overview_menu/polls/fmt:link" />
+ <a tal:replace="structure overview_menu/add_poll/fmt:link" />
+
+ </div>
+</tal:root>
=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py 2010-12-24 10:03:15 +0000
+++ lib/lp/testing/factory.py 2011-01-04 16:38:41 +0000
@@ -206,6 +206,11 @@
TeamSubscriptionPolicy,
)
from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.registry.interfaces.poll import (
+ IPollSet,
+ PollAlgorithm,
+ PollSecrecy,
+ )
from lp.registry.interfaces.product import (
IProductSet,
License,
@@ -724,6 +729,16 @@
naked_team.addMember(member, owner)
return team
+ def makePoll(self, team, name, title, proposition,
+ poll_type=PollAlgorithm.SIMPLE):
+ """Create a new poll which starts tomorrow and lasts for a week."""
+ dateopens = datetime.now(pytz.UTC) + timedelta(days=1)
+ datecloses = dateopens + timedelta(days=7)
+ return getUtility(IPollSet).new(
+ team, name, title, proposition, dateopens, datecloses,
+ PollSecrecy.SECRET, allowspoilt=True,
+ poll_type=poll_type)
+
def makeTranslationGroup(self, owner=None, name=None, title=None,
summary=None, url=None):
"""Create a new, arbitrary `TranslationGroup`."""