Merge lp:~bac/launchpad/bug-237722 into lp:launchpad

Proposed by Brad Crittenden on 2010-09-07
Status: Merged
Approved by: Brad Crittenden on 2010-09-09
Approved revision: 11489
Merged at revision: 11521
Proposed branch: lp:~bac/launchpad/bug-237722
Merge into: lp:launchpad
Diff against target: 261 lines (+190/-18)
3 files modified
lib/lp/registry/browser/person.py (+2/-5)
lib/lp/registry/browser/team.py (+45/-13)
lib/lp/registry/browser/tests/test_team_view.py (+143/-0)
To merge this branch: bzr merge lp:~bac/launchpad/bug-237722
Reviewer Review Type Date Requested Status
Matthew Revell (community) text 2010-09-08 Approve on 2010-09-09
Leonard Richardson (community) 2010-09-07 Approve on 2010-09-07
Review via email: mp+34790@code.launchpad.net

Commit Message

Catch CyclicalTeamMembershipError and display a pleasing warning message rather than filing an OOPS when +editproposedmembers would create a cycle.

Description of the Change

= Summary =

If a cycle of team membership could be created by accepting proposed
teams then the model will throw a CyclicalTeamMembershipError. This
error needs to be caught in the UI and transformed into a pleasing
message to the user.

== Proposed fix ==

Catch it and create a notification message.

== Pre-implementation notes ==

None.

== Implementation details ==

As above.

== Tests ==

bin/test -vvm lp.registry -t test_team_view

== Demo and Q/A ==

Follow the QA outlined in the test:

1) Create Team A.
2) Create Team B.
3) Go to https://launchpad.dev/~team-a
4) Select 'Add one of my teams' and choose Team B.
5) Go to https://launchpad.dev/~team-b
6) Select 'Add one of my teams' and choose Team A.
7) Go to https://launchpad.dev/~team-a and select "Edit proposed members".
8) Accept Team B
9) Go to https://launchpad.dev/~team-b and select "Edit proposed members".
10) Accept Team A
11) Note message. Verify you stayed on the +editproposedmembers page.

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/registry/browser/tests/test_team_view.py
  lib/lp/registry/browser/team.py
  lib/lp/registry/browser/person.py

To post a comment you must log in.
Leonard Richardson (leonardr) wrote :

"These teams should be declined." is ambiguous, as often happens with "should". Is it referring to something the end-user ought to do, or something that should have just happened automatically? I'm pretty sure it's the former, but you should reword it.

Can you refactor lines 152-183? (It might not be worth it.)

This is just a suggetion, but I'd kind of like to see a more general final test. Create team C and team D as
well as A and B. Team C is just like team A: team C is a member of B, and B has an outstanding invitation to C. Team D also has an outstanding invitation to B, but no current memberships.

Attempt to approve all three team memberships. Then demonstrate that A and C could not be added to B (with error message), but that D was added to B. I think this will make your coverage a little better, but not so much that I'll insist on you making this change.

(If you do this, call team B "Everyone wants to join this team" or something more memorable--it took me a while to get those last two paragraphs right, and I'm still not sure it's right.)

review: Approve
lp:~bac/launchpad/bug-237722 updated on 2010-09-08
11489. By Brad Crittenden on 2010-09-08

Added another test and refactored the common parts.

Brad Crittenden (bac) wrote :

Hi Leonard, thanks for the review. I have done the refactoring and added another test.

Your suggested word change was good. I've reworded and am asking Matthew Revell for a text review.

review: Approve (text)
lp:~bac/launchpad/bug-237722 updated on 2010-09-09
11490. By Brad Crittenden on 2010-09-09

Merge from devel

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/registry/browser/person.py'
2--- lib/lp/registry/browser/person.py 2010-09-03 03:12:39 +0000
3+++ lib/lp/registry/browser/person.py 2010-09-09 14:21:48 +0000
4@@ -208,10 +208,7 @@
5 DateTimeFormatterAPI,
6 PersonFormatterAPI,
7 )
8-from canonical.lazr.utils import (
9- safe_hasattr,
10- smartquote,
11- )
12+from canonical.lazr.utils import smartquote
13 from canonical.widgets import (
14 LaunchpadDropdownWidget,
15 LaunchpadRadioWidget,
16@@ -4435,7 +4432,7 @@
17 verb = 'have been'
18 team_string= (
19 ', '.join(team_names[:-1]) + ' and ' + team_names[-1])
20- full_message += '%s %s %s ' % (team_string, verb, message)
21+ full_message += '%s %s %s' % (team_string, verb, message)
22 self.request.response.addInfoNotification(full_message)
23
24
25
26=== modified file 'lib/lp/registry/browser/team.py'
27--- lib/lp/registry/browser/team.py 2010-09-03 03:12:39 +0000
28+++ lib/lp/registry/browser/team.py 2010-09-09 14:21:48 +0000
29@@ -89,7 +89,10 @@
30 TeamContactMethod,
31 TeamSubscriptionPolicy,
32 )
33-from lp.registry.interfaces.teammembership import TeamMembershipStatus
34+from lp.registry.interfaces.teammembership import (
35+ CyclicalTeamMembershipError,
36+ TeamMembershipStatus,
37+ )
38 from lp.services.fields import PublicPersonChoice
39 from lp.services.propertycache import cachedproperty
40
41@@ -934,31 +937,60 @@
42 @action('Save changes', name='save')
43 def action_save(self, action, data):
44 expires = self.context.defaultexpirationdate
45- for person in self.context.proposedmembers:
46+ statuses = dict(
47+ approve=TeamMembershipStatus.APPROVED,
48+ decline=TeamMembershipStatus.DECLINED,
49+ )
50+ target_team = self.context
51+ failed_joins = []
52+ for person in target_team.proposedmembers:
53 action = self.request.form.get('action_%d' % person.id)
54- if action == "approve":
55- status = TeamMembershipStatus.APPROVED
56- elif action == "decline":
57- status = TeamMembershipStatus.DECLINED
58- else:
59+ status = statuses.get(action)
60+ if status is None:
61 # The action is "hold" or no action was specified for this
62 # person, which could happen if the set of proposed members
63 # changed while the form was being processed.
64 continue
65-
66- self.context.setMembershipData(
67- person, status, reviewer=self.user, expires=expires,
68- comment=self.request.form.get('comment'))
69+ try:
70+ target_team.setMembershipData(
71+ person, status, reviewer=self.user, expires=expires,
72+ comment=self.request.form.get('comment'))
73+ except CyclicalTeamMembershipError:
74+ failed_joins.append(person)
75+
76+ if len(failed_joins) > 0:
77+ failed_names = [person.displayname for person in failed_joins]
78+ failed_list = ", ".join(failed_names)
79+
80+ mapping=dict(
81+ this_team=target_team.displayname,
82+ failed_list=failed_list)
83+
84+ if len(failed_joins) == 1:
85+ self.request.response.addInfoNotification(
86+ _('${this_team} is a member of the following team, so it '
87+ 'could not be accepted: '
88+ '${failed_list}. You need to "Decline" that team.',
89+ mapping=mapping))
90+ else:
91+ self.request.response.addInfoNotification(
92+ _('${this_team} is a member of the following teams, so '
93+ 'they could not be accepted: '
94+ '${failed_list}. You need to "Decline" those teams.',
95+ mapping=mapping))
96+ self.next_url = ''
97+ else:
98+ self.next_url = self._next_url
99
100 @property
101 def page_title(self):
102 return 'Proposed members of %s' % self.context.displayname
103
104 @property
105- def next_url(self):
106+ def _next_url(self):
107 return '%s/+members' % canonical_url(self.context)
108
109- cancel_url = next_url
110+ cancel_url = _next_url
111
112
113 class TeamBrandingView(BrandingChangeView):
114
115=== added file 'lib/lp/registry/browser/tests/test_team_view.py'
116--- lib/lp/registry/browser/tests/test_team_view.py 1970-01-01 00:00:00 +0000
117+++ lib/lp/registry/browser/tests/test_team_view.py 2010-09-09 14:21:48 +0000
118@@ -0,0 +1,143 @@
119+# Copyright 2010 Canonical Ltd. This software is licensed under the
120+# GNU Affero General Public License version 3 (see the file LICENSE).
121+
122+"""
123+Test team views.
124+"""
125+
126+__metaclass__ = type
127+
128+import transaction
129+
130+from canonical.testing import DatabaseFunctionalLayer
131+
132+from lp.registry.interfaces.person import TeamSubscriptionPolicy
133+
134+from lp.testing import (
135+ login_person,
136+ TestCaseWithFactory,
137+ )
138+from lp.testing.views import create_initialized_view
139+
140+
141+class TestProposedTeamMembersEditView(TestCaseWithFactory):
142+ """Tests for ProposedTeamMembersEditView."""
143+
144+ layer = DatabaseFunctionalLayer
145+
146+ def setUp(self):
147+ super(TestProposedTeamMembersEditView, self).setUp()
148+ self.owner = self.factory.makePerson(name="team-owner")
149+ self.a_team = self.makeTeam("team-a", "A-Team")
150+ self.b_team = self.makeTeam("team-b", "B-Team")
151+ transaction.commit()
152+ login_person(self.owner)
153+
154+ def makeTeam(self, name, displayname):
155+ """Make a moderated team."""
156+ return self.factory.makeTeam(
157+ name=name,
158+ owner=self.owner,
159+ displayname=displayname,
160+ subscription_policy=TeamSubscriptionPolicy.MODERATED)
161+
162+ def inviteToJoin(self, joinee, joiner):
163+ """Invite the joiner team into the joinee team."""
164+ # Joiner is proposed to join joinee.
165+ form = {
166+ 'field.teams': joiner.name,
167+ 'field.actions.continue': 'Continue',
168+ }
169+ view = create_initialized_view(
170+ joinee, "+add-my-teams", form=form)
171+ self.assertEqual([], view.errors)
172+ notifications = view.request.response.notifications
173+ self.assertEqual(1, len(notifications))
174+ expected = u"%s has been proposed to this team." % (
175+ joiner.displayname)
176+ self.assertEqual(
177+ expected,
178+ notifications[0].message)
179+
180+ def acceptTeam(self, joinee, successful, failed):
181+ """Accept the teams into the joinee team.
182+
183+ The teams in 'successful' are expected to be allowed.
184+ The teams in 'failed' are expected to fail.
185+ """
186+ failed_names = ', '.join([team.displayname for team in failed])
187+ if len(failed) == 1:
188+ failed_message = (
189+ u'%s is a member of the following team, '
190+ 'so it could not be accepted: %s. '
191+ 'You need to "Decline" that team.' %
192+ (joinee.displayname, failed_names))
193+ else:
194+ failed_message = (
195+ u'%s is a member of the following teams, '
196+ 'so they could not be accepted: %s. '
197+ 'You need to "Decline" those teams.' %
198+ (joinee.displayname, failed_names))
199+
200+ form = {
201+ 'field.actions.save': 'Save changes',
202+ }
203+ for team in successful + failed:
204+ # Construct the team selection field, based on the id of the
205+ # team.
206+ selector = 'action_%d' % team.id
207+ form[selector] = 'approve'
208+
209+ view = create_initialized_view(
210+ joinee, "+editproposedmembers", form=form)
211+ self.assertEqual([], view.errors)
212+ notifications = view.request.response.notifications
213+ if len(failed) == 0:
214+ self.assertEqual(0, len(notifications))
215+ else:
216+ self.assertEqual(1, len(notifications))
217+ self.assertEqual(
218+ failed_message,
219+ notifications[0].message)
220+
221+ def test_circular_proposal_acceptance(self):
222+ """Two teams can invite each other without horrifying results."""
223+
224+ # Make the criss-cross invitations.
225+
226+ # Owner proposes Team B join Team A.
227+ self.inviteToJoin(self.a_team, self.b_team)
228+
229+ # Owner proposes Team A join Team B.
230+ self.inviteToJoin(self.b_team, self.a_team)
231+
232+ # Accept Team B into Team A.
233+ self.acceptTeam(self.a_team, successful=(self.b_team,), failed=())
234+
235+ # Accept Team A into Team B, and fail trying.
236+ self.acceptTeam(self.b_team, successful=(), failed=(self.a_team,))
237+
238+ def test_circular_proposal_acceptance_with_some_noncircular(self):
239+ """Accepting a mix of successful and failed teams works."""
240+ # Create some extra teams.
241+ self.c_team = self.makeTeam("team-c", "C-Team")
242+ self.d_team = self.makeTeam("team-d", "D-Team")
243+ self.super_team = self.makeTeam("super-team", "Super Team")
244+
245+ # Everyone wants to join Super Team.
246+ for team in [self.a_team, self.b_team, self.c_team, self.d_team]:
247+ self.inviteToJoin(self.super_team, team)
248+
249+ # Super Team joins two teams.
250+ for team in [self.a_team, self.b_team]:
251+ self.inviteToJoin(team, self.super_team)
252+
253+ # Super Team is accepted into both.
254+ for team in [self.a_team, self.b_team]:
255+ self.acceptTeam(team, successful=(self.super_team, ), failed=())
256+
257+ # Now Super Team attempts to accept all teams. Two succeed but the
258+ # two with that would cause a cycle fail.
259+ failed = (self.a_team, self.b_team)
260+ successful = (self.c_team, self.d_team)
261+ self.acceptTeam(self.super_team, successful, failed)