← Back to team overview

launchpad-reviewers team mailing list archive

[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&#8217;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&#8217;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&#8217;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&#8217;t vote in this poll because you&#8217;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&#8217;t vote in this poll because you&#8217;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 &#8220;<span tal:replace="context/title" />&#8221;
+        </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&#8217;t add new options because the poll is already closed.
+      </p>
+      <p class="error message" tal:condition="context/isOpen">
+        You can&#8217;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 &mdash;
+        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&#8217;t vote in this poll because you&#8217;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 &mdash;
+        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&#8217;t vote in this poll because you&#8217;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 
+          &#8220;<span tal:replace="context/name" />&#8221;
+        </h1>
+
+      </div>
+    </tal:block>
+    
+    <tal:block condition="not: context/poll/isNotYetOpened">
+      <p class="error message" tal:condition="context/poll/isClosed">
+        You can&#8217;t edit any options because the poll is already closed.
+      </p>
+      <p class="error message" tal:condition="context/poll/isOpen">
+        You can&#8217;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`."""