← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~stevenk/launchpad/move-team-out-of-person into lp:launchpad

 

Steve Kowalik has proposed merging lp:~stevenk/launchpad/move-team-out-of-person into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~stevenk/launchpad/move-team-out-of-person/+merge/78189

Be a bad person, and move all of the Team gubbins out of lp.registry.browser.person.

This was done entirely by hand, but only non-mechnical changes were making pyflakes happy, and things like fixing circular imports hell.
-- 
https://code.launchpad.net/~stevenk/launchpad/move-team-out-of-person/+merge/78189
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~stevenk/launchpad/move-team-out-of-person into lp:launchpad.
=== modified file 'lib/lp/registry/browser/configure.zcml'
--- lib/lp/registry/browser/configure.zcml	2011-09-19 13:56:31 +0000
+++ lib/lp/registry/browser/configure.zcml	2011-10-05 01:53:26 +0000
@@ -794,9 +794,10 @@
         facet="overview">
         <browser:navigation
             module="lp.registry.browser.person"
-            classes="
-                TeamNavigation
-                PersonNavigation"/>
+            classes="PersonNavigation"/>
+        <browser:navigation
+            module="lp.registry.browser.team"
+            classes="TeamNavigation"/>
         <browser:menus
             module="lp.registry.browser.person"
             classes="
@@ -809,16 +810,20 @@
                 PersonSetActionNavigationMenu
                 PersonSetContextMenu
                 PersonSpecsMenu
+                "/>
+        <browser:menus
+            module="lp.registry.browser.menu"
+            classes="
+                RegistryCollectionNavigationMenu
+                "/>
+        <browser:menus
+            module="lp.registry.browser.team"
+            classes="
                 TeamEditMenu
                 TeamIndexMenu
                 TeamOverviewMenu
                 TeamOverviewNavigationMenu
                 "/>
-        <browser:menus
-            module="lp.registry.browser.menu"
-            classes="
-                RegistryCollectionNavigationMenu
-                "/>
         <browser:url
             for="lp.registry.interfaces.person.IPerson"
             path_expression="string:~${name}"
@@ -1100,12 +1105,12 @@
         <browser:page
             for="lp.registry.interfaces.person.ITeam"
             permission="zope.Public"
-            class="lp.registry.browser.person.TeamIndexView"
+            class="lp.registry.browser.team.TeamIndexView"
             name="+index"
             template="../templates/team-index.pt"/>
         <browser:page
             for="lp.registry.interfaces.person.ITeam"
-            class="lp.registry.browser.person.TeamIndexView"
+            class="lp.registry.browser.team.TeamIndexView"
             permission="zope.Public"
             name="+portlet-polls"
             template="../templates/team-portlet-polls.pt"/>
@@ -1143,7 +1148,7 @@
             permission="zope.Public"
             name="+mugshots"
             template="../templates/team-mugshots.pt"
-            class="lp.registry.browser.person.TeamMugshotView"/>
+            class="lp.registry.browser.team.TeamMugshotView"/>
         <browser:page
             for="lp.registry.interfaces.person.ITeam"
             class="lp.registry.browser.team.TeamMapLtdView"
@@ -1152,7 +1157,7 @@
             template="../templates/team-portlet-map.pt"/>
         <browser:page
             for="lp.registry.interfaces.person.ITeam"
-            class="lp.registry.browser.person.TeamIndexView"
+            class="lp.registry.browser.team.TeamIndexView"
             permission="zope.Public"
             name="+portlet-membership"
             template="../templates/team-portlet-membership.pt"/>
@@ -1195,25 +1200,25 @@
         <browser:page
             for="lp.registry.interfaces.person.ITeam"
             permission="launchpad.Owner"
-            class="lp.registry.browser.person.TeamReassignmentView"
+            class="lp.registry.browser.team.TeamReassignmentView"
             name="+reassign"
             template="../../app/templates/object-reassignment.pt"/>
         <browser:page
             for="lp.registry.interfaces.person.ITeam"
             permission="launchpad.AnyPerson"
-            class="lp.registry.browser.person.TeamAddMyTeamsView"
+            class="lp.registry.browser.team.TeamAddMyTeamsView"
             name="+add-my-teams"
             template="../templates/team-add-my-teams.pt"/>
         <browser:page
             for="lp.registry.interfaces.person.ITeam"
             permission="launchpad.AnyPerson"
-            class="lp.registry.browser.person.TeamJoinView"
+            class="lp.registry.browser.team.TeamJoinView"
             name="+join"
             template="../templates/team-join.pt"/>
         <browser:page
             for="lp.registry.interfaces.person.ITeam"
             permission="launchpad.AnyPerson"
-            class="lp.registry.browser.person.TeamLeaveView"
+            class="lp.registry.browser.team.TeamLeaveView"
             name="+leave"
             template="../templates/team-leave.pt"/>
         <browser:page
@@ -1233,7 +1238,7 @@
             for="lp.registry.interfaces.person.ITeam"
             permission="zope.Public"
             template="../templates/team-members.pt"
-            class="lp.registry.browser.person.TeamMembershipView"/>
+            class="lp.registry.browser.team.TeamMembershipView"/>
         <browser:page
             name="+invitations"
             for="lp.registry.interfaces.person.ITeam"

=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py	2011-09-29 10:06:09 +0000
+++ lib/lp/registry/browser/person.py	2011-10-05 01:53:26 +0000
@@ -9,6 +9,7 @@
 __all__ = [
     'BeginTeamClaimView',
     'BugSubscriberPackageBugsSearchListingView',
+    'CommonMenuLinks',
     'EmailToPersonView',
     'PeopleSearchView',
     'PersonAccountAdministerView',
@@ -56,6 +57,7 @@
     'PersonSubscriptionsView',
     'PersonView',
     'PersonVouchersView',
+    'PPANavigationMenuMixIn',
     'RedirectToEditLanguagesView',
     'RestrictedMembershipsPersonView',
     'SearchAnsweredQuestionsView',
@@ -64,28 +66,13 @@
     'SearchCreatedQuestionsView',
     'SearchNeedAttentionQuestionsView',
     'SearchSubscribedQuestionsView',
-    'TeamAddMyTeamsView',
-    'TeamBreadcrumb',
-    'TeamEditMenu',
-    'TeamIndexMenu',
-    'TeamJoinView',
-    'TeamLeaveView',
-    'TeamMembershipView',
-    'TeamMugshotView',
-    'TeamNavigation',
-    'TeamOverviewMenu',
-    'TeamOverviewNavigationMenu',
-    'TeamReassignmentView',
     'archive_to_person',
     ]
 
 
 import cgi
 import copy
-from datetime import (
-    datetime,
-    timedelta,
-    )
+from datetime import datetime
 import itertools
 from itertools import chain
 from operator import (
@@ -124,11 +111,9 @@
 from zope.interface.exceptions import Invalid
 from zope.interface.interface import invariant
 from zope.publisher.interfaces import NotFound
-from zope.publisher.interfaces.browser import IBrowserPublisher
 from zope.schema import (
     Bool,
     Choice,
-    List,
     Text,
     TextLine,
     )
@@ -181,12 +166,7 @@
     structured,
     )
 from canonical.launchpad.webapp.authorization import check_permission
-from canonical.launchpad.webapp.batching import (
-    ActiveBatchNavigator,
-    BatchNavigator,
-    InactiveBatchNavigator,
-    )
-from canonical.launchpad.webapp.breadcrumb import Breadcrumb
+from canonical.launchpad.webapp.batching import BatchNavigator
 from canonical.launchpad.webapp.interfaces import (
     ILaunchBag,
     IOpenLaunchBag,
@@ -217,7 +197,6 @@
 from lp.app.validators.email import valid_email
 from lp.app.widgets.image import ImageChangeWidget
 from lp.app.widgets.itemswidgets import (
-    LabeledMultiCheckBoxWidget,
     LaunchpadDropdownWidget,
     LaunchpadRadioWidget,
     LaunchpadRadioWidgetWithDescription,
@@ -240,14 +219,12 @@
 from lp.code.interfaces.branchnamespace import IBranchNamespaceSet
 from lp.registry.browser import BaseRdfView
 from lp.registry.browser.branding import BrandingChangeView
-from lp.registry.browser.mailinglists import enabled_with_active_mailing_list
 from lp.registry.browser.menu import (
     IRegistryCollectionNavigationMenu,
     RegistryCollectionActionMenuBase,
     TopLevelMenuMixin,
     )
-from lp.registry.browser.objectreassignment import ObjectReassignmentView
-from lp.registry.browser.team import TeamEditView
+from lp.registry.browser.teamjoin import TeamJoinMixin
 from lp.registry.interfaces.codeofconduct import ISignedCodeOfConductSet
 from lp.registry.interfaces.gpg import IGPGKeySet
 from lp.registry.interfaces.irc import IIrcIDSet
@@ -266,18 +243,11 @@
     IPerson,
     IPersonClaim,
     IPersonSet,
-    ITeam,
-    ITeamReassignment,
     PersonVisibility,
-    TeamMembershipRenewalPolicy,
-    TeamSubscriptionPolicy,
     )
 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.poll import IPollSubset
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.ssh import (
     ISSHKeySet,
@@ -286,9 +256,6 @@
     SSHKeyType,
     )
 from lp.registry.interfaces.teammembership import (
-    CyclicalTeamMembershipError,
-    DAYS_BEFORE_EXPIRATION_WARNING_IS_SENT,
-    ITeamMembership,
     ITeamMembershipSet,
     TeamMembershipStatus,
     )
@@ -457,6 +424,8 @@
         # Return the found membership regardless of its status as we know
         # TeamMembershipSelfRenewalView will tell users why the memembership
         # can't be renewed when necessary.
+        # Circular imports
+        from lp.registry.browser.team import TeamMembershipSelfRenewalView
         membership = getUtility(ITeamMembershipSet).getByPersonAndTeam(
             self.context, getUtility(IPersonSet).getByName(name))
         if membership is None:
@@ -551,221 +520,6 @@
         return self.context.getMergeQueue(name)
 
 
-class TeamNavigation(PersonNavigation):
-
-    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
-        # TeamInvitationView can handle memberships in statuses other than
-        # INVITED.
-        membership = getUtility(ITeamMembershipSet).getByPersonAndTeam(
-            self.context, getUtility(IPersonSet).getByName(name))
-        if membership is None:
-            return None
-        return TeamInvitationView(membership, self.request)
-
-    @stepthrough('+member')
-    def traverse_member(self, name):
-        person = getUtility(IPersonSet).getByName(name)
-        if person is None:
-            return None
-        return getUtility(ITeamMembershipSet).getByPersonAndTeam(
-            person, self.context)
-
-
-class TeamBreadcrumb(Breadcrumb):
-    """Builds a breadcrumb for an `ITeam`."""
-
-    @property
-    def text(self):
-        return smartquote('"%s" team') % self.context.displayname
-
-
-class TeamMembershipSelfRenewalView(LaunchpadFormView):
-
-    implements(IBrowserPublisher)
-
-    # This is needed for our breadcrumbs, as there's no <browser:page>
-    # declaration for this view.
-    __name__ = '+self-renewal'
-    schema = ITeamMembership
-    field_names = []
-    template = ViewPageTemplateFile(
-        '../templates/teammembership-self-renewal.pt')
-
-    @property
-    def label(self):
-        return "Renew membership of %s in %s" % (
-            self.context.person.displayname, self.context.team.displayname)
-
-    page_title = label
-
-    def __init__(self, context, request):
-        # Only the member himself or admins of the member (in case it's a
-        # team) can see the page in which they renew memberships that are
-        # about to expire.
-        if not check_permission('launchpad.Edit', context.person):
-            raise Unauthorized(
-                "You may not renew the membership for %s." %
-                context.person.displayname)
-        LaunchpadFormView.__init__(self, context, request)
-
-    def browserDefault(self, request):
-        return self, ()
-
-    @property
-    def reason_for_denied_renewal(self):
-        """Return text describing why the membership can't be renewed."""
-        context = self.context
-        ondemand = TeamMembershipRenewalPolicy.ONDEMAND
-        admin = TeamMembershipStatus.ADMIN
-        approved = TeamMembershipStatus.APPROVED
-        date_limit = datetime.now(pytz.UTC) - timedelta(
-            days=DAYS_BEFORE_EXPIRATION_WARNING_IS_SENT)
-        if context.status not in (admin, approved):
-            text = "it is not active."
-        elif context.team.renewal_policy != ondemand:
-            text = ('<a href="%s">%s</a> is not a team that allows its '
-                    'members to renew their own memberships.'
-                    % (canonical_url(context.team),
-                       context.team.unique_displayname))
-        elif context.dateexpires is None or context.dateexpires > date_limit:
-            if context.person.isTeam():
-                link_text = "Somebody else has already renewed it."
-            else:
-                link_text = (
-                    "You or one of the team administrators has already "
-                    "renewed it.")
-            text = ('it is not set to expire in %d days or less. '
-                    '<a href="%s/+members">%s</a>'
-                    % (DAYS_BEFORE_EXPIRATION_WARNING_IS_SENT,
-                       canonical_url(context.team), link_text))
-        else:
-            raise AssertionError('This membership can be renewed!')
-        return text
-
-    @property
-    def time_before_expiration(self):
-        return self.context.dateexpires - datetime.now(pytz.timezone('UTC'))
-
-    @property
-    def next_url(self):
-        return canonical_url(self.context.person)
-
-    cancel_url = next_url
-
-    @action(_("Renew"), name="renew")
-    def renew_action(self, action, data):
-        member = self.context.person
-        # This if-statement prevents an exception if the user
-        # double clicks on the submit button.
-        if self.context.canBeRenewedByMember():
-            member.renewTeamMembership(self.context.team)
-        self.request.response.addInfoNotification(
-            _("Membership renewed until ${date}.", mapping=dict(
-                    date=self.context.dateexpires.strftime('%Y-%m-%d'))))
-
-
-class ITeamMembershipInvitationAcknowledgementForm(Interface):
-    """Schema for the form in which team admins acknowledge invitations.
-
-    We could use ITeamMembership for that, but the acknowledger_comment is
-    marked readonly there and that means LaunchpadFormView won't include the
-    value of that in the data given to our action handler.
-    """
-
-    acknowledger_comment = Text(
-        title=_("Comment"), required=False, readonly=False)
-
-
-class TeamInvitationView(LaunchpadFormView):
-    """Where team admins can accept/decline membership invitations."""
-
-    implements(IBrowserPublisher)
-
-    # This is needed for our breadcrumbs, as there's no <browser:page>
-    # declaration for this view.
-    __name__ = '+invitation'
-    schema = ITeamMembershipInvitationAcknowledgementForm
-    field_names = ['acknowledger_comment']
-    custom_widget('acknowledger_comment', TextAreaWidget, height=5, width=60)
-    template = ViewPageTemplateFile(
-        '../templates/teammembership-invitation.pt')
-
-    def __init__(self, context, request):
-        # Only admins of the invited team can see the page in which they
-        # approve/decline invitations.
-        if not check_permission('launchpad.Edit', context.person):
-            raise Unauthorized(
-                "Only team administrators can approve/decline invitations "
-                "sent to this team.")
-        LaunchpadFormView.__init__(self, context, request)
-
-    @property
-    def label(self):
-        """See `LaunchpadFormView`."""
-        return "Make %s a member of %s" % (
-            self.context.person.displayname, self.context.team.displayname)
-
-    @property
-    def page_title(self):
-        return smartquote(
-            '"%s" team invitation') % self.context.team.displayname
-
-    def browserDefault(self, request):
-        return self, ()
-
-    @property
-    def next_url(self):
-        return canonical_url(self.context.person)
-
-    @action(_("Accept"), name="accept")
-    def accept_action(self, action, data):
-        if self.context.status != TeamMembershipStatus.INVITED:
-            self.request.response.addInfoNotification(
-                _("This invitation has already been processed."))
-            return
-        member = self.context.person
-        try:
-            member.acceptInvitationToBeMemberOf(
-                self.context.team, data['acknowledger_comment'])
-        except CyclicalTeamMembershipError:
-            self.request.response.addInfoNotification(
-                _("This team may not be added to ${that_team} because it is "
-                  "a member of ${this_team}.",
-                  mapping=dict(
-                      that_team=self.context.team.displayname,
-                      this_team=member.displayname)))
-        else:
-            self.request.response.addInfoNotification(
-                _("This team is now a member of ${team}.", mapping=dict(
-                    team=self.context.team.displayname)))
-
-    @action(_("Decline"), name="decline")
-    def decline_action(self, action, data):
-        if self.context.status != TeamMembershipStatus.INVITED:
-            self.request.response.addInfoNotification(
-                _("This invitation has already been processed."))
-            return
-        member = self.context.person
-        member.declineInvitationToBeMemberOf(
-            self.context.team, data['acknowledger_comment'])
-        self.request.response.addInfoNotification(
-            _("Declined the invitation to join ${team}", mapping=dict(
-                  team=self.context.team.displayname)))
-
-    @action(_("Cancel"), name="cancel")
-    def cancel_action(self, action, data):
-        # Simply redirect back.
-        pass
-
-
 class PersonSetNavigation(Navigation):
 
     usedfor = IPersonSet
@@ -1245,248 +999,6 @@
         return Link(target, text)
 
 
-class TeamMenuMixin(PPANavigationMenuMixIn, CommonMenuLinks):
-    """Base class of team menus.
-
-    You will need to override the team attribute if your menu subclass
-    has the view as its context object.
-    """
-
-    def profile(self):
-        target = ''
-        text = 'Overview'
-        return Link(target, text)
-
-    @enabled_with_permission('launchpad.Edit')
-    def edit(self):
-        target = '+edit'
-        text = 'Change details'
-        return Link(target, text, icon='edit')
-
-    @enabled_with_permission('launchpad.Edit')
-    def branding(self):
-        target = '+branding'
-        text = 'Change branding'
-        return Link(target, text, icon='edit')
-
-    @enabled_with_permission('launchpad.Owner')
-    def reassign(self):
-        target = '+reassign'
-        text = 'Change owner'
-        summary = 'Change the owner of the team'
-        return Link(target, text, summary, icon='edit')
-
-    @enabled_with_permission('launchpad.Moderate')
-    def delete(self):
-        target = '+delete'
-        text = 'Delete'
-        summary = 'Delete this team'
-        return Link(target, text, summary, icon='trash-icon')
-
-    @enabled_with_permission('launchpad.View')
-    def members(self):
-        target = '+members'
-        text = 'Show all members'
-        return Link(target, text, icon='team')
-
-    @enabled_with_permission('launchpad.Edit')
-    def received_invitations(self):
-        target = '+invitations'
-        text = 'Show received invitations'
-        return Link(target, text, icon='info')
-
-    @enabled_with_permission('launchpad.Edit')
-    def add_member(self):
-        target = '+addmember'
-        text = 'Add member'
-        return Link(target, text, icon='add')
-
-    @enabled_with_permission('launchpad.Edit')
-    def proposed_members(self):
-        target = '+editproposedmembers'
-        text = 'Approve or decline members'
-        return Link(target, text, icon='add')
-
-    def map(self):
-        target = '+map'
-        text = 'View map and time zones'
-        return Link(target, text, icon='meeting')
-
-    def add_my_teams(self):
-        target = '+add-my-teams'
-        text = 'Add one of my teams'
-        enabled = True
-        restricted = TeamSubscriptionPolicy.RESTRICTED
-        if self.person.subscriptionpolicy == restricted:
-            # This is a restricted team; users can't join.
-            enabled = False
-        return Link(target, text, icon='add', enabled=enabled)
-
-    def memberships(self):
-        target = '+participation'
-        text = 'Show team participation'
-        return Link(target, text, icon='info')
-
-    @enabled_with_permission('launchpad.View')
-    def mugshots(self):
-        target = '+mugshots'
-        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'
-        text = 'Set contact address'
-        summary = (
-            'The address Launchpad uses to contact %s' %
-            self.person.displayname)
-        return Link(target, text, summary, icon='edit')
-
-    @enabled_with_permission('launchpad.Moderate')
-    def configure_mailing_list(self):
-        target = '+mailinglist'
-        mailing_list = self.person.mailing_list
-        if mailing_list is not None:
-            text = 'Configure mailing list'
-            icon = 'edit'
-        else:
-            text = 'Create a mailing list'
-            icon = 'add'
-        summary = (
-            'The mailing list associated with %s' % self.context.displayname)
-        return Link(target, text, summary, icon=icon)
-
-    @enabled_with_active_mailing_list
-    @enabled_with_permission('launchpad.Edit')
-    def moderate_mailing_list(self):
-        target = '+mailinglist-moderate'
-        text = 'Moderate mailing list'
-        summary = (
-            'The mailing list associated with %s' % self.context.displayname)
-        return Link(target, text, summary, icon='edit')
-
-    @enabled_with_permission('launchpad.Edit')
-    def editlanguages(self):
-        target = '+editlanguages'
-        text = 'Set preferred languages'
-        return Link(target, text, icon='edit')
-
-    def leave(self):
-        enabled = True
-        if not userIsActiveTeamMember(self.person):
-            enabled = False
-        if self.person.teamowner == self.user:
-            # The owner cannot leave his team.
-            enabled = False
-        target = '+leave'
-        text = 'Leave the Team'
-        icon = 'remove'
-        return Link(target, text, icon=icon, enabled=enabled)
-
-    def join(self):
-        enabled = True
-        person = self.person
-        if userIsActiveTeamMember(person):
-            enabled = False
-        elif (self.person.subscriptionpolicy ==
-              TeamSubscriptionPolicy.RESTRICTED):
-            # This is a restricted team; users can't join.
-            enabled = False
-        target = '+join'
-        text = 'Join the team'
-        icon = 'add'
-        return Link(target, text, icon=icon, enabled=enabled)
-
-
-class TeamOverviewMenu(ApplicationMenu, TeamMenuMixin, HasRecipesMenuMixin):
-
-    usedfor = ITeam
-    facet = 'overview'
-    links = [
-        'edit',
-        'branding',
-        'common_edithomepage',
-        'members',
-        'mugshots',
-        'add_member',
-        'proposed_members',
-        'memberships',
-        'received_invitations',
-        'editemail',
-        'configure_mailing_list',
-        'moderate_mailing_list',
-        'editlanguages',
-        'map',
-        'polls',
-        'add_poll',
-        'join',
-        'leave',
-        'add_my_teams',
-        'reassign',
-        'projects',
-        'activate_ppa',
-        'maintained',
-        'ppa',
-        'related_software_summary',
-        'view_recipes',
-        'subscriptions',
-        'structural_subscriptions',
-        ]
-
-
-class TeamOverviewNavigationMenu(NavigationMenu, TeamMenuMixin):
-    """A top-level menu for navigation within a Team."""
-
-    usedfor = ITeam
-    facet = 'overview'
-    links = ['profile', 'polls', 'members', 'ppas']
-
-
-class TeamMembershipView(LaunchpadView):
-    """The view behind ITeam/+members."""
-
-    @cachedproperty
-    def label(self):
-        return smartquote('Members of "%s"' % self.context.displayname)
-
-    @cachedproperty
-    def active_memberships(self):
-        """Current members of the team."""
-        return ActiveBatchNavigator(
-            self.context.member_memberships, self.request)
-
-    @cachedproperty
-    def inactive_memberships(self):
-        """Former members of the team."""
-        return InactiveBatchNavigator(
-            self.context.getInactiveMemberships(), self.request)
-
-    @cachedproperty
-    def invited_memberships(self):
-        """Other teams invited to become members of this team."""
-        return list(self.context.getInvitedMemberships())
-
-    @cachedproperty
-    def proposed_memberships(self):
-        """Users who have requested to join this team."""
-        return list(self.context.getProposedMemberships())
-
-    @property
-    def have_pending_members(self):
-        return self.proposed_memberships or self.invited_memberships
-
-
 class PersonSetActionNavigationMenu(RegistryCollectionActionMenuBase):
     """Action menu for `PeopleSearchView`."""
     usedfor = IPersonSet
@@ -1841,16 +1353,6 @@
         self.updateContextFromData(data)
 
 
-def userIsActiveTeamMember(team):
-    """Return True if the user is an active member of this team."""
-    user = getUtility(ILaunchBag).user
-    if user is None:
-        return False
-    if not check_permission('launchpad.View', team):
-        return False
-    return user in team.activemembers
-
-
 class PersonSpecWorkloadView(LaunchpadView):
     """View to render the specification workload for a person or team.
 
@@ -2768,67 +2270,7 @@
         return self.context.latestKarma().count() > 0
 
 
-class TeamJoinMixin:
-    """Mixin class for views related to joining teams."""
-
-    @property
-    def user_can_subscribe_to_list(self):
-        """Can the prospective member subscribe to this team's mailing list?
-
-        A user can subscribe to the list if the team has an active
-        mailing list, and if they do not already have a subscription.
-        """
-        if self.team_has_mailing_list:
-            # If we are already subscribed, then we can not subscribe again.
-            return not self.user_is_subscribed_to_list
-        else:
-            return False
-
-    @property
-    def user_is_subscribed_to_list(self):
-        """Is the user subscribed to the team's mailing list?
-
-        Subscriptions hang around even if the list is deactivated, etc.
-
-        It is an error to ask if the user is subscribed to a mailing list
-        that doesn't exist.
-        """
-        if self.user is None:
-            return False
-
-        mailing_list = self.context.mailing_list
-        assert mailing_list is not None, "This team has no mailing list."
-        has_subscription = bool(mailing_list.getSubscription(self.user))
-        return has_subscription
-
-    @property
-    def team_has_mailing_list(self):
-        """Is the team mailing list available for subscription?"""
-        mailing_list = self.context.mailing_list
-        return mailing_list is not None and mailing_list.is_usable
-
-    @property
-    def user_is_active_member(self):
-        """Return True if the user is an active member of this team."""
-        return userIsActiveTeamMember(self.context)
-
-    @property
-    def user_is_proposed_member(self):
-        """Return True if the user is a proposed member of this team."""
-        if self.user is None:
-            return False
-        return self.user in self.context.proposedmembers
-
-    @property
-    def user_can_request_to_leave(self):
-        """Return true if the user can request to leave this team.
-
-        A given user can leave a team only if he's an active member.
-        """
-        return self.user_is_active_member
-
-
-class PersonView(LaunchpadView, FeedsMixin, TeamJoinMixin):
+class PersonView(LaunchpadView, FeedsMixin):
     """A View class used in almost all Person's pages."""
 
     @property
@@ -3131,6 +2573,8 @@
 
     @property
     def should_show_polls_portlet(self):
+        # Circular imports.
+        from lp.registry.browser.team import TeamOverviewMenu
         menu = TeamOverviewMenu(self.context)
         return (
             self.has_current_polls or self.closedpolls
@@ -3442,7 +2886,8 @@
         return self.state is EmailAddressVisibleState.ALLOWED
 
 
-class PersonIndexView(XRDSContentNegotiationMixin, PersonView):
+class PersonIndexView(XRDSContentNegotiationMixin, PersonView,
+                      TeamJoinMixin):
     """View class for person +index and +xrds pages."""
 
     xrds_template = ViewPageTemplateFile(
@@ -3550,50 +2995,6 @@
             return self.has_visible_location
 
 
-class TeamIndexView(PersonIndexView):
-    """The view class for the +index page.
-
-    This class is needed, so an action menu that only applies to
-    teams can be displayed without showing up on the person index page.
-    """
-
-    @property
-    def can_show_subteam_portlet(self):
-        """Only show the subteam portlet if there is info to display.
-
-        Either the team is a member of another team, or there are
-        invitations to join a team, and the owner needs to see the
-        link so that the invitation can be accepted.
-        """
-        try:
-            return (self.context.super_teams.count() > 0
-                    or (self.context.open_membership_invitations
-                        and check_permission('launchpad.Edit', self.context)))
-        except AttributeError, e:
-            raise AssertionError(e)
-
-    @property
-    def visibility_info(self):
-        if self.context.visibility == PersonVisibility.PRIVATE:
-            return 'Private team'
-        else:
-            return 'Public team'
-
-    @property
-    def visibility_portlet_class(self):
-        """The portlet class for team visibility."""
-        if self.context.visibility == PersonVisibility.PUBLIC:
-            return 'portlet'
-        return 'portlet private'
-
-    @property
-    def add_member_step_title(self):
-        """A string for setup_add_member_handler with escaped quotes."""
-        vocabulary_registry = getVocabularyRegistry()
-        vocabulary = vocabulary_registry.get(self.context, 'ValidTeamMember')
-        return vocabulary.step_title.replace("'", "\\'").replace('"', '\\"')
-
-
 class PersonCodeOfConductEditView(LaunchpadView):
     """View for the ~person/+codesofconduct pages."""
 
@@ -4116,290 +3517,6 @@
     schema = IPerson
 
 
-class TeamJoinForm(Interface):
-    """Schema for team join."""
-    mailinglist_subscribe = Bool(
-        title=_("Subscribe me to this team's mailing list"),
-        required=True, default=True)
-
-
-class TeamJoinView(LaunchpadFormView, TeamJoinMixin):
-    """A view class for joining a team."""
-    schema = TeamJoinForm
-
-    @property
-    def label(self):
-        return 'Join ' + cgi.escape(self.context.displayname)
-
-    page_title = label
-
-    def setUpWidgets(self):
-        super(TeamJoinView, self).setUpWidgets()
-        if 'mailinglist_subscribe' in self.field_names:
-            widget = self.widgets['mailinglist_subscribe']
-            widget.setRenderedValue(self.user_wants_list_subscriptions)
-
-    @property
-    def field_names(self):
-        """See `LaunchpadFormView`.
-
-        If the user can subscribe to the mailing list then include the
-        mailinglist subscription checkbox otherwise remove it.
-        """
-        if self.user_can_subscribe_to_list:
-            return ['mailinglist_subscribe']
-        else:
-            return []
-
-    @property
-    def join_allowed(self):
-        """Is the logged in user allowed to join this team?
-
-        The answer is yes if this team's subscription policy is not RESTRICTED
-        and this team's visibility is either None or PUBLIC.
-        """
-        # Joining a moderated team will put you on the proposed_members
-        # list. If it is a private team, you are not allowed to view the
-        # proposed_members attribute until you are an active member;
-        # therefore, it would look like the join button is broken. Either
-        # private teams should always have a restricted subscription policy,
-        # or we need a more complicated permission model.
-        if not (self.context.visibility is None
-                or self.context.visibility == PersonVisibility.PUBLIC):
-            return False
-
-        restricted = TeamSubscriptionPolicy.RESTRICTED
-        return self.context.subscriptionpolicy != restricted
-
-    @property
-    def user_can_request_to_join(self):
-        """Can the logged in user request to join this team?
-
-        The user can request if he's allowed to join this team and if he's
-        not yet an active member of this team.
-        """
-        if not self.join_allowed:
-            return False
-        return not (self.user_is_active_member or
-                    self.user_is_proposed_member)
-
-    @property
-    def user_wants_list_subscriptions(self):
-        """Is the user interested in subscribing to mailing lists?"""
-        return (self.user.mailing_list_auto_subscribe_policy !=
-                MailingListAutoSubscribePolicy.NEVER)
-
-    @property
-    def team_is_moderated(self):
-        """Is this team a moderated team?
-
-        Return True if the team's subscription policy is MODERATED.
-        """
-        policy = self.context.subscriptionpolicy
-        return policy == TeamSubscriptionPolicy.MODERATED
-
-    @property
-    def next_url(self):
-        return canonical_url(self.context)
-
-    @property
-    def cancel_url(self):
-        return canonical_url(self.context)
-
-    @action(_("Join"), name="join")
-    def action_save(self, action, data):
-        response = self.request.response
-
-        if self.user_can_request_to_join:
-            # Shut off mailing list auto-subscription - we want direct
-            # control over it.
-            self.user.join(self.context, may_subscribe_to_list=False)
-
-            if self.team_is_moderated:
-                response.addInfoNotification(
-                    _('Your request to join ${team} is awaiting '
-                      'approval.',
-                      mapping={'team': self.context.displayname}))
-            else:
-                response.addInfoNotification(
-                    _('You have successfully joined ${team}.',
-                      mapping={'team': self.context.displayname}))
-            if data.get('mailinglist_subscribe', False):
-                self._subscribeToList(response)
-
-        else:
-            response.addErrorNotification(
-                _('You cannot join ${team}.',
-                  mapping={'team': self.context.displayname}))
-
-    def _subscribeToList(self, response):
-        """Subscribe the user to the team's mailing list."""
-
-        if self.user_can_subscribe_to_list:
-            # 'user_can_subscribe_to_list' should have dealt with
-            # all of the error cases.
-            self.context.mailing_list.subscribe(self.user)
-
-            if self.team_is_moderated:
-                response.addInfoNotification(
-                    _('Your mailing list subscription is '
-                      'awaiting approval.'))
-            else:
-                response.addInfoNotification(
-                    structured(
-                        _("You have been subscribed to this "
-                          "team&#x2019;s mailing list.")))
-        else:
-            # A catch-all case, perhaps from stale or mangled
-            # form data.
-            response.addErrorNotification(
-                _('Mailing list subscription failed.'))
-
-
-class TeamAddMyTeamsView(LaunchpadFormView):
-    """Propose/add to this team any team that you're an administrator of."""
-
-    page_title = 'Propose/add one of your teams to another one'
-    custom_widget('teams', LabeledMultiCheckBoxWidget)
-
-    def initialize(self):
-        context = self.context
-        if context.subscriptionpolicy == TeamSubscriptionPolicy.MODERATED:
-            self.label = 'Propose these teams as members'
-        else:
-            self.label = 'Add these teams to %s' % context.displayname
-        self.next_url = canonical_url(context)
-        super(TeamAddMyTeamsView, self).initialize()
-
-    def setUpFields(self):
-        terms = []
-        for team in self.candidate_teams:
-            text = structured(
-                '<a href="%s">%s</a>', canonical_url(team), team.displayname)
-            terms.append(SimpleTerm(team, team.name, text))
-        self.form_fields = FormFields(
-            List(__name__='teams',
-                 title=_(''),
-                 value_type=Choice(vocabulary=SimpleVocabulary(terms)),
-                 required=False),
-            render_context=self.render_context)
-
-    def setUpWidgets(self, context=None):
-        super(TeamAddMyTeamsView, self).setUpWidgets(context)
-        self.widgets['teams'].display_label = False
-
-    @cachedproperty
-    def candidate_teams(self):
-        """Return the set of teams that can be added/proposed for the context.
-
-        We return only teams that the user can administer, that aren't already
-        a member in the context or that the context isn't a member of. (Of
-        course, the context is also omitted.)
-        """
-        candidates = []
-        for team in self.user.getAdministratedTeams():
-            if team == self.context:
-                continue
-            elif team.visibility != PersonVisibility.PUBLIC:
-                continue
-            elif team in self.context.activemembers:
-                # The team is already a member of the context object.
-                continue
-            elif self.context.hasParticipationEntryFor(team):
-                # The context object is a member/submember of the team.
-                continue
-            candidates.append(team)
-        return candidates
-
-    @property
-    def cancel_url(self):
-        """The return URL."""
-        return canonical_url(self.context)
-
-    def validate(self, data):
-        if len(data.get('teams', [])) == 0:
-            self.setFieldError('teams',
-                               'Please select the team(s) you want to be '
-                               'member(s) of this team.')
-
-    def hasCandidates(self, action):
-        """Return whether the user has teams to propose."""
-        return len(self.candidate_teams) > 0
-
-    @action(_("Continue"), name="continue", condition=hasCandidates)
-    def continue_action(self, action, data):
-        """Make the selected teams join this team."""
-        context = self.context
-        is_admin = check_permission('launchpad.Admin', context)
-        membership_set = getUtility(ITeamMembershipSet)
-        proposed_team_names = []
-        added_team_names = []
-        accepted_invite_team_names = []
-        membership_set = getUtility(ITeamMembershipSet)
-        for team in data['teams']:
-            membership = membership_set.getByPersonAndTeam(team, context)
-            if (membership is not None
-                and membership.status == TeamMembershipStatus.INVITED):
-                team.acceptInvitationToBeMemberOf(
-                    context,
-                    'Accepted an already pending invitation while trying to '
-                    'propose the team for membership.')
-                accepted_invite_team_names.append(team.displayname)
-            elif is_admin:
-                context.addMember(team, reviewer=self.user)
-                added_team_names.append(team.displayname)
-            else:
-                team.join(context, requester=self.user)
-                membership = membership_set.getByPersonAndTeam(team, context)
-                if membership.status == TeamMembershipStatus.PROPOSED:
-                    proposed_team_names.append(team.displayname)
-                elif membership.status == TeamMembershipStatus.APPROVED:
-                    added_team_names.append(team.displayname)
-                else:
-                    raise AssertionError(
-                        'Unexpected membership status (%s) for %s.'
-                        % (membership.status.name, team.name))
-        full_message = ''
-        for team_names, message in (
-            (proposed_team_names, 'proposed to this team.'),
-            (added_team_names, 'added to this team.'),
-            (accepted_invite_team_names,
-             'added to this team because of an existing invite.'),
-            ):
-            if len(team_names) == 0:
-                continue
-            elif len(team_names) == 1:
-                verb = 'has been'
-                team_string = team_names[0]
-            elif len(team_names) > 1:
-                verb = 'have been'
-                team_string = (
-                    ', '.join(team_names[:-1]) + ' and ' + team_names[-1])
-            full_message += '%s %s %s' % (team_string, verb, message)
-        self.request.response.addInfoNotification(full_message)
-
-
-class TeamLeaveView(LaunchpadFormView, TeamJoinMixin):
-    schema = Interface
-
-    @property
-    def label(self):
-        return 'Leave ' + cgi.escape(self.context.displayname)
-
-    page_title = label
-
-    @property
-    def cancel_url(self):
-        return canonical_url(self.context)
-
-    next_url = cancel_url
-
-    @action(_("Leave"), name="leave")
-    def action_save(self, action, data):
-        if self.user_can_request_to_leave:
-            self.user.leave(self.context)
-
-
 class PersonEditEmailsView(LaunchpadFormView):
     """A view for editing a person's email settings.
 
@@ -4904,86 +4021,6 @@
         self.next_url = self.action_url
 
 
-class TeamMugshotView(LaunchpadView):
-    """A view for the team mugshot (team photo) page"""
-
-    label = "Member photos"
-    batch_size = config.launchpad.mugshot_batch_size
-
-    def initialize(self):
-        """Cache images to avoid dying from a million cuts."""
-        getUtility(IPersonSet).cacheBrandingForPeople(
-            self.members.currentBatch())
-
-    @cachedproperty
-    def members(self):
-        """Get a batch of all members in the team."""
-        batch_nav = BatchNavigator(
-            self.context.allmembers, self.request, size=self.batch_size)
-        return batch_nav
-
-
-class TeamReassignmentView(ObjectReassignmentView):
-
-    ownerOrMaintainerAttr = 'teamowner'
-    schema = ITeamReassignment
-
-    def __init__(self, context, request):
-        super(TeamReassignmentView, self).__init__(context, request)
-        self.callback = self._addOwnerAsMember
-
-    def validateOwner(self, new_owner):
-        """Display error if the owner is not valid.
-
-        Called by ObjectReassignmentView.validate().
-        """
-        if self.context.inTeam(new_owner):
-            path = self.context.findPathToTeam(new_owner)
-            if len(path) == 1:
-                relationship = 'a direct member'
-                path_string = ''
-            else:
-                relationship = 'an indirect member'
-                full_path = [self.context] + path
-                path_string = '(%s)' % '&rArr;'.join(
-                    team.displayname for team in full_path)
-            error = structured(
-                'Circular team memberships are not allowed. '
-                '%(new)s cannot be the new team owner, since %(context)s '
-                'is %(relationship)s of %(new)s. '
-                '<span style="white-space: nowrap">%(path)s</span>'
-                % dict(new=new_owner.displayname,
-                        context=self.context.displayname,
-                        relationship=relationship,
-                        path=path_string))
-            self.setFieldError(self.ownerOrMaintainerName, error)
-
-    @property
-    def contextName(self):
-        return self.context.displayname
-
-    def _addOwnerAsMember(self, team, oldOwner, newOwner):
-        """Add the new and the old owners as administrators of the team.
-
-        When a user creates a new team, he is added as an administrator of
-        that team. To be consistent with this, we must make the new owner an
-        administrator of the team. This rule is ignored only if the new owner
-        is an inactive member of the team, as that means he's not interested
-        in being a member. The same applies to the old owner.
-        """
-        # Both new and old owners won't be added as administrators of the team
-        # only if they're inactive members. If they're either active or
-        # proposed members they'll be made administrators of the team.
-        if newOwner not in team.inactivemembers:
-            team.addMember(
-                newOwner, reviewer=oldOwner,
-                status=TeamMembershipStatus.ADMIN, force_team_add=True)
-        if oldOwner not in team.inactivemembers:
-            team.addMember(
-                oldOwner, reviewer=oldOwner,
-                status=TeamMembershipStatus.ADMIN, force_team_add=True)
-
-
 class PersonLatestQuestionsView(LaunchpadFormView):
     """View used by the porlet displaying the latest questions made by
     a person.
@@ -6115,43 +5152,6 @@
     links = ('edit', 'administer', 'administer_account', 'branding')
 
 
-class ITeamIndexMenu(Interface):
-    """A marker interface for the +index navigation menu."""
-
-
-class ITeamEditMenu(Interface):
-    """A marker interface for the edit navigation menu."""
-
-
-class TeamNavigationMenuBase(NavigationMenu, TeamMenuMixin):
-
-    @property
-    def person(self):
-        """Override CommonMenuLinks since the view is the context."""
-        return self.context.context
-
-
-class TeamIndexMenu(TeamNavigationMenuBase):
-    """A menu for different aspects of editing a team."""
-
-    usedfor = ITeamIndexMenu
-    facet = 'overview'
-    title = 'Change team'
-    links = ('edit', 'delete', 'join', 'add_my_teams', 'leave')
-
-
-class TeamEditMenu(TeamNavigationMenuBase):
-    """A menu for different aspects of editing a team."""
-
-    usedfor = ITeamEditMenu
-    facet = 'overview'
-    title = 'Change team'
-    links = ('branding', 'common_edithomepage', 'editlanguages', 'reassign',
-             'editemail')
-
-
-classImplements(TeamIndexView, ITeamIndexMenu)
-classImplements(TeamEditView, ITeamEditMenu)
 classImplements(PersonIndexView, IPersonIndexMenu)
 
 

=== modified file 'lib/lp/registry/browser/team.py'
--- lib/lp/registry/browser/team.py	2011-08-23 23:32:26 +0000
+++ lib/lp/registry/browser/team.py	2011-10-05 01:53:26 +0000
@@ -5,12 +5,18 @@
 __all__ = [
     'HasRenewalPolicyMixin',
     'ProposedTeamMembersEditView',
+    'TeamAddMyTeamsView',
     'TeamAddView',
     'TeamBadges',
     'TeamBrandingView',
+    'TeamBreadcrumb',
     'TeamContactAddressView',
+    'TeamEditMenu',
     'TeamEditView',
     'TeamHierarchyView',
+    'TeamIndexMenu',
+    'TeamJoinView',
+    'TeamLeaveView',
     'TeamMailingListConfigurationView',
     'TeamMailingListModerationView',
     'TeamMailingListSubscribersView',
@@ -19,38 +25,71 @@
     'TeamMapView',
     'TeamMapLtdView',
     'TeamMemberAddView',
+    'TeamMembershipView',
+    'TeamMugshotView',
+    'TeamNavigation',
+    'TeamOverviewMenu',
+    'TeamOverviewNavigationMenu',
     'TeamPrivacyAdapter',
+    'TeamReassignmentView',
     ]
 
 
-from datetime import datetime
+import cgi
+from datetime import (
+    datetime,
+    timedelta,
+    )
 import math
 from urllib import unquote
 
 import pytz
+from lazr.restful.utils import smartquote
+from z3c.ptcompat import ViewPageTemplateFile
 from zope.app.form.browser import TextAreaWidget
 from zope.component import getUtility
 from zope.formlib import form
+from zope.formlib.form import FormFields
 from zope.interface import (
+    classImplements,
     implements,
     Interface,
     )
-from zope.schema import Choice
+from zope.publisher.interfaces.browser import IBrowserPublisher
+from zope.security.interfaces import Unauthorized
+from zope.schema import (
+    Bool,
+    Choice,
+    List,
+    Text,
+    )
 from zope.schema.vocabulary import (
+    getVocabularyRegistry,
     SimpleTerm,
     SimpleVocabulary,
     )
 
+from canonical.config import config
 from canonical.launchpad import _
 from canonical.launchpad.interfaces.authtoken import LoginTokenType
 from canonical.launchpad.interfaces.emailaddress import IEmailAddressSet
 from canonical.launchpad.interfaces.logintoken import ILoginTokenSet
 from canonical.launchpad.interfaces.validation import validate_new_team_email
 from canonical.launchpad.webapp import (
+    ApplicationMenu,
     canonical_url,
+    enabled_with_permission,
     LaunchpadView,
+    Link,
+    NavigationMenu,
+    stepthrough,
     )
 from canonical.launchpad.webapp.authorization import check_permission
+from canonical.launchpad.webapp.batching import (
+    ActiveBatchNavigator,
+    InactiveBatchNavigator,
+    )
+from canonical.launchpad.webapp.breadcrumb import Breadcrumb
 from canonical.launchpad.webapp.badge import HasBadgeBase
 from canonical.launchpad.webapp.batching import BatchNavigator
 from canonical.launchpad.webapp.interfaces import ILaunchBag
@@ -66,12 +105,26 @@
 from lp.app.errors import UnexpectedFormData
 from lp.app.validators import LaunchpadValidationError
 from lp.app.widgets.itemswidgets import (
+    LabeledMultiCheckBoxWidget,
     LaunchpadRadioWidget,
     LaunchpadRadioWidgetWithDescription,
     )
 from lp.app.widgets.owner import HiddenUserWidget
 from lp.app.widgets.popup import PersonPickerWidget
+from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
 from lp.registry.browser.branding import BrandingChangeView
+from lp.registry.browser.mailinglists import enabled_with_active_mailing_list
+from lp.registry.browser.objectreassignment import ObjectReassignmentView
+from lp.registry.browser.person import (
+    CommonMenuLinks,
+    PersonIndexView,
+    PersonNavigation,
+    PPANavigationMenuMixIn,
+    )
+from lp.registry.browser.teamjoin import (
+    TeamJoinMixin,
+    userIsActiveTeamMember,
+    )
 from lp.registry.interfaces.mailinglist import (
     IMailingList,
     IMailingListSet,
@@ -79,19 +132,28 @@
     PostedMessageStatus,
     PURGE_STATES,
     )
+from lp.registry.interfaces.mailinglistsubscription import (
+    MailingListAutoSubscribePolicy,
+    )
 from lp.registry.interfaces.person import (
     ImmutableVisibilityError,
     IPersonSet,
     ITeam,
+    ITeamReassignment,
     ITeamContactAddressForm,
     ITeamCreation,
     PersonVisibility,
     PRIVATE_TEAM_PREFIX,
     TeamContactMethod,
+    TeamMembershipRenewalPolicy,
     TeamSubscriptionPolicy,
     )
+from lp.registry.interfaces.poll import IPollSet
 from lp.registry.interfaces.teammembership import (
     CyclicalTeamMembershipError,
+    DAYS_BEFORE_EXPIRATION_WARNING_IS_SENT,
+    ITeamMembership,
+    ITeamMembershipSet,
     TeamMembershipStatus,
     )
 from lp.services.fields import PublicPersonChoice
@@ -1221,3 +1283,907 @@
     @property
     def has_relationships(self):
         return self.has_sub_teams or self.has_super_teams
+
+
+class TeamNavigation(PersonNavigation):
+
+    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
+        # TeamInvitationView can handle memberships in statuses other than
+        # INVITED.
+        membership = getUtility(ITeamMembershipSet).getByPersonAndTeam(
+            self.context, getUtility(IPersonSet).getByName(name))
+        if membership is None:
+            return None
+        return TeamInvitationView(membership, self.request)
+
+    @stepthrough('+member')
+    def traverse_member(self, name):
+        person = getUtility(IPersonSet).getByName(name)
+        if person is None:
+            return None
+        return getUtility(ITeamMembershipSet).getByPersonAndTeam(
+            person, self.context)
+
+
+class TeamBreadcrumb(Breadcrumb):
+    """Builds a breadcrumb for an `ITeam`."""
+
+    @property
+    def text(self):
+        return smartquote('"%s" team') % self.context.displayname
+
+
+class TeamMembershipSelfRenewalView(LaunchpadFormView):
+
+    implements(IBrowserPublisher)
+
+    # This is needed for our breadcrumbs, as there's no <browser:page>
+    # declaration for this view.
+    __name__ = '+self-renewal'
+    schema = ITeamMembership
+    field_names = []
+    template = ViewPageTemplateFile(
+        '../templates/teammembership-self-renewal.pt')
+
+    @property
+    def label(self):
+        return "Renew membership of %s in %s" % (
+            self.context.person.displayname, self.context.team.displayname)
+
+    page_title = label
+
+    def __init__(self, context, request):
+        # Only the member himself or admins of the member (in case it's a
+        # team) can see the page in which they renew memberships that are
+        # about to expire.
+        if not check_permission('launchpad.Edit', context.person):
+            raise Unauthorized(
+                "You may not renew the membership for %s." %
+                context.person.displayname)
+        LaunchpadFormView.__init__(self, context, request)
+
+    def browserDefault(self, request):
+        return self, ()
+
+    @property
+    def reason_for_denied_renewal(self):
+        """Return text describing why the membership can't be renewed."""
+        context = self.context
+        ondemand = TeamMembershipRenewalPolicy.ONDEMAND
+        admin = TeamMembershipStatus.ADMIN
+        approved = TeamMembershipStatus.APPROVED
+        date_limit = datetime.now(pytz.UTC) - timedelta(
+            days=DAYS_BEFORE_EXPIRATION_WARNING_IS_SENT)
+        if context.status not in (admin, approved):
+            text = "it is not active."
+        elif context.team.renewal_policy != ondemand:
+            text = ('<a href="%s">%s</a> is not a team that allows its '
+                    'members to renew their own memberships.'
+                    % (canonical_url(context.team),
+                       context.team.unique_displayname))
+        elif context.dateexpires is None or context.dateexpires > date_limit:
+            if context.person.isTeam():
+                link_text = "Somebody else has already renewed it."
+            else:
+                link_text = (
+                    "You or one of the team administrators has already "
+                    "renewed it.")
+            text = ('it is not set to expire in %d days or less. '
+                    '<a href="%s/+members">%s</a>'
+                    % (DAYS_BEFORE_EXPIRATION_WARNING_IS_SENT,
+                       canonical_url(context.team), link_text))
+        else:
+            raise AssertionError('This membership can be renewed!')
+        return text
+
+    @property
+    def time_before_expiration(self):
+        return self.context.dateexpires - datetime.now(pytz.timezone('UTC'))
+
+    @property
+    def next_url(self):
+        return canonical_url(self.context.person)
+
+    cancel_url = next_url
+
+    @action(_("Renew"), name="renew")
+    def renew_action(self, action, data):
+        member = self.context.person
+        # This if-statement prevents an exception if the user
+        # double clicks on the submit button.
+        if self.context.canBeRenewedByMember():
+            member.renewTeamMembership(self.context.team)
+        self.request.response.addInfoNotification(
+            _("Membership renewed until ${date}.", mapping=dict(
+                    date=self.context.dateexpires.strftime('%Y-%m-%d'))))
+
+
+class ITeamMembershipInvitationAcknowledgementForm(Interface):
+    """Schema for the form in which team admins acknowledge invitations.
+
+    We could use ITeamMembership for that, but the acknowledger_comment is
+    marked readonly there and that means LaunchpadFormView won't include the
+    value of that in the data given to our action handler.
+    """
+
+    acknowledger_comment = Text(
+        title=_("Comment"), required=False, readonly=False)
+
+
+class TeamInvitationView(LaunchpadFormView):
+    """Where team admins can accept/decline membership invitations."""
+
+    implements(IBrowserPublisher)
+
+    # This is needed for our breadcrumbs, as there's no <browser:page>
+    # declaration for this view.
+    __name__ = '+invitation'
+    schema = ITeamMembershipInvitationAcknowledgementForm
+    field_names = ['acknowledger_comment']
+    custom_widget('acknowledger_comment', TextAreaWidget, height=5, width=60)
+    template = ViewPageTemplateFile(
+        '../templates/teammembership-invitation.pt')
+
+    def __init__(self, context, request):
+        # Only admins of the invited team can see the page in which they
+        # approve/decline invitations.
+        if not check_permission('launchpad.Edit', context.person):
+            raise Unauthorized(
+                "Only team administrators can approve/decline invitations "
+                "sent to this team.")
+        LaunchpadFormView.__init__(self, context, request)
+
+    @property
+    def label(self):
+        """See `LaunchpadFormView`."""
+        return "Make %s a member of %s" % (
+            self.context.person.displayname, self.context.team.displayname)
+
+    @property
+    def page_title(self):
+        return smartquote(
+            '"%s" team invitation') % self.context.team.displayname
+
+    def browserDefault(self, request):
+        return self, ()
+
+    @property
+    def next_url(self):
+        return canonical_url(self.context.person)
+
+    @action(_("Accept"), name="accept")
+    def accept_action(self, action, data):
+        if self.context.status != TeamMembershipStatus.INVITED:
+            self.request.response.addInfoNotification(
+                _("This invitation has already been processed."))
+            return
+        member = self.context.person
+        try:
+            member.acceptInvitationToBeMemberOf(
+                self.context.team, data['acknowledger_comment'])
+        except CyclicalTeamMembershipError:
+            self.request.response.addInfoNotification(
+                _("This team may not be added to ${that_team} because it is "
+                  "a member of ${this_team}.",
+                  mapping=dict(
+                      that_team=self.context.team.displayname,
+                      this_team=member.displayname)))
+        else:
+            self.request.response.addInfoNotification(
+                _("This team is now a member of ${team}.", mapping=dict(
+                    team=self.context.team.displayname)))
+
+    @action(_("Decline"), name="decline")
+    def decline_action(self, action, data):
+        if self.context.status != TeamMembershipStatus.INVITED:
+            self.request.response.addInfoNotification(
+                _("This invitation has already been processed."))
+            return
+        member = self.context.person
+        member.declineInvitationToBeMemberOf(
+            self.context.team, data['acknowledger_comment'])
+        self.request.response.addInfoNotification(
+            _("Declined the invitation to join ${team}", mapping=dict(
+                  team=self.context.team.displayname)))
+
+    @action(_("Cancel"), name="cancel")
+    def cancel_action(self, action, data):
+        # Simply redirect back.
+        pass
+
+
+class TeamMenuMixin(PPANavigationMenuMixIn, CommonMenuLinks):
+    """Base class of team menus.
+
+    You will need to override the team attribute if your menu subclass
+    has the view as its context object.
+    """
+
+    def profile(self):
+        target = ''
+        text = 'Overview'
+        return Link(target, text)
+
+    @enabled_with_permission('launchpad.Edit')
+    def edit(self):
+        target = '+edit'
+        text = 'Change details'
+        return Link(target, text, icon='edit')
+
+    @enabled_with_permission('launchpad.Edit')
+    def branding(self):
+        target = '+branding'
+        text = 'Change branding'
+        return Link(target, text, icon='edit')
+
+    @enabled_with_permission('launchpad.Owner')
+    def reassign(self):
+        target = '+reassign'
+        text = 'Change owner'
+        summary = 'Change the owner of the team'
+        return Link(target, text, summary, icon='edit')
+
+    @enabled_with_permission('launchpad.Moderate')
+    def delete(self):
+        target = '+delete'
+        text = 'Delete'
+        summary = 'Delete this team'
+        return Link(target, text, summary, icon='trash-icon')
+
+    @enabled_with_permission('launchpad.View')
+    def members(self):
+        target = '+members'
+        text = 'Show all members'
+        return Link(target, text, icon='team')
+
+    @enabled_with_permission('launchpad.Edit')
+    def received_invitations(self):
+        target = '+invitations'
+        text = 'Show received invitations'
+        return Link(target, text, icon='info')
+
+    @enabled_with_permission('launchpad.Edit')
+    def add_member(self):
+        target = '+addmember'
+        text = 'Add member'
+        return Link(target, text, icon='add')
+
+    @enabled_with_permission('launchpad.Edit')
+    def proposed_members(self):
+        target = '+editproposedmembers'
+        text = 'Approve or decline members'
+        return Link(target, text, icon='add')
+
+    def map(self):
+        target = '+map'
+        text = 'View map and time zones'
+        return Link(target, text, icon='meeting')
+
+    def add_my_teams(self):
+        target = '+add-my-teams'
+        text = 'Add one of my teams'
+        enabled = True
+        restricted = TeamSubscriptionPolicy.RESTRICTED
+        if self.person.subscriptionpolicy == restricted:
+            # This is a restricted team; users can't join.
+            enabled = False
+        return Link(target, text, icon='add', enabled=enabled)
+
+    def memberships(self):
+        target = '+participation'
+        text = 'Show team participation'
+        return Link(target, text, icon='info')
+
+    @enabled_with_permission('launchpad.View')
+    def mugshots(self):
+        target = '+mugshots'
+        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'
+        text = 'Set contact address'
+        summary = (
+            'The address Launchpad uses to contact %s' %
+            self.person.displayname)
+        return Link(target, text, summary, icon='edit')
+
+    @enabled_with_permission('launchpad.Moderate')
+    def configure_mailing_list(self):
+        target = '+mailinglist'
+        mailing_list = self.person.mailing_list
+        if mailing_list is not None:
+            text = 'Configure mailing list'
+            icon = 'edit'
+        else:
+            text = 'Create a mailing list'
+            icon = 'add'
+        summary = (
+            'The mailing list associated with %s' % self.context.displayname)
+        return Link(target, text, summary, icon=icon)
+
+    @enabled_with_active_mailing_list
+    @enabled_with_permission('launchpad.Edit')
+    def moderate_mailing_list(self):
+        target = '+mailinglist-moderate'
+        text = 'Moderate mailing list'
+        summary = (
+            'The mailing list associated with %s' % self.context.displayname)
+        return Link(target, text, summary, icon='edit')
+
+    @enabled_with_permission('launchpad.Edit')
+    def editlanguages(self):
+        target = '+editlanguages'
+        text = 'Set preferred languages'
+        return Link(target, text, icon='edit')
+
+    def leave(self):
+        enabled = True
+        if not userIsActiveTeamMember(self.person):
+            enabled = False
+        if self.person.teamowner == self.user:
+            # The owner cannot leave his team.
+            enabled = False
+        target = '+leave'
+        text = 'Leave the Team'
+        icon = 'remove'
+        return Link(target, text, icon=icon, enabled=enabled)
+
+    def join(self):
+        enabled = True
+        person = self.person
+        if userIsActiveTeamMember(person):
+            enabled = False
+        elif (self.person.subscriptionpolicy ==
+              TeamSubscriptionPolicy.RESTRICTED):
+            # This is a restricted team; users can't join.
+            enabled = False
+        target = '+join'
+        text = 'Join the team'
+        icon = 'add'
+        return Link(target, text, icon=icon, enabled=enabled)
+
+
+class TeamOverviewMenu(ApplicationMenu, TeamMenuMixin, HasRecipesMenuMixin):
+
+    usedfor = ITeam
+    facet = 'overview'
+    links = [
+        'edit',
+        'branding',
+        'common_edithomepage',
+        'members',
+        'mugshots',
+        'add_member',
+        'proposed_members',
+        'memberships',
+        'received_invitations',
+        'editemail',
+        'configure_mailing_list',
+        'moderate_mailing_list',
+        'editlanguages',
+        'map',
+        'polls',
+        'add_poll',
+        'join',
+        'leave',
+        'add_my_teams',
+        'reassign',
+        'projects',
+        'activate_ppa',
+        'maintained',
+        'ppa',
+        'related_software_summary',
+        'view_recipes',
+        'subscriptions',
+        'structural_subscriptions',
+        ]
+
+
+class TeamOverviewNavigationMenu(NavigationMenu, TeamMenuMixin):
+    """A top-level menu for navigation within a Team."""
+
+    usedfor = ITeam
+    facet = 'overview'
+    links = ['profile', 'polls', 'members', 'ppas']
+
+
+class TeamMembershipView(LaunchpadView):
+    """The view behind ITeam/+members."""
+
+    @cachedproperty
+    def label(self):
+        return smartquote('Members of "%s"' % self.context.displayname)
+
+    @cachedproperty
+    def active_memberships(self):
+        """Current members of the team."""
+        return ActiveBatchNavigator(
+            self.context.member_memberships, self.request)
+
+    @cachedproperty
+    def inactive_memberships(self):
+        """Former members of the team."""
+        return InactiveBatchNavigator(
+            self.context.getInactiveMemberships(), self.request)
+
+    @cachedproperty
+    def invited_memberships(self):
+        """Other teams invited to become members of this team."""
+        return list(self.context.getInvitedMemberships())
+
+    @cachedproperty
+    def proposed_memberships(self):
+        """Users who have requested to join this team."""
+        return list(self.context.getProposedMemberships())
+
+    @property
+    def have_pending_members(self):
+        return self.proposed_memberships or self.invited_memberships
+
+
+class TeamIndexView(PersonIndexView, TeamJoinMixin):
+    """The view class for the +index page.
+
+    This class is needed, so an action menu that only applies to
+    teams can be displayed without showing up on the person index page.
+    """
+
+    @property
+    def can_show_subteam_portlet(self):
+        """Only show the subteam portlet if there is info to display.
+
+        Either the team is a member of another team, or there are
+        invitations to join a team, and the owner needs to see the
+        link so that the invitation can be accepted.
+        """
+        try:
+            return (self.context.super_teams.count() > 0
+                    or (self.context.open_membership_invitations
+                        and check_permission('launchpad.Edit', self.context)))
+        except AttributeError, e:
+            raise AssertionError(e)
+
+    @property
+    def visibility_info(self):
+        if self.context.visibility == PersonVisibility.PRIVATE:
+            return 'Private team'
+        else:
+            return 'Public team'
+
+    @property
+    def visibility_portlet_class(self):
+        """The portlet class for team visibility."""
+        if self.context.visibility == PersonVisibility.PUBLIC:
+            return 'portlet'
+        return 'portlet private'
+
+    @property
+    def add_member_step_title(self):
+        """A string for setup_add_member_handler with escaped quotes."""
+        vocabulary_registry = getVocabularyRegistry()
+        vocabulary = vocabulary_registry.get(self.context, 'ValidTeamMember')
+        return vocabulary.step_title.replace("'", "\\'").replace('"', '\\"')
+
+
+class TeamJoinForm(Interface):
+    """Schema for team join."""
+    mailinglist_subscribe = Bool(
+        title=_("Subscribe me to this team's mailing list"),
+        required=True, default=True)
+
+
+class TeamJoinView(LaunchpadFormView, TeamJoinMixin):
+    """A view class for joining a team."""
+    schema = TeamJoinForm
+
+    @property
+    def label(self):
+        return 'Join ' + cgi.escape(self.context.displayname)
+
+    page_title = label
+
+    def setUpWidgets(self):
+        super(TeamJoinView, self).setUpWidgets()
+        if 'mailinglist_subscribe' in self.field_names:
+            widget = self.widgets['mailinglist_subscribe']
+            widget.setRenderedValue(self.user_wants_list_subscriptions)
+
+    @property
+    def field_names(self):
+        """See `LaunchpadFormView`.
+
+        If the user can subscribe to the mailing list then include the
+        mailinglist subscription checkbox otherwise remove it.
+        """
+        if self.user_can_subscribe_to_list:
+            return ['mailinglist_subscribe']
+        else:
+            return []
+
+    @property
+    def join_allowed(self):
+        """Is the logged in user allowed to join this team?
+
+        The answer is yes if this team's subscription policy is not RESTRICTED
+        and this team's visibility is either None or PUBLIC.
+        """
+        # Joining a moderated team will put you on the proposed_members
+        # list. If it is a private team, you are not allowed to view the
+        # proposed_members attribute until you are an active member;
+        # therefore, it would look like the join button is broken. Either
+        # private teams should always have a restricted subscription policy,
+        # or we need a more complicated permission model.
+        if not (self.context.visibility is None
+                or self.context.visibility == PersonVisibility.PUBLIC):
+            return False
+
+        restricted = TeamSubscriptionPolicy.RESTRICTED
+        return self.context.subscriptionpolicy != restricted
+
+    @property
+    def user_can_request_to_join(self):
+        """Can the logged in user request to join this team?
+
+        The user can request if he's allowed to join this team and if he's
+        not yet an active member of this team.
+        """
+        if not self.join_allowed:
+            return False
+        return not (self.user_is_active_member or
+                    self.user_is_proposed_member)
+
+    @property
+    def user_wants_list_subscriptions(self):
+        """Is the user interested in subscribing to mailing lists?"""
+        return (self.user.mailing_list_auto_subscribe_policy !=
+                MailingListAutoSubscribePolicy.NEVER)
+
+    @property
+    def team_is_moderated(self):
+        """Is this team a moderated team?
+
+        Return True if the team's subscription policy is MODERATED.
+        """
+        policy = self.context.subscriptionpolicy
+        return policy == TeamSubscriptionPolicy.MODERATED
+
+    @property
+    def next_url(self):
+        return canonical_url(self.context)
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    @action(_("Join"), name="join")
+    def action_save(self, action, data):
+        response = self.request.response
+
+        if self.user_can_request_to_join:
+            # Shut off mailing list auto-subscription - we want direct
+            # control over it.
+            self.user.join(self.context, may_subscribe_to_list=False)
+
+            if self.team_is_moderated:
+                response.addInfoNotification(
+                    _('Your request to join ${team} is awaiting '
+                      'approval.',
+                      mapping={'team': self.context.displayname}))
+            else:
+                response.addInfoNotification(
+                    _('You have successfully joined ${team}.',
+                      mapping={'team': self.context.displayname}))
+            if data.get('mailinglist_subscribe', False):
+                self._subscribeToList(response)
+
+        else:
+            response.addErrorNotification(
+                _('You cannot join ${team}.',
+                  mapping={'team': self.context.displayname}))
+
+    def _subscribeToList(self, response):
+        """Subscribe the user to the team's mailing list."""
+
+        if self.user_can_subscribe_to_list:
+            # 'user_can_subscribe_to_list' should have dealt with
+            # all of the error cases.
+            self.context.mailing_list.subscribe(self.user)
+
+            if self.team_is_moderated:
+                response.addInfoNotification(
+                    _('Your mailing list subscription is '
+                      'awaiting approval.'))
+            else:
+                response.addInfoNotification(
+                    structured(
+                        _("You have been subscribed to this "
+                          "team&#x2019;s mailing list.")))
+        else:
+            # A catch-all case, perhaps from stale or mangled
+            # form data.
+            response.addErrorNotification(
+                _('Mailing list subscription failed.'))
+
+
+class TeamAddMyTeamsView(LaunchpadFormView):
+    """Propose/add to this team any team that you're an administrator of."""
+
+    page_title = 'Propose/add one of your teams to another one'
+    custom_widget('teams', LabeledMultiCheckBoxWidget)
+
+    def initialize(self):
+        context = self.context
+        if context.subscriptionpolicy == TeamSubscriptionPolicy.MODERATED:
+            self.label = 'Propose these teams as members'
+        else:
+            self.label = 'Add these teams to %s' % context.displayname
+        self.next_url = canonical_url(context)
+        super(TeamAddMyTeamsView, self).initialize()
+
+    def setUpFields(self):
+        terms = []
+        for team in self.candidate_teams:
+            text = structured(
+                '<a href="%s">%s</a>', canonical_url(team), team.displayname)
+            terms.append(SimpleTerm(team, team.name, text))
+        self.form_fields = FormFields(
+            List(__name__='teams',
+                 title=_(''),
+                 value_type=Choice(vocabulary=SimpleVocabulary(terms)),
+                 required=False),
+            render_context=self.render_context)
+
+    def setUpWidgets(self, context=None):
+        super(TeamAddMyTeamsView, self).setUpWidgets(context)
+        self.widgets['teams'].display_label = False
+
+    @cachedproperty
+    def candidate_teams(self):
+        """Return the set of teams that can be added/proposed for the context.
+
+        We return only teams that the user can administer, that aren't already
+        a member in the context or that the context isn't a member of. (Of
+        course, the context is also omitted.)
+        """
+        candidates = []
+        for team in self.user.getAdministratedTeams():
+            if team == self.context:
+                continue
+            elif team.visibility != PersonVisibility.PUBLIC:
+                continue
+            elif team in self.context.activemembers:
+                # The team is already a member of the context object.
+                continue
+            elif self.context.hasParticipationEntryFor(team):
+                # The context object is a member/submember of the team.
+                continue
+            candidates.append(team)
+        return candidates
+
+    @property
+    def cancel_url(self):
+        """The return URL."""
+        return canonical_url(self.context)
+
+    def validate(self, data):
+        if len(data.get('teams', [])) == 0:
+            self.setFieldError('teams',
+                               'Please select the team(s) you want to be '
+                               'member(s) of this team.')
+
+    def hasCandidates(self, action):
+        """Return whether the user has teams to propose."""
+        return len(self.candidate_teams) > 0
+
+    @action(_("Continue"), name="continue", condition=hasCandidates)
+    def continue_action(self, action, data):
+        """Make the selected teams join this team."""
+        context = self.context
+        is_admin = check_permission('launchpad.Admin', context)
+        membership_set = getUtility(ITeamMembershipSet)
+        proposed_team_names = []
+        added_team_names = []
+        accepted_invite_team_names = []
+        membership_set = getUtility(ITeamMembershipSet)
+        for team in data['teams']:
+            membership = membership_set.getByPersonAndTeam(team, context)
+            if (membership is not None
+                and membership.status == TeamMembershipStatus.INVITED):
+                team.acceptInvitationToBeMemberOf(
+                    context,
+                    'Accepted an already pending invitation while trying to '
+                    'propose the team for membership.')
+                accepted_invite_team_names.append(team.displayname)
+            elif is_admin:
+                context.addMember(team, reviewer=self.user)
+                added_team_names.append(team.displayname)
+            else:
+                team.join(context, requester=self.user)
+                membership = membership_set.getByPersonAndTeam(team, context)
+                if membership.status == TeamMembershipStatus.PROPOSED:
+                    proposed_team_names.append(team.displayname)
+                elif membership.status == TeamMembershipStatus.APPROVED:
+                    added_team_names.append(team.displayname)
+                else:
+                    raise AssertionError(
+                        'Unexpected membership status (%s) for %s.'
+                        % (membership.status.name, team.name))
+        full_message = ''
+        for team_names, message in (
+            (proposed_team_names, 'proposed to this team.'),
+            (added_team_names, 'added to this team.'),
+            (accepted_invite_team_names,
+             'added to this team because of an existing invite.'),
+            ):
+            if len(team_names) == 0:
+                continue
+            elif len(team_names) == 1:
+                verb = 'has been'
+                team_string = team_names[0]
+            elif len(team_names) > 1:
+                verb = 'have been'
+                team_string = (
+                    ', '.join(team_names[:-1]) + ' and ' + team_names[-1])
+            full_message += '%s %s %s' % (team_string, verb, message)
+        self.request.response.addInfoNotification(full_message)
+
+
+class TeamLeaveView(LaunchpadFormView, TeamJoinMixin):
+    schema = Interface
+
+    @property
+    def label(self):
+        return 'Leave ' + cgi.escape(self.context.displayname)
+
+    page_title = label
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    next_url = cancel_url
+
+    @action(_("Leave"), name="leave")
+    def action_save(self, action, data):
+        if self.user_can_request_to_leave:
+            self.user.leave(self.context)
+
+
+class TeamReassignmentView(ObjectReassignmentView):
+
+    ownerOrMaintainerAttr = 'teamowner'
+    schema = ITeamReassignment
+
+    def __init__(self, context, request):
+        super(TeamReassignmentView, self).__init__(context, request)
+        self.callback = self._addOwnerAsMember
+
+    def validateOwner(self, new_owner):
+        """Display error if the owner is not valid.
+
+        Called by ObjectReassignmentView.validate().
+        """
+        if self.context.inTeam(new_owner):
+            path = self.context.findPathToTeam(new_owner)
+            if len(path) == 1:
+                relationship = 'a direct member'
+                path_string = ''
+            else:
+                relationship = 'an indirect member'
+                full_path = [self.context] + path
+                path_string = '(%s)' % '&rArr;'.join(
+                    team.displayname for team in full_path)
+            error = structured(
+                'Circular team memberships are not allowed. '
+                '%(new)s cannot be the new team owner, since %(context)s '
+                'is %(relationship)s of %(new)s. '
+                '<span style="white-space: nowrap">%(path)s</span>'
+                % dict(new=new_owner.displayname,
+                        context=self.context.displayname,
+                        relationship=relationship,
+                        path=path_string))
+            self.setFieldError(self.ownerOrMaintainerName, error)
+
+    @property
+    def contextName(self):
+        return self.context.displayname
+
+    def _addOwnerAsMember(self, team, oldOwner, newOwner):
+        """Add the new and the old owners as administrators of the team.
+
+        When a user creates a new team, he is added as an administrator of
+        that team. To be consistent with this, we must make the new owner an
+        administrator of the team. This rule is ignored only if the new owner
+        is an inactive member of the team, as that means he's not interested
+        in being a member. The same applies to the old owner.
+        """
+        # Both new and old owners won't be added as administrators of the team
+        # only if they're inactive members. If they're either active or
+        # proposed members they'll be made administrators of the team.
+        if newOwner not in team.inactivemembers:
+            team.addMember(
+                newOwner, reviewer=oldOwner,
+                status=TeamMembershipStatus.ADMIN, force_team_add=True)
+        if oldOwner not in team.inactivemembers:
+            team.addMember(
+                oldOwner, reviewer=oldOwner,
+                status=TeamMembershipStatus.ADMIN, force_team_add=True)
+
+
+class ITeamIndexMenu(Interface):
+    """A marker interface for the +index navigation menu."""
+
+
+class ITeamEditMenu(Interface):
+    """A marker interface for the edit navigation menu."""
+
+
+class TeamNavigationMenuBase(NavigationMenu, TeamMenuMixin):
+
+    @property
+    def person(self):
+        """Override CommonMenuLinks since the view is the context."""
+        return self.context.context
+
+
+class TeamIndexMenu(TeamNavigationMenuBase):
+    """A menu for different aspects of editing a team."""
+
+    usedfor = ITeamIndexMenu
+    facet = 'overview'
+    title = 'Change team'
+    links = ('edit', 'delete', 'join', 'add_my_teams', 'leave')
+
+
+class TeamEditMenu(TeamNavigationMenuBase):
+    """A menu for different aspects of editing a team."""
+
+    usedfor = ITeamEditMenu
+    facet = 'overview'
+    title = 'Change team'
+    links = ('branding', 'common_edithomepage', 'editlanguages', 'reassign',
+             'editemail')
+
+
+class TeamMugshotView(LaunchpadView):
+    """A view for the team mugshot (team photo) page"""
+
+    label = "Member photos"
+    batch_size = config.launchpad.mugshot_batch_size
+
+    def initialize(self):
+        """Cache images to avoid dying from a million cuts."""
+        getUtility(IPersonSet).cacheBrandingForPeople(
+            self.members.currentBatch())
+
+    @cachedproperty
+    def members(self):
+        """Get a batch of all members in the team."""
+        batch_nav = BatchNavigator(
+            self.context.allmembers, self.request, size=self.batch_size)
+        return batch_nav
+
+
+classImplements(TeamIndexView, ITeamIndexMenu)
+classImplements(TeamEditView, ITeamEditMenu)

=== added file 'lib/lp/registry/browser/teamjoin.py'
--- lib/lp/registry/browser/teamjoin.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/browser/teamjoin.py	2011-10-05 01:53:26 +0000
@@ -0,0 +1,82 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+    'TeamJoinMixin',
+    'userIsActiveTeamMember',
+    ]
+
+from zope.component import getUtility
+
+from canonical.launchpad.webapp.authorization import check_permission
+from canonical.launchpad.webapp.interfaces import ILaunchBag
+
+
+def userIsActiveTeamMember(team):
+    """Return True if the user is an active member of this team."""
+    user = getUtility(ILaunchBag).user
+    if user is None:
+        return False
+    if not check_permission('launchpad.View', team):
+        return False
+    return user in team.activemembers
+
+
+class TeamJoinMixin:
+    """Mixin class for views related to joining teams."""
+
+    @property
+    def user_can_subscribe_to_list(self):
+        """Can the prospective member subscribe to this team's mailing list?
+
+        A user can subscribe to the list if the team has an active
+        mailing list, and if they do not already have a subscription.
+        """
+        if self.team_has_mailing_list:
+            # If we are already subscribed, then we can not subscribe again.
+            return not self.user_is_subscribed_to_list
+        else:
+            return False
+
+    @property
+    def user_is_subscribed_to_list(self):
+        """Is the user subscribed to the team's mailing list?
+
+        Subscriptions hang around even if the list is deactivated, etc.
+
+        It is an error to ask if the user is subscribed to a mailing list
+        that doesn't exist.
+        """
+        if self.user is None:
+            return False
+
+        mailing_list = self.context.mailing_list
+        assert mailing_list is not None, "This team has no mailing list."
+        has_subscription = bool(mailing_list.getSubscription(self.user))
+        return has_subscription
+
+    @property
+    def team_has_mailing_list(self):
+        """Is the team mailing list available for subscription?"""
+        mailing_list = self.context.mailing_list
+        return mailing_list is not None and mailing_list.is_usable
+
+    @property
+    def user_is_active_member(self):
+        """Return True if the user is an active member of this team."""
+        return userIsActiveTeamMember(self.context)
+
+    @property
+    def user_is_proposed_member(self):
+        """Return True if the user is a proposed member of this team."""
+        if self.user is None:
+            return False
+        return self.user in self.context.proposedmembers
+
+    @property
+    def user_can_request_to_leave(self):
+        """Return true if the user can request to leave this team.
+
+        A given user can leave a team only if he's an active member.
+        """
+        return self.user_is_active_member

=== modified file 'lib/lp/registry/browser/tests/teammembership-views.txt'
--- lib/lp/registry/browser/tests/teammembership-views.txt	2009-12-24 01:41:54 +0000
+++ lib/lp/registry/browser/tests/teammembership-views.txt	2011-10-05 01:53:26 +0000
@@ -59,7 +59,7 @@
 to join another team. The invitation is a TeamMembership instance. The view
 is applied during the stepto traversal--it is not a named view in ZCML.
 
-    >>> from lp.registry.browser.person import TeamInvitationView
+    >>> from lp.registry.browser.team import TeamInvitationView
 
     >>> login_person(team_owner)
     >>> ignored = super_team.addMember(team, team_owner)

=== modified file 'lib/lp/registry/browser/tests/test_person_view.py'
--- lib/lp/registry/browser/tests/test_person_view.py	2011-09-29 10:11:05 +0000
+++ lib/lp/registry/browser/tests/test_person_view.py	2011-10-05 01:53:26 +0000
@@ -40,8 +40,8 @@
 from lp.registry.browser.person import (
     PersonEditView,
     PersonView,
-    TeamInvitationView,
     )
+from lp.registry.browser.team import TeamInvitationView
 from lp.registry.interfaces.karma import IKarmaCacheManager
 from lp.registry.interfaces.person import (
     IPersonSet,

=== modified file 'lib/lp/registry/browser/tests/test_team.py'
--- lib/lp/registry/browser/tests/test_team.py	2011-08-26 20:00:37 +0000
+++ lib/lp/registry/browser/tests/test_team.py	2011-10-05 01:53:26 +0000
@@ -8,7 +8,7 @@
 
 from canonical.launchpad.webapp.publisher import canonical_url
 from canonical.testing.layers import DatabaseFunctionalLayer
-from lp.registry.browser.person import TeamOverviewMenu
+from lp.registry.browser.team import TeamOverviewMenu
 from lp.registry.interfaces.persontransferjob import IPersonMergeJobSource
 from lp.registry.interfaces.teammembership import (
     ITeamMembershipSet,

=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml	2011-09-28 03:21:49 +0000
+++ lib/lp/registry/configure.zcml	2011-10-05 01:53:26 +0000
@@ -69,7 +69,7 @@
             set_attributes="reviewed_by reviewer_comment"/>
     </class>
     <class
-        class="lp.registry.browser.person.TeamInvitationView">
+        class="lp.registry.browser.team.TeamInvitationView">
         <allow
             interface="zope.publisher.interfaces.browser.IBrowserPublisher"/>
         <allow
@@ -77,7 +77,7 @@
                 __call__"/>
     </class>
     <class
-        class="lp.registry.browser.person.TeamMembershipSelfRenewalView">
+        class="lp.registry.browser.team.TeamMembershipSelfRenewalView">
         <allow
             interface="zope.publisher.interfaces.browser.IBrowserPublisher"/>
         <allow
@@ -869,7 +869,7 @@
         <adapter
             provides="canonical.launchpad.webapp.interfaces.IBreadcrumb"
             for="lp.registry.interfaces.person.ITeam"
-            factory="lp.registry.browser.person.TeamBreadcrumb"
+            factory="lp.registry.browser.team.TeamBreadcrumb"
             permission="zope.Public"/>
 
         <adapter