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

Proposed by Curtis Hovey
Status: Merged
Merged at revision: 11858
Proposed branch: lp:~sinzui/launchpad/delete-team-3
Merge into: lp:launchpad
Diff against target: 788 lines (+227/-207)
10 files modified
lib/lp/registry/browser/peoplemerge.py (+37/-8)
lib/lp/registry/browser/team.py (+7/-9)
lib/lp/registry/browser/tests/mailinglist-views.txt (+6/-8)
lib/lp/registry/browser/tests/peoplemerge-views.txt (+2/-28)
lib/lp/registry/browser/tests/test_peoplemerge.py (+62/-1)
lib/lp/registry/interfaces/person.py (+21/-8)
lib/lp/registry/model/person.py (+23/-11)
lib/lp/registry/stories/mailinglists/lifecycle.txt (+14/-125)
lib/lp/registry/templates/team-delete.pt (+2/-2)
lib/lp/registry/tests/test_teammembership.py (+53/-7)
To merge this branch: bzr merge lp:~sinzui/launchpad/delete-team-3
Reviewer Review Type Date Requested Status
Henning Eggers (community) code Approve
Review via email: mp+39746@code.launchpad.net

Description of the change

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.

To post a comment you must log in.
Revision history for this message
Henning Eggers (henninge) wrote :
Download full text (12.0 KiB)

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

Hi Curtis,
thank you for your many fixes and the extensive cover letter. Much appreciated.

An overall note on tests comments: "Verify ..." is noise on a test comment
because "verifying" is what tests are supposed to be doing. Our style guide
even mentions that. They are fixed easily, though:
# Verify that inactive lists do not block merges.
becomes
# Inactive lists do not block merges.

Am 01.11.2010 14:12, schrieb Curtis Hovey:
> === 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

Well "it is not transferable" sounds more reasonable to me because it is just
one address.

> + # 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 re...

review: Approve (code)
Revision history for this message
Curtis Hovey (sinzui) wrote :
Download full text (9.5 KiB)

Thanks for the review.

On Mon, 2010-11-01 at 17:46 +0000, Henning Eggers wrote:
> > + @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).
>
> Apart from the fact that parameters are not documented, I find this
> description misleading. In fact "leave" is specialized version of
> "retractTeamMembership" and I first wondered why you introduced the latter
> instead of revamping the former. I realized that it is for API consistency.
> But the comments should reflect that "leave" now is a convenience method to
> "retractTeamMembership".

Thanks for prompting me to revise this. My change implicitly fixed the
situation where the user wants to retract a PROPOSED membership in a
team. I updated the docstrings to explain what really happen.

{{{
=== modified file 'lib/lp/registry/browser/peoplemerge.py'
--- lib/lp/registry/browser/peoplemerge.py 2010-10-31 22:03:40 +0000
+++ lib/lp/registry/browser/peoplemerge.py 2010-11-01 19:59:54 +0000
@@ -229,8 +229,7 @@
         # 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.
+ # Team email addresses are not transferable.
         self.dupe_person.setContactAddress(None)
         # The registry experts does not want to acquire super teams from a
         # merge.
@@ -255,8 +254,8 @@
         # 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):
+ super_teams_count = dupe_team.super_teams.count()
+ if target_team != self.registry_experts and super_teams_count > 0:
             self.addError(_(
                 "${name} has super teams, so it can't be merged.",
                 mapping=dict(name=dupe_team.name)))

=== modified file 'lib/lp/registry/browser/team.py'
--- lib/lp/registry/browser/team.py 2010-10-31 22:21:20 +0000
+++ lib/lp/registry/browser/team.py 2010-11-01 20:10:37 +0000
@@ -742,8 +742,10 @@
         or INACTIVE states. Further, the user doing the purging, must be
         an owner, Launchpad administrator or mailing list expert.
         """
- if (check_permission('launchpad.Moderate', self.context) or
- check_permission('launchpad.MailingListManager', self.context)):
+ is_moderator = check_permission('launchpad.Moderate', self.context)
+ is_mailing_list_manager = check_permission(
+ 'launchpad.MailingListManager', self.context)
+ if is_moderator or is_mailing_list_manager:
             return self.getListInState(*PURGE_STATES) is not None
         else:
             return...

Read more...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/registry/browser/peoplemerge.py'
2--- lib/lp/registry/browser/peoplemerge.py 2010-09-23 15:34:05 +0000
3+++ lib/lp/registry/browser/peoplemerge.py 2010-11-03 22:16:52 +0000
4@@ -34,13 +34,17 @@
5 LaunchpadView,
6 )
7 from canonical.launchpad.webapp.interfaces import ILaunchBag
8-from lp.registry.interfaces.mailinglist import MailingListStatus
9+from lp.registry.interfaces.mailinglist import (
10+ MailingListStatus,
11+ PURGE_STATES,
12+ )
13 from lp.registry.interfaces.person import (
14 IAdminPeopleMergeSchema,
15 IAdminTeamMergeSchema,
16 IPersonSet,
17 IRequestPeopleMerge,
18 )
19+from lp.services.propertycache import cachedproperty
20
21
22 class RequestPeopleMergeView(LaunchpadFormView):
23@@ -214,9 +218,32 @@
24 schema = IAdminTeamMergeSchema
25
26 def hasMailingList(self, team):
27+ unused_states = [state for state in PURGE_STATES]
28+ unused_states.append(MailingListStatus.PURGED)
29 return (
30 team.mailing_list is not None
31- and team.mailing_list.status != MailingListStatus.PURGED)
32+ and team.mailing_list.status not in unused_states)
33+
34+ @cachedproperty
35+ def registry_experts(self):
36+ return getUtility(ILaunchpadCelebrities).registry_experts
37+
38+ def doMerge(self, data):
39+ """Purge the non-transferable team data and merge."""
40+ # A team cannot have more than one mailing list. The old list will
41+ # remain in the archive.
42+ purge_list = (self.dupe_person.mailing_list is not None
43+ and self.dupe_person.mailing_list.status in PURGE_STATES)
44+ if purge_list:
45+ self.dupe_person.mailing_list.purge()
46+ # Team email addresses are not transferable.
47+ self.dupe_person.setContactAddress(None)
48+ # The registry experts does not want to acquire super teams from a
49+ # merge.
50+ if self.target_person is self.registry_experts:
51+ for team in self.dupe_person.teams_participated_in:
52+ self.dupe_person.retractTeamMembership(team, self.user)
53+ super(AdminTeamMergeView, self).doMerge(data)
54
55 def validate(self, data):
56 """Check there are no mailing lists associated with the dupe team."""
57@@ -228,9 +255,14 @@
58
59 super(AdminTeamMergeView, self).validate(data)
60 dupe_team = data['dupe_person']
61- # Our code doesn't know how to merge a team's superteams, so we
62- # prohibit that here.
63- if dupe_team.super_teams.count() > 0:
64+ target_team = data['target_person']
65+ # Merge cannot reconcile cyclic membership in super teams.
66+ # Super team memberships are automatically removed when merging into
67+ # the registry experts team. When merging into any other team, an
68+ # error must be raised to explain that the user must remove the teams
69+ # himself.
70+ super_teams_count = dupe_team.super_teams.count()
71+ if target_team != self.registry_experts and super_teams_count > 0:
72 self.addError(_(
73 "${name} has super teams, so it can't be merged.",
74 mapping=dict(name=dupe_team.name)))
75@@ -330,9 +362,6 @@
76 @action('Delete', name='delete', condition=canDelete)
77 def merge_action(self, action, data):
78 base = super(DeleteTeamView, self)
79- # Delete is implemented as a merge process, but email addresses should
80- # be deleted because ~registry can never claim them.
81- self.context.setContactAddress(None)
82 base.deactivate_members_and_merge_action.success(data)
83
84
85
86=== modified file 'lib/lp/registry/browser/team.py'
87--- lib/lp/registry/browser/team.py 2010-09-25 14:29:32 +0000
88+++ lib/lp/registry/browser/team.py 2010-11-03 22:16:52 +0000
89@@ -44,7 +44,6 @@
90 from canonical.launchpad import _
91 from canonical.launchpad.interfaces.authtoken import LoginTokenType
92 from canonical.launchpad.interfaces.emailaddress import IEmailAddressSet
93-from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
94 from canonical.launchpad.interfaces.logintoken import ILoginTokenSet
95 from canonical.launchpad.interfaces.validation import validate_new_team_email
96 from canonical.launchpad.validators import LaunchpadValidationError
97@@ -78,7 +77,6 @@
98 )
99 from lp.registry.interfaces.person import (
100 ImmutableVisibilityError,
101- IPerson,
102 IPersonSet,
103 ITeam,
104 ITeamContactAddressForm,
105@@ -742,15 +740,15 @@
106
107 The list must exist and be in one of the REGISTERED, DECLINED, FAILED,
108 or INACTIVE states. Further, the user doing the purging, must be
109- a Launchpad administrator or mailing list expert.
110+ an owner, Launchpad administrator or mailing list expert.
111 """
112- requester = IPerson(self.request.principal, None)
113- celebrities = getUtility(ILaunchpadCelebrities)
114- if (requester is None or
115- (not requester.inTeam(celebrities.admin) and
116- not requester.inTeam(celebrities.mailing_list_experts))):
117+ is_moderator = check_permission('launchpad.Moderate', self.context)
118+ is_mailing_list_manager = check_permission(
119+ 'launchpad.MailingListManager', self.context)
120+ if is_moderator or is_mailing_list_manager:
121+ return self.getListInState(*PURGE_STATES) is not None
122+ else:
123 return False
124- return self.getListInState(*PURGE_STATES) is not None
125
126
127 class TeamMailingListSubscribersView(LaunchpadView):
128
129=== modified file 'lib/lp/registry/browser/tests/mailinglist-views.txt'
130--- lib/lp/registry/browser/tests/mailinglist-views.txt 2010-08-28 23:01:18 +0000
131+++ lib/lp/registry/browser/tests/mailinglist-views.txt 2010-11-03 22:16:52 +0000
132@@ -76,7 +76,7 @@
133 >>> team_one, list_one = factory.makeTeamAndMailingList(
134 ... 'team-one', 'no-priv')
135
136- >>> the_owner = team_one.teamowner.preferredemail.email
137+ >>> the_owner = team_one.teamowner
138
139 >>> from canonical.launchpad.interfaces.launchpad import (
140 ... ILaunchpadCelebrities)
141@@ -85,9 +85,9 @@
142 >>> an_admin = list(celebrities.admin.allmembers)[0]
143
144 >>> def create_view(principal, form=None):
145+ ... login_person(principal)
146 ... return create_initialized_view(
147- ... team_one, '+mailinglist',
148- ... form=form, principal=principal)
149+ ... team_one, '+mailinglist', form=form)
150
151 Nobody can purge an active mailing list, the team owner...
152
153@@ -133,15 +133,13 @@
154 >>> logout()
155 >>> transaction.commit()
156
157-The team owner cannot purge his list...
158+The team owner can purge his list, as well as a Launchpad administrator and
159+a mailing list expert.
160
161 >>> login(ANONYMOUS)
162 >>> view = create_view(the_owner)
163 >>> view.list_can_be_purged
164- False
165-
166-...but a Launchpad administrator, or mailing list expert can purge the mailing
167-list.
168+ True
169
170 >>> view = create_view(an_admin)
171 >>> view.list_can_be_purged
172
173=== modified file 'lib/lp/registry/browser/tests/peoplemerge-views.txt'
174--- lib/lp/registry/browser/tests/peoplemerge-views.txt 2010-10-18 22:24:59 +0000
175+++ lib/lp/registry/browser/tests/peoplemerge-views.txt 2010-11-03 22:16:52 +0000
176@@ -9,7 +9,8 @@
177 Create a member of the registry team that is not a member of the admins
178 team.
179
180- >>> from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
181+ >>> from canonical.launchpad.interfaces.launchpad import (
182+ ... ILaunchpadCelebrities)
183 >>> from lp.registry.interfaces.person import IPersonSet
184 >>> registry_experts = getUtility(ILaunchpadCelebrities).registry_experts
185 >>> person_set = getUtility(IPersonSet)
186@@ -248,33 +249,6 @@
187 >>> print find_tag_by_id(content, 'field.actions.delete')
188 None
189
190-The registry experts can delete a team with an email address. The email
191-address is deleted instead of being transferred to the registry experts team.
192-
193- >>> from canonical.launchpad.interfaces.emailaddress import (
194- ... IEmailAddressSet, EmailAddressStatus)
195- >>> login_person(registry_expert)
196- >>> admin = getUtility(ILaunchpadCelebrities).admin
197- >>> registry_expert.inTeam(admin)
198- False
199- >>> deletable_team = factory.makeTeam()
200- >>> email = factory.makeEmail(
201- ... "del@example.com", deletable_team,
202- ... email_status=EmailAddressStatus.NEW)
203- >>> for email in getUtility(IEmailAddressSet).getByPerson(deletable_team):
204- ... print email.email, email.status.title
205- del@example.com New Email Address
206- >>> form = {'field.actions.delete': 'Delete'}
207- >>> view = create_initialized_view(deletable_team, '+delete', form=form)
208- >>> view.errors
209- []
210- >>> for notification in view.request.response.notifications:
211- ... print notification.message
212- Team deleted.
213- >>> emails = getUtility(IEmailAddressSet).getByPerson(registry_experts)
214- >>> emails.count()
215- 0
216-
217 Private teams can be deleted by admins.
218
219 >>> from lp.registry.interfaces.person import PersonVisibility
220
221=== modified file 'lib/lp/registry/browser/tests/test_peoplemerge.py'
222--- lib/lp/registry/browser/tests/test_peoplemerge.py 2010-10-26 15:47:24 +0000
223+++ lib/lp/registry/browser/tests/test_peoplemerge.py 2010-11-03 22:16:52 +0000
224@@ -6,14 +6,24 @@
225
226 from zope.component import getUtility
227
228+from canonical.launchpad.interfaces.emailaddress import (
229+ EmailAddressStatus,
230+ IEmailAddressSet,
231+ )
232+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
233 from canonical.testing.layers import DatabaseFunctionalLayer
234 from lp.registry.interfaces.person import IPersonSet
235+from lp.registry.interfaces.mailinglist import MailingListStatus
236 from lp.testing import (
237+ login_celebrity,
238 login_person,
239 person_logged_in,
240 TestCaseWithFactory,
241 )
242-from lp.testing.views import create_view
243+from lp.testing.views import (
244+ create_initialized_view,
245+ create_view,
246+ )
247
248
249 class TestRequestPeopleMergeMultipleEmailsView(TestCaseWithFactory):
250@@ -71,3 +81,54 @@
251 method='POST')
252 view.processForm()
253 self.verify_user_must_reselect_email_addresses(view)
254+
255+
256+class TestAdminTeamMergeView(TestCaseWithFactory):
257+ """Test the AdminTeamMergeView rules."""
258+
259+ layer = DatabaseFunctionalLayer
260+
261+ def setUp(self):
262+ super(TestAdminTeamMergeView, self).setUp()
263+ self.person_set = getUtility(IPersonSet)
264+ self.dupe_team = self.factory.makeTeam()
265+ self.target_team = self.factory.makeTeam()
266+ login_celebrity('registry_experts')
267+
268+ def getView(self):
269+ form = {
270+ 'field.dupe_person': self.dupe_team.name,
271+ 'field.target_person': self.target_team.name,
272+ 'field.actions.deactivate_members_and_merge': 'Merge',
273+ }
274+ return create_initialized_view(
275+ self.person_set, '+adminteammerge', form=form)
276+
277+ def test_merge_team_with_inactive_mailing_list(self):
278+ # Inactive lists do not block merges.
279+ mailing_list = self.factory.makeMailingList(
280+ self.dupe_team, self.dupe_team.teamowner)
281+ mailing_list.deactivate()
282+ mailing_list.transitionToStatus(MailingListStatus.INACTIVE)
283+ view = self.getView()
284+ self.assertEqual([], view.errors)
285+ self.assertEqual(self.target_team, self.dupe_team.merged)
286+
287+ def test_merge_team_with_email_address(self):
288+ # Team email addresses are not transferred.
289+ self.factory.makeEmail(
290+ "del@ex.dom", self.dupe_team, email_status=EmailAddressStatus.NEW)
291+ view = self.getView()
292+ self.assertEqual([], view.errors)
293+ self.assertEqual(self.target_team, self.dupe_team.merged)
294+ emails = getUtility(IEmailAddressSet).getByPerson(self.target_team)
295+ self.assertEqual(0, emails.count())
296+
297+ def test_merge_team_with_super_teams_into_registry_experts(self):
298+ # Super team memberships are removed.
299+ self.target_team = getUtility(ILaunchpadCelebrities).registry_experts
300+ super_team = self.factory.makeTeam()
301+ self.dupe_team.join(super_team, self.dupe_team.teamowner)
302+ view = self.getView()
303+ self.assertEqual([], view.errors)
304+ self.assertEqual(self.target_team, self.dupe_team.merged)
305
306=== modified file 'lib/lp/registry/interfaces/person.py'
307--- lib/lp/registry/interfaces/person.py 2010-11-03 20:24:36 +0000
308+++ lib/lp/registry/interfaces/person.py 2010-11-03 22:16:52 +0000
309@@ -1454,14 +1454,11 @@
310 def leave(team):
311 """Leave the given team.
312
313- If there's a membership entry for this person on the given team and
314- its status is either APPROVED or ADMIN, we change the status to
315- DEACTIVATED and remove the relevant entries in teamparticipation.
316+ This is a convenience method for retractTeamMembership() that allows
317+ a user to leave the given team, or to cancel a PENDING membership
318+ request.
319
320- Teams cannot call this method because they're not allowed to
321- login and thus can't 'leave' another team. Instead, they have their
322- subscription deactivated (using the setMembershipData() method) by
323- a team administrator.
324+ :param team: The team to leave.
325 """
326
327 @operation_parameters(
328@@ -1523,7 +1520,7 @@
329 :return: A tuple containing a boolean indicating when the
330 membership status changed and the current `TeamMembershipStatus`.
331 This depends on the desired status passed as an argument, the
332- subscription policy and the user's priveleges.
333+ subscription policy and the user's privileges.
334 """
335
336 @operation_parameters(
337@@ -1550,6 +1547,22 @@
338 to INVITATION_DECLINED.
339 """
340
341+ def retractTeamMembership(team, user, comment=None):
342+ """Retract this team's membership in the given team.
343+
344+ If there's a membership entry for this team on the given team and
345+ its status is either APPROVED, ADMIN, PENDING, or INVITED, the status
346+ is changed and the relevant entries in TeamParticipation.
347+
348+ APPROVED and ADMIN status are changed to DEACTIVATED.
349+ PENDING status is changed to DECLINED.
350+ INVITED status is changes to INVITATION_DECLINED.
351+
352+ :param team: The team to leave.
353+ :param user: The user making the retraction.
354+ :param comment: An optional explanation about why the change was made.
355+ """
356+
357 def renewTeamMembership(team):
358 """Renew the TeamMembership for this person on the given team.
359
360
361=== modified file 'lib/lp/registry/model/person.py'
362--- lib/lp/registry/model/person.py 2010-11-02 20:10:56 +0000
363+++ lib/lp/registry/model/person.py 2010-11-03 22:16:52 +0000
364@@ -1234,17 +1234,7 @@
365 def leave(self, team):
366 """See `IPerson`."""
367 assert not ITeam.providedBy(self)
368-
369- self._inTeam_cache = {} # Flush the cache used by the inTeam method
370-
371- active = [TeamMembershipStatus.ADMIN, TeamMembershipStatus.APPROVED]
372- tm = TeamMembership.selectOneBy(person=self, team=team)
373- if tm is None or tm.status not in active:
374- # Ok, we're done. You are not an active member and still
375- # not being.
376- return
377-
378- tm.setStatus(TeamMembershipStatus.DEACTIVATED, self)
379+ self.retractTeamMembership(team, self)
380
381 def join(self, team, requester=None, may_subscribe_to_list=True):
382 """See `IPerson`."""
383@@ -1397,6 +1387,28 @@
384 TeamMembershipStatus.INVITATION_DECLINED,
385 getUtility(ILaunchBag).user, comment=comment)
386
387+ def retractTeamMembership(self, team, user, comment=None):
388+ """See `IPerson`"""
389+ # Include PROPOSED and INVITED so that teams can retract mistakes
390+ # without involving members of the other team.
391+ active_and_transitioning = {
392+ TeamMembershipStatus.ADMIN: TeamMembershipStatus.DEACTIVATED,
393+ TeamMembershipStatus.APPROVED: TeamMembershipStatus.DEACTIVATED,
394+ TeamMembershipStatus.PROPOSED: TeamMembershipStatus.DECLINED,
395+ TeamMembershipStatus.INVITED:
396+ TeamMembershipStatus.INVITATION_DECLINED,
397+ }
398+ constraints = And(
399+ TeamMembership.personID == self.id,
400+ TeamMembership.teamID == team.id,
401+ TeamMembership.status.is_in(active_and_transitioning.keys()))
402+ tm = Store.of(self).find(TeamMembership, constraints).one()
403+ if tm is not None:
404+ # Flush the cache used by the inTeam method.
405+ self._inTeam_cache = {}
406+ new_status = active_and_transitioning[tm.status]
407+ tm.setStatus(new_status, user, comment=comment)
408+
409 def renewTeamMembership(self, team):
410 """Renew the TeamMembership for this person on the given team.
411
412
413=== modified file 'lib/lp/registry/stories/mailinglists/lifecycle.txt'
414--- lib/lp/registry/stories/mailinglists/lifecycle.txt 2010-10-18 22:24:59 +0000
415+++ lib/lp/registry/stories/mailinglists/lifecycle.txt 2010-11-03 22:16:52 +0000
416@@ -60,18 +60,7 @@
417 address until Mailman has acknowledged successful creation.
418
419 >>> browser.reload()
420- >>> browser.getLink(url='+contactaddress').click()
421- >>> browser.getControl('The Launchpad mailing list')
422- Traceback (most recent call last):
423- ...
424- LookupError: ...
425-
426 >>> browser.getLink(url='+mailinglist').click()
427- >>> browser.getControl('Apply for Mailing List')
428- Traceback (most recent call last):
429- ...
430- LookupError: ...
431-
432 >>> print mailing_list_status_message(browser.contents)
433 This team's mailing list will be available within a few minutes.
434
435@@ -113,11 +102,6 @@
436 >>> from canonical.launchpad.testing.pages import strip_label
437
438 >>> browser.getLink(url='+mailinglist').click()
439- >>> browser.getControl('Apply for Mailing List')
440- Traceback (most recent call last):
441- ...
442- LookupError: ...
443-
444 >>> print extract_text(find_tag_by_id(browser.contents,
445 ... 'mailing_list_not_contact_address'))
446 The mailing list is not set as the team contact address. You can
447@@ -137,15 +121,6 @@
448 >>> print browser.getLink(url='+mailinglist').url
449 http://launchpad.dev/~landscape-developers/+mailinglist
450
451-Although of course, it's not available to someone with no permission to
452-manipulate the list.
453-
454- >>> user_browser.open('http://launchpad.dev/~landscape-developers')
455- >>> user_browser.getLink(url='+mailinglist')
456- Traceback (most recent call last):
457- ...
458- LinkNotFoundError
459-
460 When the mailing list is not the team's contact address, the mailing
461 list configuration screen displays a message to this effect.
462
463@@ -324,7 +299,7 @@
464 ... print mailing_list.status.name
465 ... logout()
466
467-The team owner cannot purge a mailing list.
468+The team owner can see that he can purge or reactivate mailing list.
469
470 >>> user_browser.open('http://launchpad.dev/~aardvarks/+mailinglist')
471 >>> user_browser.getControl('Apply for Mailing List').click()
472@@ -345,14 +320,13 @@
473
474 >>> user_browser.getLink(url='+mailinglist').click()
475 >>> print purge_text(user_browser)
476- None
477-
478-But a Launchpad administrator can purge a list.
479-
480- >>> admin_browser.open('http://launchpad.dev/~aardvarks/+mailinglist')
481- >>> print purge_text(admin_browser)
482 You can purge this mailing list...
483
484+ >>> user_browser.getControl('Reactivate this Mailing List')
485+ <SubmitControl name='field.actions.reactivate_list' type='submit'>
486+ >>> user_browser.getControl('Purge this Mailing List')
487+ <SubmitControl name='field.actions.purge_list' type='submit'>
488+
489 Mailing list experts can also purge mailing lists. Sample Person is
490 trustworthy enough to become a mailing list expert, but not a Launchpad
491 administrator. He's given mailing list expert authority so that he can
492@@ -377,35 +351,12 @@
493 >>> print purge_text(expert_browser)
494 You can purge this mailing list...
495
496-Nobody can purge an active mailing list.
497-
498- >>> user_browser.open('http://launchpad.dev/~aardvarks/+mailinglist')
499- >>> user_browser.getControl('Reactivate this Mailing List').click()
500- >>> act()
501- >>> print_list_state()
502- ACTIVE
503-
504- >>> admin_browser.reload()
505- >>> expert_browser.reload()
506- >>> print find_tag_by_id(admin_browser.contents, 'mailing_list_purge')
507- None
508- >>> print find_tag_by_id(expert_browser.contents, 'mailing_list_purge')
509- None
510-
511- >>> act()
512- >>> print_list_state()
513- ACTIVE
514- >>> admin_browser.reload()
515- >>> expert_browser.reload()
516- >>> print find_tag_by_id(admin_browser.contents, 'mailing_list_purge')
517- None
518- >>> print find_tag_by_id(expert_browser.contents, 'mailing_list_purge')
519- None
520-
521 A constructing, modified, updating, or deactivating or mod-failed list cannot
522 be purged.
523
524 >>> from zope.security.proxy import removeSecurityProxy
525+ >>> from lp.registry.interfaces.mailinglist import MailingListStatus
526+
527 >>> def set_list_state(team_name, status):
528 ... login('foo.bar@canonical.com')
529 ... mailing_list = getUtility(IMailingListSet).get(team_name)
530@@ -424,38 +375,6 @@
531 ... expert_browser.open(url)
532 ... print purge_text(expert_browser)
533
534- >>> from lp.registry.interfaces.mailinglist import MailingListStatus
535- >>> show_states(MailingListStatus.CONSTRUCTING,
536- ... MailingListStatus.MODIFIED,
537- ... MailingListStatus.UPDATING,
538- ... MailingListStatus.DEACTIVATING,
539- ... MailingListStatus.MOD_FAILED)
540- CONSTRUCTING
541- None
542- None
543- MODIFIED
544- None
545- None
546- UPDATING
547- None
548- None
549- DEACTIVATING
550- None
551- None
552- MOD_FAILED
553- None
554- None
555-
556-A declined or failed list can be purged.
557-
558- >>> show_states(MailingListStatus.DECLINED, MailingListStatus.FAILED)
559- DECLINED
560- You can purge this mailing list...
561- You can purge this mailing list...
562- FAILED
563- You can purge this mailing list...
564- You can purge this mailing list...
565-
566 A purged list acts as if it doesn't even exist.
567
568 >>> set_list_state('aardvarks', MailingListStatus.PURGED)
569@@ -468,32 +387,10 @@
570 >>> expert_browser.getControl('Apply for Mailing List')
571 <SubmitControl name='field.actions.request_list_creation' type='submit'>
572
573-To the team owner, an inactive list can merely be reactivated, it still cannot
574-be purged.
575+The team owner can see that an inactive list can be reactivated or purged.
576
577 >>> set_list_state('aardvarks', MailingListStatus.INACTIVE)
578
579- >>> user_browser.open('http://launchpad.dev/~aardvarks/+mailinglist')
580- >>> user_browser.getControl('Reactivate this Mailing List')
581- <SubmitControl name='field.actions.reactivate_list' type='submit'>
582- >>> print purge_text(user_browser)
583- None
584-
585-However to the LP administrator or mailing list expert, the list can also be
586-purged.
587-
588- >>> expert_browser.open('http://launchpad.dev/~aardvarks/+mailinglist')
589- >>> expert_browser.getControl('Reactivate this Mailing List')
590- <SubmitControl name='field.actions.reactivate_list' type='submit'>
591- >>> print purge_text(expert_browser)
592- You can purge this mailing list...
593-
594- >>> admin_browser.open('http://launchpad.dev/~aardvarks/+mailinglist')
595- >>> admin_browser.getControl('Reactivate this Mailing List')
596- <SubmitControl name='field.actions.reactivate_list' type='submit'>
597- >>> print purge_text(admin_browser)
598- You can purge this mailing list...
599-
600
601 Actions on purged mailing lists
602 ===============================
603@@ -522,21 +419,13 @@
604 <Link text='View archive'
605 url='http://lists.launchpad.dev/aardvarks'>
606
607-A team with an active mailing list cannot be renamed.
608-
609- >>> user_browser.open('http://launchpad.dev/~aardvarks/+edit')
610- >>> user_browser.getControl(name='field.name').value = 'antelopes'
611- Traceback (most recent call last):
612- ...
613- LookupError: name 'field.name'
614-
615-But once the mailing list has been purged, the team can be renamed.
616+Once the mailing list has been purged, the team can be renamed.
617
618 >>> user_browser.open('http://launchpad.dev/~aardvarks/+mailinglist')
619 >>> user_browser.getControl('Deactivate this Mailing List').click()
620 >>> act()
621- >>> admin_browser.open('http://launchpad.dev/~aardvarks/+mailinglist')
622- >>> admin_browser.getControl('Purge this Mailing List').click()
623+ >>> user_browser.open('http://launchpad.dev/~aardvarks/+mailinglist')
624+ >>> user_browser.getControl('Purge this Mailing List').click()
625
626 >>> user_browser.open('http://launchpad.dev/~aardvarks/+edit')
627 >>> user_browser.getControl(name='field.name').value = 'antelopes'
628@@ -571,8 +460,8 @@
629 But if the mailing list for the team being merge has been purged, then the
630 merge is allowed.
631
632- >>> user_browser.open('http://launchpad.dev/~antelopes/+mailinglist')
633- >>> user_browser.getControl('Deactivate this Mailing List').click()
634+ >>> admin_browser.open('http://launchpad.dev/~antelopes/+mailinglist')
635+ >>> admin_browser.getControl('Deactivate this Mailing List').click()
636 >>> act()
637 >>> admin_browser.open('http://launchpad.dev/~antelopes/+mailinglist')
638 >>> admin_browser.getControl('Purge this Mailing List').click()
639
640=== modified file 'lib/lp/registry/templates/team-delete.pt'
641--- lib/lp/registry/templates/team-delete.pt 2009-12-24 01:26:58 +0000
642+++ lib/lp/registry/templates/team-delete.pt 2010-11-03 22:16:52 +0000
643@@ -12,8 +12,8 @@
644 </p>
645
646 <p tal:condition="view/has_mailing_list">
647- This team cannot be deleted until its mailing list is first
648- deactivated, then purged after the deactivation is confirmed.
649+ <strong>This team cannot be deleted until its mailing list is first
650+ deactivated, then purged after the deactivation is confirmed.</strong>
651 </p>
652
653 <p tal:condition="context/activemembers/count">
654
655=== modified file 'lib/lp/registry/tests/test_teammembership.py'
656--- lib/lp/registry/tests/test_teammembership.py 2010-10-04 19:50:45 +0000
657+++ lib/lp/registry/tests/test_teammembership.py 2010-11-03 22:16:52 +0000
658@@ -30,7 +30,7 @@
659 setUp,
660 tearDown,
661 )
662-from canonical.testing.layers import LaunchpadFunctionalLayer
663+from canonical.testing.layers import DatabaseFunctionalLayer
664 from lp.registry.interfaces.person import (
665 IPersonSet,
666 TeamSubscriptionPolicy,
667@@ -45,7 +45,7 @@
668
669
670 class TestTeamMembershipSet(unittest.TestCase):
671- layer = LaunchpadFunctionalLayer
672+ layer = DatabaseFunctionalLayer
673
674 def setUp(self):
675 login('test@canonical.com')
676@@ -157,7 +157,7 @@
677
678 class TeamParticipationTestCase(unittest.TestCase):
679 """Tests for team participation using 5 teams."""
680- layer = LaunchpadFunctionalLayer
681+ layer = DatabaseFunctionalLayer
682
683 def setUp(self):
684 login('foo.bar@canonical.com')
685@@ -190,6 +190,7 @@
686 team5
687 no-priv
688 """
689+ layer = DatabaseFunctionalLayer
690
691 def setUp(self):
692 """Setup the team hierarchy."""
693@@ -256,6 +257,7 @@
694 team5
695 no-priv
696 """
697+ layer = DatabaseFunctionalLayer
698
699 def setUp(self):
700 """Setup the team hierarchy."""
701@@ -320,6 +322,7 @@
702 team5
703 no-priv
704 """
705+ layer = DatabaseFunctionalLayer
706
707 def setUp(self):
708 """Setup the team hierarchy."""
709@@ -381,7 +384,7 @@
710
711
712 class TestTeamMembership(unittest.TestCase):
713- layer = LaunchpadFunctionalLayer
714+ layer = DatabaseFunctionalLayer
715
716 def test_teams_not_kicked_from_themselves_bug_248498(self):
717 """The self-participation of a team must not be removed.
718@@ -443,7 +446,7 @@
719
720 class TestTeamMembershipSetStatus(unittest.TestCase):
721 """Test the behaviour of TeamMembership's setStatus()."""
722- layer = LaunchpadFunctionalLayer
723+ layer = DatabaseFunctionalLayer
724
725 def setUp(self):
726 login('foo.bar@canonical.com')
727@@ -651,9 +654,52 @@
728 team1_on_team2.setStatus(TeamMembershipStatus.ADMIN, self.foobar)
729 self.assertEqual(team1_on_team2.status, TeamMembershipStatus.ADMIN)
730
731+ def test_invited_member_can_be_declined(self):
732+ # A team can decline an invited member.
733+ self.team2.addMember(self.team1, self.no_priv)
734+ tm = getUtility(ITeamMembershipSet).getByPersonAndTeam(
735+ self.team1, self.team2)
736+ tm.setStatus(
737+ TeamMembershipStatus.INVITATION_DECLINED, self.team2.teamowner)
738+ self.assertEqual(TeamMembershipStatus.INVITATION_DECLINED, tm.status)
739+
740+ def test_retractTeamMembership_invited(self):
741+ # A team can retract a membership invitation.
742+ self.team2.addMember(self.team1, self.no_priv)
743+ self.team1.retractTeamMembership(self.team2, self.team1.teamowner)
744+ tm = getUtility(ITeamMembershipSet).getByPersonAndTeam(
745+ self.team1, self.team2)
746+ self.assertEqual(TeamMembershipStatus.INVITATION_DECLINED, tm.status)
747+
748+ def test_retractTeamMembership_proposed(self):
749+ # A team can retract the proposed membership in a team.
750+ self.team2.subscriptionpolicy = TeamSubscriptionPolicy.MODERATED
751+ self.team1.join(self.team2, self.team1.teamowner)
752+ self.team1.retractTeamMembership(self.team2, self.team1.teamowner)
753+ tm = getUtility(ITeamMembershipSet).getByPersonAndTeam(
754+ self.team1, self.team2)
755+ self.assertEqual(TeamMembershipStatus.DECLINED, tm.status)
756+
757+ def test_retractTeamMembership_active(self):
758+ # A team can retract the membership in a team.
759+ self.team1.join(self.team2, self.team1.teamowner)
760+ self.team1.retractTeamMembership(self.team2, self.team1.teamowner)
761+ tm = getUtility(ITeamMembershipSet).getByPersonAndTeam(
762+ self.team1, self.team2)
763+ self.assertEqual(TeamMembershipStatus.DEACTIVATED, tm.status)
764+
765+ def test_retractTeamMembership_admin(self):
766+ # A team can retract the membership in a team.
767+ self.team1.join(self.team2, self.team1.teamowner)
768+ tm = getUtility(ITeamMembershipSet).getByPersonAndTeam(
769+ self.team1, self.team2)
770+ tm.setStatus(TeamMembershipStatus.ADMIN, self.team2.teamowner)
771+ self.team1.retractTeamMembership(self.team2, self.team1.teamowner)
772+ self.assertEqual(TeamMembershipStatus.DEACTIVATED, tm.status)
773+
774
775 class TestCheckTeamParticipationScript(unittest.TestCase):
776- layer = LaunchpadFunctionalLayer
777+ layer = DatabaseFunctionalLayer
778
779 def _runScript(self, expected_returncode=0):
780 process = subprocess.Popen(
781@@ -760,6 +806,6 @@
782 suite = unittest.TestLoader().loadTestsFromName(__name__)
783 bug_249185 = LayeredDocFileSuite(
784 'bug-249185.txt', optionflags=default_optionflags,
785- layer=LaunchpadFunctionalLayer, setUp=setUp, tearDown=tearDown)
786+ layer=DatabaseFunctionalLayer, setUp=setUp, tearDown=tearDown)
787 suite.addTest(bug_249185)
788 return suite