Merge lp:~edwin-grubbs/launchpad/bug-664828-teammembership-delete-timeout into lp:launchpad

Proposed by Edwin Grubbs
Status: Merged
Approved by: Māris Fogels
Approved revision: no longer in the source branch.
Merged at revision: 12183
Proposed branch: lp:~edwin-grubbs/launchpad/bug-664828-teammembership-delete-timeout
Merge into: lp:launchpad
Diff against target: 681 lines (+261/-191)
5 files modified
lib/lp/registry/browser/tests/test_teammembership.py (+69/-0)
lib/lp/registry/doc/teammembership.txt (+2/-0)
lib/lp/registry/interfaces/teammembership.py (+5/-1)
lib/lp/registry/model/teammembership.py (+78/-160)
lib/lp/registry/tests/test_teammembership.py (+107/-30)
To merge this branch: bzr merge lp:~edwin-grubbs/launchpad/bug-664828-teammembership-delete-timeout
Reviewer Review Type Date Requested Status
Māris Fogels (community) Approve
Review via email: mp+45805@code.launchpad.net

Commit message

[r=mars][ui=none][bug=664828] Fixed timeout when deactivating a member of a team.

Description of the change

Summary
-------

This branch fixes the timeout when deactivating a member of a team that
was caused by the many queries necessary to traverse the TeamMembership
table to determine what entries need to be removed from the
TeamParticipation table.

Implementation details
----------------------

Used a postgresql 8.4 recursive query to traverse the TeamMembership
table to determine which entries should be kept in TeamParticipation.
    lib/lp/registry/model/teammembership.py

Extended tests to verify that there are no extra deletions from the
TeamParticipation table as a side-effect. Added test to make sure that
the number of queries doesn't grow.
    lib/lp/registry/tests/test_teammembership.py
    lib/lp/registry/browser/tests/test_teammembership.py

Minor edits:
    lib/lp/registry/doc/teammembership.txt
    lib/lp/registry/interfaces/teammembership.py

Tests
-----

./bin/test -vv -t 'doc/teammembership.txt|registry.tests.test_teammembership'

Demo and Q/A
------------

* Open http://launchpad.dev/...

Lint
----

No lint.

To post a comment you must log in.
Revision history for this message
Robert Collins (lifeless) wrote :

I'm confused - why is a recursive query needed?

Isn't the same thing used to do the +participations able to serve the
response in ~ 2 queries?

Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :

> I'm confused - why is a recursive query needed?
>
> Isn't the same thing used to do the +participations able to serve the
> response in ~ 2 queries?

If Team B's membership in Team A is deactivated, then Team B and all of its participants may or may not still participate in Team A and its super teams. The only way to tell if the super teams still have participants via another path is by recursing the membership tree. This could either be done as a recursive query, which I chose, or in python.

Revision history for this message
Māris Fogels (mars) wrote :

Hi Edwin,

The code in this change looks good to me, and the SQL is nicely documented. My first question is, where does the magic number '12' come from as the query count in test_deactivate_member_query_count? I know the test is for performance testing, but that one test and one number don't detail how the query is expected to perform using different data.

On a related note, is there already a test for the case you mentioned to Robert? "The only way to tell if the super teams still have participants via another path is by recursing the membership tree."

Maris

review: Needs Fixing
Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :
Download full text (3.2 KiB)

Hi Maris,

Thanks for the review. An incremental diff is included at the bottom.

> Hi Edwin,
>
> The code in this change looks good to me, and the SQL is nicely documented.
> My first question is, where does the magic number '12' come from as the query
> count in test_deactivate_member_query_count? I know the test is for
> performance testing, but that one test and one number don't detail how the
> query is expected to perform using different data.

I have added a comment explaining the queries. I was able to reduce the number of queries by one by moving create_view() outside of the with-statement.

> On a related note, is there already a test for the case you mentioned to
> Robert? "The only way to tell if the super teams still have participants via
> another path is by recursing the membership tree."

lp/registry/tests/test_teammembership.py already has the TestTeamParticipationMesh, TestTeamParticipationTable, and TestTeamParticipationHierarchy tests that verify that the TeamParticipation table is updated correctly.

Incremental diff:

=== modified file 'lib/lp/registry/browser/tests/test_teammembership.py'
--- lib/lp/registry/browser/tests/test_teammembership.py 2011-01-11 01:40:09 +0000
+++ lib/lp/registry/browser/tests/test_teammembership.py 2011-01-11 20:45:04 +0000
@@ -32,6 +32,25 @@
         self.member = self.factory.makeTeam()

     def test_deactivate_member_query_count(self):
+ # Only these queries should be run, no matter what the
+ # membership tree looks like, although the number of queries
+ # could change slightly if a different user is logged in.
+ # 1. Check whether the user is the team owner.
+ # 2. Deactivate the membership in the TeamMembership table.
+ # 3. Delete from TeamParticipation table.
+ # (Queries #4, #5, #6, #7, and #10 are run because the storm
+ # objects have been invalidated.)
+ # 4. Get the TeamMembership entry.
+ # 5. Verify that the member exists in the db, but don't load
+ # the refresh the rest of its data, since we just need the id.
+ # 6. Verify that the user exists in the db.
+ # 7. Verify that the team exists in the db.
+ # 8. Insert into Job table.
+ # 9. Insert into PersonTransferJob table to schedule sending
+ # email. (This requires the data from queries #5, #6, and
+ # #7.)
+ # 10.Query the rest of the team data for the invalidated
+ # object in order to generate the canonical url.
         self.team.addMember(
             self.member, self.team.teamowner, force_team_add=True)
         form = {
@@ -41,9 +60,10 @@
             }
         membership = self.membership_set.getByPersonAndTeam(
             self.member, self.team)
+ view = create_view(
+ membership, "+index", method='POST', form=form)
         with StormStatementRecorder() as recorder:
- view = create_view(membership, "+index", method='POST', form=form)
             view.processForm()
         self.assertEqual('', view.errormessage)
         self.assertEqual(TeamMembershipStatus.DEACTIVATED, membership.status)
- self...

Read more...

Revision history for this message
Māris Fogels (mars) wrote :

> Hi Maris,
>
> Thanks for the review. An incremental diff is included at the bottom.
>

Thanks Edwin, your code looks good. r=mars

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'lib/lp/registry/browser/tests/test_teammembership.py'
2--- lib/lp/registry/browser/tests/test_teammembership.py 1970-01-01 00:00:00 +0000
3+++ lib/lp/registry/browser/tests/test_teammembership.py 2011-01-11 20:48:40 +0000
4@@ -0,0 +1,69 @@
5+# Copyright 2011 Canonical Ltd. This software is licensed under the
6+# GNU Affero General Public License version 3 (see the file LICENSE).
7+
8+__metaclass__ = type
9+
10+from testtools.matchers import LessThan
11+from zope.component import getUtility
12+
13+from canonical.testing.layers import DatabaseFunctionalLayer
14+from lp.testing.matchers import HasQueryCount
15+from lp.registry.interfaces.teammembership import (
16+ ITeamMembershipSet,
17+ TeamMembershipStatus,
18+ )
19+from lp.testing import (
20+ login_celebrity,
21+ StormStatementRecorder,
22+ TestCaseWithFactory,
23+ )
24+from lp.testing.views import create_view
25+
26+
27+class TestTeamMenu(TestCaseWithFactory):
28+
29+ layer = DatabaseFunctionalLayer
30+
31+ def setUp(self):
32+ super(TestTeamMenu, self).setUp()
33+ login_celebrity('admin')
34+ self.membership_set = getUtility(ITeamMembershipSet)
35+ self.team = self.factory.makeTeam()
36+ self.member = self.factory.makeTeam()
37+
38+ def test_deactivate_member_query_count(self):
39+ # Only these queries should be run, no matter what the
40+ # membership tree looks like, although the number of queries
41+ # could change slightly if a different user is logged in.
42+ # 1. Check whether the user is the team owner.
43+ # 2. Deactivate the membership in the TeamMembership table.
44+ # 3. Delete from TeamParticipation table.
45+ # (Queries #4, #5, #6, #7, and #10 are run because the storm
46+ # objects have been invalidated.)
47+ # 4. Get the TeamMembership entry.
48+ # 5. Verify that the member exists in the db, but don't load
49+ # the refresh the rest of its data, since we just need the id.
50+ # 6. Verify that the user exists in the db.
51+ # 7. Verify that the team exists in the db.
52+ # 8. Insert into Job table.
53+ # 9. Insert into PersonTransferJob table to schedule sending
54+ # email. (This requires the data from queries #5, #6, and
55+ # #7.)
56+ # 10.Query the rest of the team data for the invalidated
57+ # object in order to generate the canonical url.
58+ self.team.addMember(
59+ self.member, self.team.teamowner, force_team_add=True)
60+ form = {
61+ 'editactive': 1,
62+ 'expires': 'never',
63+ 'deactivate': 'Deactivate',
64+ }
65+ membership = self.membership_set.getByPersonAndTeam(
66+ self.member, self.team)
67+ view = create_view(
68+ membership, "+index", method='POST', form=form)
69+ with StormStatementRecorder() as recorder:
70+ view.processForm()
71+ self.assertEqual('', view.errormessage)
72+ self.assertEqual(TeamMembershipStatus.DEACTIVATED, membership.status)
73+ self.assertThat(recorder, HasQueryCount(LessThan(11)))
74
75=== modified file 'lib/lp/registry/doc/teammembership.txt'
76--- lib/lp/registry/doc/teammembership.txt 2010-10-24 12:46:23 +0000
77+++ lib/lp/registry/doc/teammembership.txt 2011-01-11 20:48:40 +0000
78@@ -131,6 +131,8 @@
79 ... ubuntu_team, TeamMembershipStatus.DEACTIVATED, t2.teamowner)
80 >>> ubuntu_team in t2.activemembers
81 False
82+ >>> [m.displayname for m in t2.allmembers]
83+ [u'No Privileges Person']
84 >>> login(ANONYMOUS)
85
86 Another API for adding members is ITeam.addMember(), which ensures the given
87
88=== modified file 'lib/lp/registry/interfaces/teammembership.py'
89--- lib/lp/registry/interfaces/teammembership.py 2010-12-10 14:58:31 +0000
90+++ lib/lp/registry/interfaces/teammembership.py 2011-01-11 20:48:40 +0000
91@@ -116,7 +116,11 @@
92
93
94 class ITeamMembership(Interface):
95- """TeamMembership for Users"""
96+ """TeamMembership for Users.
97+
98+ This table includes *direct* team members only. Indirect memberships are
99+ handled by the TeamParticipation table.
100+ """
101 export_as_webservice_entry()
102
103 id = Int(title=_('ID'), required=True, readonly=True)
104
105=== modified file 'lib/lp/registry/model/teammembership.py'
106--- lib/lp/registry/model/teammembership.py 2010-12-10 14:58:31 +0000
107+++ lib/lp/registry/model/teammembership.py 2011-01-11 20:48:40 +0000
108@@ -21,7 +21,7 @@
109 ForeignKey,
110 StringCol,
111 )
112-from storm.locals import Store
113+from storm.store import Store
114 from zope.component import getUtility
115 from zope.interface import implements
116
117@@ -498,167 +498,85 @@
118 person = ForeignKey(dbName='person', foreignKey='Person', notNull=True)
119
120
121-def _cleanTeamParticipation(person, team):
122- """Remove relevant entries in TeamParticipation for <person> and <team>.
123-
124- Remove all tuples "person, team" from TeamParticipation for the given
125- person and team (together with all its superteams), unless this person is
126- an indirect member of the given team. More information on how to use the
127- TeamParticipation table can be found in the TeamParticipationUsage spec or
128- the teammembership.txt system doctest.
129- """
130- query = """
131- SELECT EXISTS(
132- SELECT 1 FROM TeamParticipation
133- WHERE person = %(person_id)s AND team IN (
134- SELECT person
135- FROM TeamParticipation JOIN Person ON
136- (person = Person.id)
137- WHERE team = %(team_id)s
138- AND person NOT IN (%(team_id)s, %(person_id)s)
139- AND teamowner IS NOT NULL
140- )
141- )
142- """ % dict(team_id=team.id, person_id=person.id)
143- store = Store.of(person)
144- (result, ) = store.execute(query).get_one()
145- if result:
146- # The person is a participant in this team by virtue of a membership
147- # in another one, so don't attempt to remove anything.
148- return
149-
150- # First of all, we remove <person> from <team> (and its superteams).
151- _removeParticipantFromTeamAndSuperTeams(person, team)
152- if not person.is_team:
153- # Nothing else to do.
154- return
155-
156- store = Store.of(person)
157-
158- # Clean the participation of all our participant subteams, that are
159- # not a direct members of the target team.
160- query = """
161- -- All of my participant subteams...
162- SELECT person
163- FROM TeamParticipation JOIN Person ON (person = Person.id)
164- WHERE team = %(person_id)s AND person != %(person_id)s
165- AND teamowner IS NOT NULL
166- EXCEPT
167- -- that aren't a direct member of the team.
168- SELECT person
169- FROM TeamMembership
170- WHERE team = %(team_id)s AND status IN %(active_states)s
171- """ % dict(
172- person_id=person.id, team_id=team.id,
173- active_states=sqlvalues(ACTIVE_STATES)[0])
174-
175- # Avoid circular import.
176- from lp.registry.model.person import Person
177- for subteam in store.find(Person, "id IN (%s)" % query):
178- _cleanTeamParticipation(subteam, team)
179-
180- # Then clean-up all the non-team participants. We can remove those
181- # in a single query when the team graph is up to date.
182- _removeAllIndividualParticipantsFromTeamAndSuperTeams(person, team)
183-
184-
185-def _removeParticipantFromTeamAndSuperTeams(person, team):
186- """Remove participation of person in team.
187-
188- If <person> is a participant (that is, has a TeamParticipation entry)
189- of any team that is a subteam of <team>, then <person> should be kept as
190- a participant of <team> and (as a consequence) all its superteams.
191- Otherwise, <person> is removed from <team> and we repeat this process for
192- each superteam of <team>.
193- """
194- # Check if the person is a member of the given team through another team.
195- query = """
196- SELECT EXISTS(
197- SELECT 1
198- FROM TeamParticipation, TeamMembership
199- WHERE
200- TeamMembership.team = %(team_id)s AND
201- TeamMembership.person = TeamParticipation.team AND
202- TeamParticipation.person = %(person_id)s AND
203- TeamMembership.status IN %(active_states)s)
204- """ % dict(team_id=team.id, person_id=person.id,
205- active_states=sqlvalues(ACTIVE_STATES)[0])
206- store = Store.of(person)
207- (result, ) = store.execute(query).get_one()
208- if result:
209- # The person is a participant by virtue of a membership on another
210- # team, so don't remove.
211- return
212- store.find(TeamParticipation, (
213- (TeamParticipation.team == team) &
214- (TeamParticipation.person == person))).remove()
215-
216- for superteam in _getSuperTeamsExcludingDirectMembership(person, team):
217- _removeParticipantFromTeamAndSuperTeams(person, superteam)
218-
219-
220-def _removeAllIndividualParticipantsFromTeamAndSuperTeams(team, target_team):
221- """Remove all non-team participants in <team> from <target_team>.
222-
223- All the non-team participants of <team> are removed from <target_team>
224- and its super teams, unless they participate in <target_team> also from
225- one of its sub team.
226- """
227- query = """
228+def _cleanTeamParticipation(child, parent):
229+ """Remove child from team and clean up child's subteams.
230+
231+ A participant of child is removed from parent's TeamParticipation
232+ entries if the only path from the participant to parent is via
233+ child.
234+ """
235+ # Delete participation entries for the child and the child's
236+ # direct/indirect members in other ancestor teams, unless those
237+ # ancestor teams have another path the the child besides the
238+ # membership that has just been deactivated.
239+ store = Store.of(parent)
240+ store.execute("""
241 DELETE FROM TeamParticipation
242- WHERE team = %(target_team_id)s AND person IN (
243- -- All the individual participants.
244- SELECT person
245- FROM TeamParticipation JOIN Person ON (person = Person.id)
246- WHERE team = %(team_id)s AND teamowner IS NULL
247- EXCEPT
248- -- people participating through a subteam of target_team;
249- SELECT person
250+ USING (
251+ /* Get all the participation entries that might need to be
252+ * deleted, i.e. all the entries where the
253+ * TeamParticipation.person is a participant of child, and
254+ * where child participated in TeamParticipation.team until
255+ * child was removed from parent.
256+ */
257+ SELECT person, team
258 FROM TeamParticipation
259- WHERE team IN (
260- -- The subteams of target_team.
261- SELECT person
262- FROM TeamParticipation JOIN Person ON (person = Person.id)
263- WHERE team = %(target_team_id)s
264- AND person NOT IN (%(target_team_id)s, %(team_id)s)
265- AND teamowner IS NOT NULL
266- )
267- -- or people directly a member of the target team.
268- EXCEPT
269- SELECT person
270- FROM TeamMembership
271- WHERE team = %(target_team_id)s AND status IN %(active_states)s
272- )
273- """ % dict(
274- team_id=team.id, target_team_id=target_team.id,
275- active_states=sqlvalues(ACTIVE_STATES)[0])
276- store = Store.of(team)
277- store.execute(query)
278-
279- super_teams = _getSuperTeamsExcludingDirectMembership(team, target_team)
280- for superteam in super_teams:
281- _removeAllIndividualParticipantsFromTeamAndSuperTeams(team, superteam)
282-
283-
284-def _getSuperTeamsExcludingDirectMembership(person, team):
285- """Return all the super teams of <team> where person isn't a member."""
286- query = """
287- -- All the super teams...
288- SELECT team
289- FROM TeamParticipation
290- WHERE person = %(team_id)s AND team != %(team_id)s
291- EXCEPT
292- -- The one where person has an active membership.
293- SELECT team
294- FROM TeamMembership
295- WHERE person = %(person_id)s AND status IN %(active_states)s
296- """ % dict(
297- person_id=person.id, team_id=team.id,
298- active_states=sqlvalues(ACTIVE_STATES)[0])
299-
300- # Avoid circular import.
301- from lp.registry.model.person import Person
302- return Store.of(person).find(Person, "id IN (%s)" % query)
303+ WHERE person IN (
304+ SELECT person
305+ FROM TeamParticipation
306+ WHERE team = %(child)s
307+ )
308+ AND team IN (
309+ SELECT team
310+ FROM TeamParticipation
311+ WHERE person = %(child)s
312+ AND team != %(child)s
313+ )
314+
315+
316+ EXCEPT (
317+
318+ /* Compute the TeamParticipation entries that we need to
319+ * keep by walking the tree in the TeamMembership table.
320+ */
321+ WITH RECURSIVE parent(person, team) AS (
322+ /* Start by getting all the ancestors of the child
323+ * from the TeamParticipation table, then get those
324+ * ancestors' direct members to recurse through the
325+ * tree from the top.
326+ */
327+ SELECT ancestor.person, ancestor.team
328+ FROM TeamMembership ancestor
329+ WHERE ancestor.status IN %(active_states)s
330+ AND ancestor.team IN (
331+ SELECT team
332+ FROM TeamParticipation
333+ WHERE person = %(child)s
334+ )
335+
336+ UNION
337+
338+ /* Find the next level of direct members, but hold
339+ * onto the parent.team, since we want the top and
340+ * bottom of the hierarchy to calculate the
341+ * TeamParticipation. The query above makes sure
342+ * that we do this for all the ancestors.
343+ */
344+ SELECT child.person, parent.team
345+ FROM TeamMembership child
346+ JOIN parent ON child.team = parent.person
347+ WHERE child.status IN %(active_states)s
348+ )
349+ SELECT person, team
350+ FROM parent
351+ )
352+ ) AS keeping
353+ WHERE TeamParticipation.person = keeping.person
354+ AND TeamParticipation.team = keeping.team
355+ """ % sqlvalues(
356+ child=child.id,
357+ active_states=ACTIVE_STATES))
358+ store.invalidate()
359
360
361 def _fillTeamParticipation(member, accepting_team):
362
363=== modified file 'lib/lp/registry/tests/test_teammembership.py'
364--- lib/lp/registry/tests/test_teammembership.py 2010-12-02 16:13:51 +0000
365+++ lib/lp/registry/tests/test_teammembership.py 2011-01-11 20:48:40 +0000
366@@ -9,7 +9,10 @@
367 )
368 import re
369 import subprocess
370-import unittest
371+from unittest import (
372+ TestCase,
373+ TestLoader,
374+ )
375
376 import pytz
377 from zope.component import getUtility
378@@ -24,6 +27,7 @@
379 login,
380 login_person,
381 )
382+from canonical.launchpad.interfaces.lpstorm import IStore
383 from canonical.launchpad.testing.systemdocs import (
384 default_optionflags,
385 LayeredDocFileSuite,
386@@ -40,11 +44,14 @@
387 ITeamMembershipSet,
388 TeamMembershipStatus,
389 )
390-from lp.registry.model.teammembership import TeamMembership
391-from lp.testing.factory import LaunchpadObjectFactory
392-
393-
394-class TestTeamMembershipSet(unittest.TestCase):
395+from lp.registry.model.teammembership import (
396+ TeamMembership,
397+ TeamParticipation,
398+ )
399+from lp.testing import TestCaseWithFactory
400+
401+
402+class TestTeamMembershipSet(TestCase):
403 layer = DatabaseFunctionalLayer
404
405 def setUp(self):
406@@ -155,11 +162,12 @@
407 self.failIf(sample_person.inTeam(motu))
408
409
410-class TeamParticipationTestCase(unittest.TestCase):
411+class TeamParticipationTestCase(TestCaseWithFactory):
412 """Tests for team participation using 5 teams."""
413 layer = DatabaseFunctionalLayer
414
415 def setUp(self):
416+ super(TeamParticipationTestCase, self).setUp()
417 login('foo.bar@canonical.com')
418 person_set = getUtility(IPersonSet)
419 self.foo_bar = person_set.getByEmail('foo.bar@canonical.com')
420@@ -177,6 +185,9 @@
421 sorted(participant_names),
422 sorted([participant.name for participant in team.allmembers]))
423
424+ def getTeamParticipationCount(self):
425+ return IStore(TeamParticipation).find(TeamParticipation).count()
426+
427
428 class TestTeamParticipationHierarchy(TeamParticipationTestCase):
429 """Participation management tests using 5 nested teams.
430@@ -221,6 +232,7 @@
431
432 This is similar to what was experienced in bug 261915.
433 """
434+ previous_count = self.getTeamParticipationCount()
435 self.team2.setMembershipData(
436 self.team3, TeamMembershipStatus.DEACTIVATED, self.foo_bar)
437 self.assertParticipantsEquals(['name16', 'team2'], self.team1)
438@@ -230,11 +242,15 @@
439 self.assertParticipantsEquals(
440 ['name16', 'no-priv', 'team5'], self.team4)
441 self.assertParticipantsEquals(['name16', 'no-priv'], self.team5)
442+ self.assertEqual(
443+ previous_count-8,
444+ self.getTeamParticipationCount())
445
446 def testRemovingLeafTeam(self):
447 """Make sure that participations are updated correctly when removing
448 the leaf team.
449 """
450+ previous_count = self.getTeamParticipationCount()
451 self.team4.setMembershipData(
452 self.team5, TeamMembershipStatus.DEACTIVATED, self.foo_bar)
453 self.assertParticipantsEquals(
454@@ -244,6 +260,9 @@
455 self.assertParticipantsEquals(['name16', 'team4'], self.team3)
456 self.assertParticipantsEquals(['name16'], self.team4)
457 self.assertParticipantsEquals(['name16', 'no-priv'], self.team5)
458+ self.assertEqual(
459+ previous_count-8,
460+ self.getTeamParticipationCount())
461
462
463 class TestTeamParticipationTree(TeamParticipationTestCase):
464@@ -252,9 +271,10 @@
465 Create a team hierarchy looking like this:
466 team1
467 team2
468- team5 team3
469- team4
470- team5
471+ team5
472+ team3
473+ team4
474+ team5
475 no-priv
476 """
477 layer = DatabaseFunctionalLayer
478@@ -269,6 +289,10 @@
479 self.team3.addMember(self.team4, self.foo_bar, force_team_add=True)
480 self.team4.addMember(self.team5, self.foo_bar, force_team_add=True)
481
482+ def tearDown(self):
483+ super(TestTeamParticipationTree, self).tearDown()
484+ self.layer.force_dirty_database()
485+
486 def testTeamParticipationSetUp(self):
487 """Make sure that the TeamParticipation are sane after setUp."""
488 self.assertParticipantsEquals(
489@@ -284,6 +308,7 @@
490 ['name16', 'no-priv'], self.team5)
491
492 def testRemoveTeam3FromTeam2(self):
493+ previous_count = self.getTeamParticipationCount()
494 self.team2.setMembershipData(
495 self.team3, TeamMembershipStatus.DEACTIVATED, self.foo_bar)
496 self.assertParticipantsEquals(
497@@ -295,8 +320,12 @@
498 self.assertParticipantsEquals(
499 ['name16', 'no-priv', 'team5'], self.team4)
500 self.assertParticipantsEquals(['name16', 'no-priv'], self.team5)
501+ self.assertEqual(
502+ previous_count-4,
503+ self.getTeamParticipationCount())
504
505 def testRemoveTeam5FromTeam4(self):
506+ previous_count = self.getTeamParticipationCount()
507 self.team4.setMembershipData(
508 self.team5, TeamMembershipStatus.DEACTIVATED, self.foo_bar)
509 self.assertParticipantsEquals(
510@@ -308,6 +337,40 @@
511 ['name16', 'team4'], self.team3)
512 self.assertParticipantsEquals(['name16'], self.team4)
513 self.assertParticipantsEquals(['name16', 'no-priv'], self.team5)
514+ self.assertEqual(
515+ previous_count-4,
516+ self.getTeamParticipationCount())
517+
518+
519+class TestParticipationCleanup(TeamParticipationTestCase):
520+ """Test deletion of a member from a team with many superteams.
521+ Create a team hierarchy looking like this:
522+ team1
523+ team2
524+ team3
525+ team4
526+ team5
527+ no-priv
528+ """
529+
530+ def setUp(self):
531+ """Setup the team hierarchy."""
532+ super(TestParticipationCleanup, self).setUp()
533+ self.team1.addMember(self.team2, self.foo_bar, force_team_add=True)
534+ self.team2.addMember(self.team3, self.foo_bar, force_team_add=True)
535+ self.team3.addMember(self.team4, self.foo_bar, force_team_add=True)
536+ self.team4.addMember(self.team5, self.foo_bar, force_team_add=True)
537+ self.team5.addMember(self.no_priv, self.foo_bar)
538+
539+ def testMemberRemoval(self):
540+ """Remove the member from the last team.
541+
542+ The number of db queries should be constant not O(depth).
543+ """
544+ self.assertStatementCount(
545+ 7,
546+ self.team5.setMembershipData, self.no_priv,
547+ TeamMembershipStatus.DEACTIVATED, self.team5.teamowner)
548
549
550 class TestTeamParticipationMesh(TeamParticipationTestCase):
551@@ -315,12 +378,15 @@
552 branches.
553
554 Create a team hierarchy looking like this:
555- team1 /--team6
556- team2 \
557- | team3 |
558- \--- team4-/
559- team5
560- no-priv
561+ team1 team6
562+ \ / |
563+ team2 |
564+ / | |
565+ team3 | |
566+ \ | /
567+ team4
568+ team5
569+ no-priv
570 """
571 layer = DatabaseFunctionalLayer
572
573@@ -338,6 +404,10 @@
574 self.team6.addMember(self.team2, self.foo_bar, force_team_add=True)
575 self.team6.addMember(self.team4, self.foo_bar, force_team_add=True)
576
577+ def tearDown(self):
578+ super(TestTeamParticipationMesh, self).tearDown()
579+ self.layer.force_dirty_database()
580+
581 def testTeamParticipationSetUp(self):
582 """Make sure that the TeamParticipation are sane after setUp."""
583 self.assertParticipantsEquals(
584@@ -355,6 +425,7 @@
585 self.team6)
586
587 def testRemoveTeam3FromTeam2(self):
588+ previous_count = self.getTeamParticipationCount()
589 self.team2.setMembershipData(
590 self.team3, TeamMembershipStatus.DEACTIVATED, self.foo_bar)
591 self.assertParticipantsEquals(
592@@ -368,8 +439,12 @@
593 self.assertParticipantsEquals(['name16', 'no-priv'], self.team5)
594 self.assertParticipantsEquals(
595 ['name16', 'no-priv', 'team2', 'team4', 'team5'], self.team6)
596+ self.assertEqual(
597+ previous_count-3,
598+ self.getTeamParticipationCount())
599
600 def testRemoveTeam5FromTeam4(self):
601+ previous_count = self.getTeamParticipationCount()
602 self.team4.setMembershipData(
603 self.team5, TeamMembershipStatus.DEACTIVATED, self.foo_bar)
604 self.assertParticipantsEquals(
605@@ -381,9 +456,12 @@
606 self.assertParticipantsEquals(['name16', 'no-priv'], self.team5)
607 self.assertParticipantsEquals(
608 ['name16', 'team2', 'team3', 'team4'], self.team6)
609-
610-
611-class TestTeamMembership(unittest.TestCase):
612+ self.assertEqual(
613+ previous_count-10,
614+ self.getTeamParticipationCount())
615+
616+
617+class TestTeamMembership(TestCaseWithFactory):
618 layer = DatabaseFunctionalLayer
619
620 def test_teams_not_kicked_from_themselves_bug_248498(self):
621@@ -400,12 +478,11 @@
622 This test will make sure that doesn't happen in the future.
623 """
624 login('test@canonical.com')
625- factory = LaunchpadObjectFactory()
626- person = factory.makePerson()
627+ person = self.factory.makePerson()
628 login_person(person) # Now login with the future owner of the teams.
629- teamA = factory.makeTeam(
630+ teamA = self.factory.makeTeam(
631 person, subscription_policy=TeamSubscriptionPolicy.MODERATED)
632- teamB = factory.makeTeam(
633+ teamB = self.factory.makeTeam(
634 person, subscription_policy=TeamSubscriptionPolicy.MODERATED)
635 self.failUnless(
636 teamA.inTeam(teamA), "teamA is not a participant of itself")
637@@ -444,21 +521,21 @@
638 self.assertEqual(new_status, TeamMembershipStatus.DEACTIVATED.value)
639
640
641-class TestTeamMembershipSetStatus(unittest.TestCase):
642+class TestTeamMembershipSetStatus(TestCaseWithFactory):
643 """Test the behaviour of TeamMembership's setStatus()."""
644 layer = DatabaseFunctionalLayer
645
646 def setUp(self):
647+ super(TestTeamMembershipSetStatus, self).setUp()
648 login('foo.bar@canonical.com')
649 self.foobar = getUtility(IPersonSet).getByName('name16')
650 self.no_priv = getUtility(IPersonSet).getByName('no-priv')
651 self.ubuntu_team = getUtility(IPersonSet).getByName('ubuntu-team')
652 self.admins = getUtility(IPersonSet).getByName('admins')
653 # Create a bunch of arbitrary teams to use in the tests.
654- factory = LaunchpadObjectFactory()
655- self.team1 = factory.makeTeam(self.foobar)
656- self.team2 = factory.makeTeam(self.foobar)
657- self.team3 = factory.makeTeam(self.foobar)
658+ self.team1 = self.factory.makeTeam(self.foobar)
659+ self.team2 = self.factory.makeTeam(self.foobar)
660+ self.team3 = self.factory.makeTeam(self.foobar)
661
662 def test_proponent_is_stored(self):
663 for status in [TeamMembershipStatus.DEACTIVATED,
664@@ -698,7 +775,7 @@
665 self.assertEqual(TeamMembershipStatus.DEACTIVATED, tm.status)
666
667
668-class TestCheckTeamParticipationScript(unittest.TestCase):
669+class TestCheckTeamParticipationScript(TestCase):
670 layer = DatabaseFunctionalLayer
671
672 def _runScript(self, expected_returncode=0):
673@@ -803,7 +880,7 @@
674
675
676 def test_suite():
677- suite = unittest.TestLoader().loadTestsFromName(__name__)
678+ suite = TestLoader().loadTestsFromName(__name__)
679 bug_249185 = LayeredDocFileSuite(
680 'bug-249185.txt', optionflags=default_optionflags,
681 layer=DatabaseFunctionalLayer, setUp=setUp, tearDown=tearDown)