Merge lp:~edwin-grubbs/launchpad/bug-664828-teammembership-delete-timeout into lp:launchpad
- bug-664828-teammembership-delete-timeout
- Merge into devel
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 | ||||
Related bugs: |
|
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/
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/
lib/
Minor edits:
lib/
lib/
Tests
-----
./bin/test -vv -t 'doc/teammember
Demo and Q/A
------------
* Open http://
Lint
----
No lint.
Robert Collins (lifeless) wrote : | # |
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.
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
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
Edwin Grubbs (edwin-grubbs) wrote : | # |
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
> 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/
Incremental diff:
=== modified file 'lib/lp/
--- lib/lp/
+++ lib/lp/
@@ -32,6 +32,25 @@
def test_deactivate
+ # 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.
form = {
@@ -41,9 +60,10 @@
}
membership = self.membership
+ view = create_view(
+ membership, "+index", method='POST', form=form)
with StormStatementR
- view = create_
- self...
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
Preview Diff
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) |
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?