← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~sinzui/launchpad/delete-team-3 into lp:launchpad/devel

 

Curtis Hovey has proposed merging lp:~sinzui/launchpad/delete-team-3 into lp:launchpad/devel.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  #523985 teams cannot purge their mailing list
  https://bugs.launchpad.net/bugs/523985
  #577079 PersonSet.merge must delete the team email address
  https://bugs.launchpad.net/bugs/577079
  #589125 Message about deactivating the mailing list when deleting a team needs bling
  https://bugs.launchpad.net/bugs/589125
  #599464 Can't delete team with superteams
  https://bugs.launchpad.net/bugs/599464


This is my branch to unblock owners from deleting their teams.

    lp:~sinzui/launchpad/delete-team-3
    Diff size: 492
    Launchpad bug:
        https://bugs.launchpad.net/bugs/599464
        https://bugs.launchpad.net/bugs/523985
        https://bugs.launchpad.net/bugs/589125
        https://bugs.launchpad.net/bugs/577079
    Test command: ./bin/test -vv --layer=DatabaseFunctional \
        -t peoplemerge-views -t mailinglist-views
        -t test_peoplemerge -t test_teammembership
    Pre-implementation: barry
    Target release: 10.11


Unblock owners from deleting their teams
-----------------------------------------

The registry team continues to help owners delete there teams. Owners often
cannot delete the team because it has an unpurged mailing list or super
teams:

523985	teams cannot purge their mailing list
    Owners cannot purge mailing lists, a registry admin can. But in the
    case of deleting a team with a deactivated the mailing list, we know
    no one can use the list, so it is okay to automatically purge.

599464	Can't delete team with super teams
    The code doing the delete action *thinks* it is doing a merge action.
    Merge cannot handle super teams because there is a chance that a cyclic
    team member error will occur. But since delete removes the team members,
    there is never any chance of a cyclic error. Actually, the delete step
    should remove the team from the super teams immediately after the
    membership is removed.

589125	Message about deactivating the mailing list when deleting a team
        needs bling
    The sentence saying that the delete cannot be performed until the mailing
    list is purged could be bold to alert the user.

577079	PersonSet.merge must delete the team email address
    The subclass that delete teams removes the email address, but this
    rule applies to all team merges. Move the rule to the base class.


Rules
-----

On closer examination, these issues affect users merging teams too. An owner
who is merging his team into the ~registry team is doing a delete, and he
is stuck as well. (The delete rules preselect the teams used in a merge).

    * Allow owners to purge lists.
    * Update the rules to permit the owner to delete a team when the view
      cane safely purge the mailing list.
    * Allow admins/owners to retract their team's membership in another team.
    * Update the merge rules to remove the team from its super teams
      before it starts the merge into ~registry.

QA
--

    * Visit open team delete questions that are blocked by lists and
      super teams.
    * Delete all the teams.
    * Rejoice that this is that last time this will ever need to be done
      by a Registry Admins.


Lint
----

Linting changed files:
  lib/lp/registry/browser/peoplemerge.py
  lib/lp/registry/browser/team.py
  lib/lp/registry/browser/tests/mailinglist-views.txt
  lib/lp/registry/browser/tests/peoplemerge-views.txt
  lib/lp/registry/browser/tests/test_peoplemerge.py
  lib/lp/registry/interfaces/person.py
  lib/lp/registry/model/person.py
  lib/lp/registry/templates/team-delete.pt
  lib/lp/registry/tests/test_teammembership.py


Test
----

lib/lp/registry/browser/tests/mailinglist-views.txt
    * Updated tests to document that an owner can purge a mailing list.
    * This test was broken! The setup of the owner never worked (the
      view was testing anonymous). The setup of logged in user was testing
      the view implementation of security; the test now logs the correct
      user in to verify the permissions.

lib/lp/registry/browser/tests/peoplemerge-views.txt
    * Removed doctest. There is a revised test added to test_peoplemerge

lib/lp/registry/browser/tests/test_peoplemerge.py
    * Added tests for AdminTeamMergeView's handling of team email
      addresses, mailing lists, and super teams.

lib/lp/registry/tests/test_teammembership.py
   * Added tests to verify that memberships can be retracted and that the
     TeamMembershipStatus is correct.
   * Updated the tests to run on the correct layer.


Implementation
--------------

lib/lp/registry/browser/peoplemerge.py
    * Moved the delete team email address rule from the delete view to
      AdminTeamMergeView.
    * Extended AdminTeamMergeView to purge mailing lists when possible and
      to remove super teams when the merge is with Registry Admins.

lib/lp/registry/browser/team.py
    * Replaced the view implementation of security with standard security
      checkers. This change implicitly allows the team owner to purge his
      list.

lib/lp/registry/interfaces/person.py
    * Added retractTeamMembership() so that teams can leave other teams.
    * This is a partial fix for bugs 239486 and 656782 where users cannot
      retract memberships in PENDING or INVITED states. Savvy user can
      use the API to fix the issue. The real fix will require a lot of
      UI work.

lib/lp/registry/model/person.py
    * Reimplemented Person.leave() to be a specific case of
      Person.retractTeamMembership().
    * Added retractTeamMembership() as a stormification of the Person.leave()
      method.
    * PENDING and INVITED states are supported because there are edge cases
      in production where users want to delete a team, but it has a
      membership stuck in a another team's join queue.

lib/lp/registry/templates/team-delete.pt
    * Made the  mailing list message bold so that users immediately know why
      there is not Delete button shown on the page.
-- 
https://code.launchpad.net/~sinzui/launchpad/delete-team-3/+merge/39746
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~sinzui/launchpad/delete-team-3 into lp:launchpad/devel.
=== modified file 'lib/lp/registry/browser/peoplemerge.py'
--- lib/lp/registry/browser/peoplemerge.py	2010-09-23 15:34:05 +0000
+++ lib/lp/registry/browser/peoplemerge.py	2010-11-01 13:11:51 +0000
@@ -34,13 +34,14 @@
     LaunchpadView,
     )
 from canonical.launchpad.webapp.interfaces import ILaunchBag
-from lp.registry.interfaces.mailinglist import MailingListStatus
+from lp.registry.interfaces.mailinglist import PURGE_STATES
 from lp.registry.interfaces.person import (
     IAdminPeopleMergeSchema,
     IAdminTeamMergeSchema,
     IPersonSet,
     IRequestPeopleMerge,
     )
+from lp.services.propertycache import cachedproperty
 
 
 class RequestPeopleMergeView(LaunchpadFormView):
@@ -216,7 +217,27 @@
     def hasMailingList(self, team):
         return (
             team.mailing_list is not None
-            and team.mailing_list.status != MailingListStatus.PURGED)
+            and team.mailing_list.status not in PURGE_STATES)
+
+    @cachedproperty
+    def registry_experts(self):
+        return getUtility(ILaunchpadCelebrities).registry_experts
+
+    def doMerge(self, data):
+        """Purge the non-transferable team data and merge."""
+        # A team cannot have more than one mailing list. The old list will
+        # remain in the archive.
+        if self.dupe_person.mailing_list is not None:
+            self.dupe_person.mailing_list.purge()
+        # A team cannot have more than one email address; they are not
+        # transferable because the identity of the team has changed.
+        self.dupe_person.setContactAddress(None)
+        # The registry experts does not want to acquire super teams from a
+        # merge.
+        if self.target_person is self.registry_experts:
+            for team in self.dupe_person.teams_participated_in:
+                self.dupe_person.retractTeamMembership(team, self.user)
+        super(AdminTeamMergeView, self).doMerge(data)
 
     def validate(self, data):
         """Check there are no mailing lists associated with the dupe team."""
@@ -228,9 +249,14 @@
 
         super(AdminTeamMergeView, self).validate(data)
         dupe_team = data['dupe_person']
-        # Our code doesn't know how to merge a team's superteams, so we
-        # prohibit that here.
-        if dupe_team.super_teams.count() > 0:
+        target_team = data['target_person']
+        # Merge cannot reconcile cyclic membership in super teams.
+        # Super team memberships are automatically removed when merging into
+        # the registry experts team. When merging into any other team, an
+        # error must be raised to explain that the user must remove the teams
+        # himself.
+        if (target_team != self.registry_experts
+            and dupe_team.super_teams.count() > 0):
             self.addError(_(
                 "${name} has super teams, so it can't be merged.",
                 mapping=dict(name=dupe_team.name)))
@@ -330,9 +356,6 @@
     @action('Delete', name='delete', condition=canDelete)
     def merge_action(self, action, data):
         base = super(DeleteTeamView, self)
-        # Delete is implemented as a merge process, but email addresses should
-        # be deleted because ~registry can never claim them.
-        self.context.setContactAddress(None)
         base.deactivate_members_and_merge_action.success(data)
 
 

=== modified file 'lib/lp/registry/browser/team.py'
--- lib/lp/registry/browser/team.py	2010-09-25 14:29:32 +0000
+++ lib/lp/registry/browser/team.py	2010-11-01 13:11:51 +0000
@@ -44,7 +44,6 @@
 from canonical.launchpad import _
 from canonical.launchpad.interfaces.authtoken import LoginTokenType
 from canonical.launchpad.interfaces.emailaddress import IEmailAddressSet
-from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
 from canonical.launchpad.interfaces.logintoken import ILoginTokenSet
 from canonical.launchpad.interfaces.validation import validate_new_team_email
 from canonical.launchpad.validators import LaunchpadValidationError
@@ -78,7 +77,6 @@
     )
 from lp.registry.interfaces.person import (
     ImmutableVisibilityError,
-    IPerson,
     IPersonSet,
     ITeam,
     ITeamContactAddressForm,
@@ -742,15 +740,13 @@
 
         The list must exist and be in one of the REGISTERED, DECLINED, FAILED,
         or INACTIVE states.  Further, the user doing the purging, must be
-        a Launchpad administrator or mailing list expert.
+        an owner, Launchpad administrator or mailing list expert.
         """
-        requester = IPerson(self.request.principal, None)
-        celebrities = getUtility(ILaunchpadCelebrities)
-        if (requester is None or
-            (not requester.inTeam(celebrities.admin) and
-             not requester.inTeam(celebrities.mailing_list_experts))):
+        if (check_permission('launchpad.Moderate', self.context) or
+            check_permission('launchpad.MailingListManager', self.context)):
+            return self.getListInState(*PURGE_STATES) is not None
+        else:
             return False
-        return self.getListInState(*PURGE_STATES) is not None
 
 
 class TeamMailingListSubscribersView(LaunchpadView):

=== modified file 'lib/lp/registry/browser/tests/mailinglist-views.txt'
--- lib/lp/registry/browser/tests/mailinglist-views.txt	2010-08-28 23:01:18 +0000
+++ lib/lp/registry/browser/tests/mailinglist-views.txt	2010-11-01 13:11:51 +0000
@@ -76,7 +76,7 @@
     >>> team_one, list_one = factory.makeTeamAndMailingList(
     ...     'team-one', 'no-priv')
 
-    >>> the_owner = team_one.teamowner.preferredemail.email
+    >>> the_owner = team_one.teamowner
 
     >>> from canonical.launchpad.interfaces.launchpad import (
     ...     ILaunchpadCelebrities)
@@ -85,9 +85,9 @@
     >>> an_admin = list(celebrities.admin.allmembers)[0]
 
     >>> def create_view(principal, form=None):
+    ...     login_person(principal)
     ...     return create_initialized_view(
-    ...         team_one, '+mailinglist',
-    ...         form=form, principal=principal)
+    ...         team_one, '+mailinglist', form=form)
 
 Nobody can purge an active mailing list, the team owner...
 
@@ -133,14 +133,14 @@
     >>> logout()
     >>> transaction.commit()
 
-The team owner cannot purge his list...
+The team owner can purge his list...
 
     >>> login(ANONYMOUS)
     >>> view = create_view(the_owner)
     >>> view.list_can_be_purged
-    False
+    True
 
-...but a Launchpad administrator, or mailing list expert can purge the mailing
+...and Launchpad administrator, or mailing list expert can purge the mailing
 list.
 
     >>> view = create_view(an_admin)

=== modified file 'lib/lp/registry/browser/tests/peoplemerge-views.txt'
--- lib/lp/registry/browser/tests/peoplemerge-views.txt	2010-10-18 22:24:59 +0000
+++ lib/lp/registry/browser/tests/peoplemerge-views.txt	2010-11-01 13:11:51 +0000
@@ -9,7 +9,8 @@
 Create a member of the registry team that is not a member of the admins
 team.
 
-    >>> from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
+    >>> from canonical.launchpad.interfaces.launchpad import (
+    ...     ILaunchpadCelebrities)
     >>> from lp.registry.interfaces.person import IPersonSet
     >>> registry_experts = getUtility(ILaunchpadCelebrities).registry_experts
     >>> person_set = getUtility(IPersonSet)
@@ -248,33 +249,6 @@
     >>> print find_tag_by_id(content, 'field.actions.delete')
     None
 
-The registry experts can delete a team with an email address. The email
-address is deleted instead of being transferred to the registry experts team.
-
-    >>> from canonical.launchpad.interfaces.emailaddress import (
-    ...     IEmailAddressSet, EmailAddressStatus)
-    >>> login_person(registry_expert)
-    >>> admin = getUtility(ILaunchpadCelebrities).admin
-    >>> registry_expert.inTeam(admin)
-    False
-    >>> deletable_team = factory.makeTeam()
-    >>> email = factory.makeEmail(
-    ...     "del@xxxxxxxxxxx", deletable_team,
-    ...     email_status=EmailAddressStatus.NEW)
-    >>> for email in getUtility(IEmailAddressSet).getByPerson(deletable_team):
-    ...     print email.email, email.status.title
-    del@xxxxxxxxxxx New Email Address
-    >>> form = {'field.actions.delete': 'Delete'}
-    >>> view = create_initialized_view(deletable_team, '+delete', form=form)
-    >>> view.errors
-    []
-    >>> for notification in view.request.response.notifications:
-    ...     print notification.message
-    Team deleted.
-    >>> emails = getUtility(IEmailAddressSet).getByPerson(registry_experts)
-    >>> emails.count()
-    0
-
 Private teams can be deleted by admins.
 
     >>> from lp.registry.interfaces.person import PersonVisibility

=== modified file 'lib/lp/registry/browser/tests/test_peoplemerge.py'
--- lib/lp/registry/browser/tests/test_peoplemerge.py	2010-10-26 15:47:24 +0000
+++ lib/lp/registry/browser/tests/test_peoplemerge.py	2010-11-01 13:11:51 +0000
@@ -6,14 +6,24 @@
 
 from zope.component import getUtility
 
+from canonical.launchpad.interfaces.emailaddress import (
+    EmailAddressStatus,
+    IEmailAddressSet,
+    )
+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
 from canonical.testing.layers import DatabaseFunctionalLayer
 from lp.registry.interfaces.person import IPersonSet
+from lp.registry.interfaces.mailinglist import MailingListStatus
 from lp.testing import (
+    login_celebrity,
     login_person,
     person_logged_in,
     TestCaseWithFactory,
     )
-from lp.testing.views import create_view
+from lp.testing.views import (
+    create_initialized_view,
+    create_view,
+    )
 
 
 class TestRequestPeopleMergeMultipleEmailsView(TestCaseWithFactory):
@@ -71,3 +81,54 @@
             method='POST')
         view.processForm()
         self.verify_user_must_reselect_email_addresses(view)
+
+
+class TestAdminTeamMergeView(TestCaseWithFactory):
+    """Test the AdminTeamMergeView rules."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestAdminTeamMergeView, self).setUp()
+        self.person_set = getUtility(IPersonSet)
+        self.dupe_team = self.factory.makeTeam()
+        self.target_team = self.factory.makeTeam()
+        login_celebrity('registry_experts')
+
+    def getView(self):
+        form = {
+            'field.dupe_person': self.dupe_team.name,
+            'field.target_person': self.target_team.name,
+            'field.actions.deactivate_members_and_merge': 'Merge',
+            }
+        return create_initialized_view(
+            self.person_set, '+adminteammerge', form=form)
+
+    def test_merge_team_with_inactive_mailing_list(self):
+        # Verify that inactive lists do not block merges.
+        mailing_list = self.factory.makeMailingList(
+            self.dupe_team, self.dupe_team.teamowner)
+        mailing_list.deactivate()
+        mailing_list.transitionToStatus(MailingListStatus.INACTIVE)
+        view = self.getView()
+        self.assertEqual([], view.errors)
+        self.assertEqual(self.target_team, self.dupe_team.merged)
+
+    def test_merge_team_with_email_address(self):
+        # Verify that team email addresses are not transferred.
+        self.factory.makeEmail(
+            "del@xxxxxx", self.dupe_team, email_status=EmailAddressStatus.NEW)
+        view = self.getView()
+        self.assertEqual([], view.errors)
+        self.assertEqual(self.target_team, self.dupe_team.merged)
+        emails = getUtility(IEmailAddressSet).getByPerson(self.target_team)
+        self.assertEqual(0, emails.count())
+
+    def test_merge_team_with_super_teams_into_registry_experts(self):
+        # Verify that super team memberships are removed.
+        self.target_team = getUtility(ILaunchpadCelebrities).registry_experts
+        super_team = self.factory.makeTeam()
+        self.dupe_team.join(super_team, self.dupe_team.teamowner)
+        view = self.getView()
+        self.assertEqual([], view.errors)
+        self.assertEqual(self.target_team, self.dupe_team.merged)

=== modified file 'lib/lp/registry/interfaces/person.py'
--- lib/lp/registry/interfaces/person.py	2010-10-28 14:38:22 +0000
+++ lib/lp/registry/interfaces/person.py	2010-11-01 13:11:51 +0000
@@ -1550,6 +1550,17 @@
         to INVITATION_DECLINED.
         """
 
+    @call_with(user=REQUEST_USER)
+    @operation_parameters(
+        team=copy_field(ITeamMembership['team'],
+        comment=Text()))
+    @export_write_operation()
+    def retractTeamMembership(team, user, comment=None):
+        """Retract this team's membership in the given team.
+
+        This is the team equivalent of user.leave(team).
+        """
+
     def renewTeamMembership(team):
         """Renew the TeamMembership for this person on the given team.
 

=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py	2010-10-29 22:52:42 +0000
+++ lib/lp/registry/model/person.py	2010-11-01 13:11:51 +0000
@@ -1282,17 +1282,7 @@
     def leave(self, team):
         """See `IPerson`."""
         assert not ITeam.providedBy(self)
-
-        self._inTeam_cache = {} # Flush the cache used by the inTeam method
-
-        active = [TeamMembershipStatus.ADMIN, TeamMembershipStatus.APPROVED]
-        tm = TeamMembership.selectOneBy(person=self, team=team)
-        if tm is None or tm.status not in active:
-            # Ok, we're done. You are not an active member and still
-            # not being.
-            return
-
-        tm.setStatus(TeamMembershipStatus.DEACTIVATED, self)
+        self.retractTeamMembership(team, self)
 
     def join(self, team, requester=None, may_subscribe_to_list=True):
         """See `IPerson`."""
@@ -1445,6 +1435,28 @@
             TeamMembershipStatus.INVITATION_DECLINED,
             getUtility(ILaunchBag).user, comment=comment)
 
+    def retractTeamMembership(self, team, user, comment=None):
+        """See `IPerson`"""
+        # Include PROPOSED and INVITED so that teams can retract mistakes
+        # without involving members of the other team.
+        active_and_transitioning = {
+            TeamMembershipStatus.ADMIN: TeamMembershipStatus.DEACTIVATED,
+            TeamMembershipStatus.APPROVED: TeamMembershipStatus.DEACTIVATED,
+            TeamMembershipStatus.PROPOSED: TeamMembershipStatus.DECLINED,
+            TeamMembershipStatus.INVITED:
+                TeamMembershipStatus.INVITATION_DECLINED,
+            }
+        constraints = And(
+            TeamMembership.personID == self.id,
+            TeamMembership.teamID == team.id,
+            TeamMembership.status.is_in(active_and_transitioning.keys()))
+        tm = Store.of(self).find(TeamMembership, constraints).one()
+        if tm is not None:
+            # Flush the cache used by the inTeam method.
+            self._inTeam_cache = {}
+            new_status = active_and_transitioning[tm.status]
+            tm.setStatus(new_status, user, comment=comment)
+
     def renewTeamMembership(self, team):
         """Renew the TeamMembership for this person on the given team.
 

=== modified file 'lib/lp/registry/templates/team-delete.pt'
--- lib/lp/registry/templates/team-delete.pt	2009-12-24 01:26:58 +0000
+++ lib/lp/registry/templates/team-delete.pt	2010-11-01 13:11:51 +0000
@@ -12,8 +12,8 @@
       </p>
 
       <p tal:condition="view/has_mailing_list">
-        This team cannot be deleted until its mailing list is first
-        deactivated, then purged after the deactivation is confirmed.
+        <strong>This team cannot be deleted until its mailing list is first
+        deactivated, then purged after the deactivation is confirmed.</strong>
       </p>
 
       <p tal:condition="context/activemembers/count">

=== modified file 'lib/lp/registry/tests/test_teammembership.py'
--- lib/lp/registry/tests/test_teammembership.py	2010-10-04 19:50:45 +0000
+++ lib/lp/registry/tests/test_teammembership.py	2010-11-01 13:11:51 +0000
@@ -30,7 +30,7 @@
     setUp,
     tearDown,
     )
-from canonical.testing.layers import LaunchpadFunctionalLayer
+from canonical.testing.layers import DatabaseFunctionalLayer
 from lp.registry.interfaces.person import (
     IPersonSet,
     TeamSubscriptionPolicy,
@@ -45,7 +45,7 @@
 
 
 class TestTeamMembershipSet(unittest.TestCase):
-    layer = LaunchpadFunctionalLayer
+    layer = DatabaseFunctionalLayer
 
     def setUp(self):
         login('test@xxxxxxxxxxxxx')
@@ -157,7 +157,7 @@
 
 class TeamParticipationTestCase(unittest.TestCase):
     """Tests for team participation using 5 teams."""
-    layer = LaunchpadFunctionalLayer
+    layer = DatabaseFunctionalLayer
 
     def setUp(self):
         login('foo.bar@xxxxxxxxxxxxx')
@@ -190,6 +190,7 @@
                     team5
                        no-priv
     """
+    layer = DatabaseFunctionalLayer
 
     def setUp(self):
         """Setup the team hierarchy."""
@@ -256,6 +257,7 @@
                      team5
                        no-priv
     """
+    layer = DatabaseFunctionalLayer
 
     def setUp(self):
         """Setup the team hierarchy."""
@@ -320,6 +322,7 @@
                      team5
                        no-priv
     """
+    layer = DatabaseFunctionalLayer
 
     def setUp(self):
         """Setup the team hierarchy."""
@@ -381,7 +384,7 @@
 
 
 class TestTeamMembership(unittest.TestCase):
-    layer = LaunchpadFunctionalLayer
+    layer = DatabaseFunctionalLayer
 
     def test_teams_not_kicked_from_themselves_bug_248498(self):
         """The self-participation of a team must not be removed.
@@ -443,7 +446,7 @@
 
 class TestTeamMembershipSetStatus(unittest.TestCase):
     """Test the behaviour of TeamMembership's setStatus()."""
-    layer = LaunchpadFunctionalLayer
+    layer = DatabaseFunctionalLayer
 
     def setUp(self):
         login('foo.bar@xxxxxxxxxxxxx')
@@ -651,9 +654,52 @@
         team1_on_team2.setStatus(TeamMembershipStatus.ADMIN, self.foobar)
         self.assertEqual(team1_on_team2.status, TeamMembershipStatus.ADMIN)
 
+    def test_invited_member_can_be_declined(self):
+        # Verify a team can decline an invited member.
+        self.team2.addMember(self.team1, self.no_priv)
+        tm = getUtility(ITeamMembershipSet).getByPersonAndTeam(
+            self.team1, self.team2)
+        tm.setStatus(
+            TeamMembershipStatus.INVITATION_DECLINED, self.team2.teamowner)
+        self.assertEqual(TeamMembershipStatus.INVITATION_DECLINED, tm.status)
+
+    def test_retractTeamMembership_invited(self):
+        # Verify a team can retract a membership invitation.
+        self.team2.addMember(self.team1, self.no_priv)
+        self.team1.retractTeamMembership(self.team2, self.team1.teamowner)
+        tm = getUtility(ITeamMembershipSet).getByPersonAndTeam(
+            self.team1, self.team2)
+        self.assertEqual(TeamMembershipStatus.INVITATION_DECLINED, tm.status)
+
+    def test_retractTeamMembership_proposed(self):
+        # Verify a team can retract the proposed membership in a team.
+        self.team2.subscriptionpolicy = TeamSubscriptionPolicy.MODERATED
+        self.team1.join(self.team2, self.team1.teamowner)
+        self.team1.retractTeamMembership(self.team2, self.team1.teamowner)
+        tm = getUtility(ITeamMembershipSet).getByPersonAndTeam(
+            self.team1, self.team2)
+        self.assertEqual(TeamMembershipStatus.DECLINED, tm.status)
+
+    def test_retractTeamMembership_active(self):
+        # Verify a team can retract the membership in a team.
+        self.team1.join(self.team2, self.team1.teamowner)
+        self.team1.retractTeamMembership(self.team2, self.team1.teamowner)
+        tm = getUtility(ITeamMembershipSet).getByPersonAndTeam(
+            self.team1, self.team2)
+        self.assertEqual(TeamMembershipStatus.DEACTIVATED, tm.status)
+
+    def test_retractTeamMembership_admin(self):
+        # Verify a team can retract the membership in a team.
+        self.team1.join(self.team2, self.team1.teamowner)
+        tm = getUtility(ITeamMembershipSet).getByPersonAndTeam(
+            self.team1, self.team2)
+        tm.setStatus(TeamMembershipStatus.ADMIN, self.team2.teamowner)
+        self.team1.retractTeamMembership(self.team2, self.team1.teamowner)
+        self.assertEqual(TeamMembershipStatus.DEACTIVATED, tm.status)
+
 
 class TestCheckTeamParticipationScript(unittest.TestCase):
-    layer = LaunchpadFunctionalLayer
+    layer = DatabaseFunctionalLayer
 
     def _runScript(self, expected_returncode=0):
         process = subprocess.Popen(
@@ -760,6 +806,6 @@
     suite = unittest.TestLoader().loadTestsFromName(__name__)
     bug_249185 = LayeredDocFileSuite(
         'bug-249185.txt', optionflags=default_optionflags,
-        layer=LaunchpadFunctionalLayer, setUp=setUp, tearDown=tearDown)
+        layer=DatabaseFunctionalLayer, setUp=setUp, tearDown=tearDown)
     suite.addTest(bug_249185)
     return suite