Merge lp:~sinzui/launchpad/deactivate-all-members-fix-0 into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Merged at revision: 12620
Proposed branch: lp:~sinzui/launchpad/deactivate-all-members-fix-0
Merge into: lp:launchpad
Diff against target: 1667 lines (+361/-321)
12 files modified
lib/lp/registry/browser/peoplemerge.py (+4/-1)
lib/lp/registry/configure.zcml (+0/-3)
lib/lp/registry/doc/person-merge.txt (+105/-62)
lib/lp/registry/doc/teammembership.txt (+2/-1)
lib/lp/registry/doc/vocabularies.txt (+160/-91)
lib/lp/registry/interfaces/person.py (+1/-8)
lib/lp/registry/interfaces/teammembership.py (+10/-0)
lib/lp/registry/model/person.py (+1/-87)
lib/lp/registry/model/teammembership.py (+28/-0)
lib/lp/registry/tests/test_person.py (+4/-2)
lib/lp/registry/tests/test_team.py (+0/-65)
lib/lp/registry/tests/test_teammembership.py (+46/-1)
To merge this branch: bzr merge lp:~sinzui/launchpad/deactivate-all-members-fix-0
Reviewer Review Type Date Requested Status
j.c.sackett (community) Approve
Review via email: mp+53885@code.launchpad.net

Description of the change

deactivateAllMembers() must use TM._cleanTeamParticipation.

    Launchpad bug:
        https://bugs.launchpad.net/bugs/733881
    Pre-implementation: jcsackett
    Test command: ./bin/test -vv \
      -t team_membership -t registry/tests/test_person \
      -t registry/tests/test_team -t doc/person-merge -t peoplemerge \t
      -t doc/teammembership -t doc/vocabularies

Oopses like "AssertionError: sinzui is an indirect member of ubuntu-
translations-coordinators but sinzui is not a participant in any direct member
of ubuntu-translations-coordinators" was caused by a deletion on team. All
the users in the delete team were members of ~rosetta-admins via another
team. The TeamParticipation entries for ~rosetta-admins should not have been
deleted.

Person.deactivateAllMembers() was called many steps before merge was called
to ensure there were no TP entries for the team. This method does a simple
delete of TP based on the active membership. It does not recursively check if
the user has an other path to participation in a team via another team
membership.

TeamMembership._cleanTeamParticipation does does build a exclude list of
participation entries. The fix might be to replace the custom query with a
call to TM._cleanTeamParticipation(child, parent). I see store.invalidate() is
called twice; I think once will suffice. The final storm call to remove the
direct participants may not be needed either.

--------------------------------------------------------------------

RULES

    * Remove the redundant call to store.invalidate().
    * Replace the inline sql with a call to TM._cleanTeamParticipation
    * Remove the code to that deals with the direct participants if
      the master function does it.
    * ADDENDUM: Move this method to ITeamMembershipSet to encourage the
      one true way to manange TeamParticipation

QA

    * On staging or QA, staging, delete ~launchpad-chr.
    * Verify that ~sinzui/+participation still works.

LINT

    lib/lp/registry/configure.zcml
    lib/lp/registry/browser/peoplemerge.py
    lib/lp/registry/doc/person-merge.txt
    lib/lp/registry/doc/teammembership.txt
    lib/lp/registry/doc/vocabularies.txt
    lib/lp/registry/interfaces/person.py
    lib/lp/registry/interfaces/teammembership.py
    lib/lp/registry/model/person.py
    lib/lp/registry/model/teammembership.py
    lib/lp/registry/tests/test_person.py
    lib/lp/registry/tests/test_team.py
    lib/lp/registry/tests/test_teammembership.py
^ lint does not like some of the doctests. I can clean them up before
landing this branch.

IMPLEMENTATION

Added a test to verify the expected behaviour of mass member deactivation.
Refactored deactivateAllMembers to use TM._cleanTeamParticipation, which
allowed me to delete 2/3 of the method. The storm bug mentioned in the
comment was fixed a long time ago, so I fixed the enums. I then moved this
method to TMSet.deactivateActiveMemberships to keep the TeamParticipation
rules in one module. The existing tests from duplicated the existing
tests, but there was no test to verify that the comment and user were
saved with the mass deactivation. I revised one of the old tests to verify
that deactivateActiveMemberships stores the right information.
    lib/lp/registry/interfaces/teammembership.py
    lib/lp/registry/model/teammembership.py
    lib/lp/registry/tests/test_teammembership.py
    lib/lp/registry/tests/test_team.py

Updated call sites to use TMSet.deactivateActiveMemberships. Removed
the old method which also permited the removal of an interface, and the
deletion of an duplicate tests.
    lib/lp/registry/configure.zcml
    lib/lp/registry/browser/peoplemerge.py
    lib/lp/registry/doc/person-merge.txt
    lib/lp/registry/doc/teammembership.txt
    lib/lp/registry/doc/vocabularies.txt
    lib/lp/registry/interfaces/person.py
    lib/lp/registry/model/person.py
    lib/lp/registry/tests/test_person.py

To post a comment you must log in.
Revision history for this message
j.c.sackett (jcsackett) wrote :

This looks good to land. Moving the deactivation code into TeamMembership is a big win, and I like how much simpler this makes a fairly complicated process.

Thanks, Curtis.

review: Approve

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 2011-02-16 17:31:11 +0000
3+++ lib/lp/registry/browser/peoplemerge.py 2011-03-17 20:19:01 +0000
4@@ -47,6 +47,7 @@
5 IPersonSet,
6 IRequestPeopleMerge,
7 )
8+from lp.registry.interfaces.teammembership import ITeamMembershipSet
9 from lp.services.propertycache import cachedproperty
10 from lp.soyuz.enums import ArchiveStatus
11 from lp.soyuz.interfaces.archive import IArchiveSet
12@@ -317,7 +318,9 @@
13 'issues with this change.'
14 % (self.target_person.unique_displayname,
15 canonical_url(self.target_person)))
16- self.dupe_person.deactivateAllMembers(comment, self.user)
17+ membershipset = getUtility(ITeamMembershipSet)
18+ membershipset.deactivateActiveMemberships(
19+ self.dupe_person, comment, self.user)
20 flush_database_updates()
21 self.doMerge(data)
22
23
24=== modified file 'lib/lp/registry/configure.zcml'
25--- lib/lp/registry/configure.zcml 2011-03-10 19:11:04 +0000
26+++ lib/lp/registry/configure.zcml 2011-03-17 20:19:01 +0000
27@@ -893,9 +893,6 @@
28 permission="launchpad.Moderate"
29 set_attributes="name"/>
30 <require
31- permission="launchpad.Moderate"
32- interface="lp.registry.interfaces.person.IPersonModerate" />
33- <require
34 permission="launchpad.Commercial"
35 set_schema="lp.registry.interfaces.person.IPersonCommAdminWriteRestricted"/>
36 <require
37
38=== modified file 'lib/lp/registry/doc/person-merge.txt'
39--- lib/lp/registry/doc/person-merge.txt 2011-01-04 16:08:57 +0000
40+++ lib/lp/registry/doc/person-merge.txt 2011-03-17 20:19:01 +0000
41@@ -1,10 +1,11 @@
42-= Merging =
43+Merging
44+=======
45
46 For many reasons (i.e. a gina run) we could have duplicated accounts in
47-Launchpad. Once a duplicated account is identified, we need to allow the user
48-to merge two accounts into a single one, because both represent the same
49-person and they're there just because each of those was created using a
50-different email address.
51+Launchpad. Once a duplicated account is identified, we need to allow the
52+user to merge two accounts into a single one, because both represent the
53+same person and they're there just because each of those was created
54+using a different email address.
55
56 >>> from zope.component import getUtility
57 >>> from canonical.database.sqlbase import sqlvalues
58@@ -19,7 +20,8 @@
59 >>> marilize = personset.getByName('marilize')
60
61
62-== Sanity checks ==
63+Sanity checks
64+-------------
65
66 We can't merge an account that still has email addresses attached to it
67
68@@ -29,13 +31,15 @@
69 AssertionError: ...
70
71
72-== Preparing test person for the merge ==
73+Preparing test person for the merge
74+-----------------------------------
75
76 Merging people involves updating the merged person relationships. Let's
77 put the person we will merge into some of those.
78
79 # To assign marilize as the ubuntu team owner, we must log on as the
80 # previous owner.
81+
82 >>> login('mark@example.com')
83
84 >>> ubuntu_team = personset.getByName('ubuntu-team')
85@@ -55,20 +59,23 @@
86 >>> saved_marilize_karma_id = marilize_karma.id
87 >>> print marilize_karma.person.name
88 marilize
89+
90 >>> sampleperson_old_karma = sample.karma
91
92-Branches whose owner is being merged are uniquified by appending '-N' where N
93-is a unique integer. We create "peoplemerge" and "peoplemerge-1" branches owned
94-by marilize, and a "peoplemerge" and "peoplemerge-1" branches owned by 'Sample
95-Person' to test that branch name uniquifying works.
96-
97-Branches with smaller IDs will be processed first, so we create "peoplemerge"
98-first, and it will be renamed "peoplemerge-2". The extant "peoplemerge-1"
99-branch will be renamed "peoplemerge-1-1". The "peoplemerge-0" branch will not
100-be renamed since it will not conflict.
101-
102-That is not a particularly sensible way of renaming branches, but it is simple
103-to implement, and it be should extremely rare for the case to occur.
104+Branches whose owner is being merged are uniquified by appending '-N'
105+where N is a unique integer. We create "peoplemerge" and "peoplemerge-1"
106+branches owned by marilize, and a "peoplemerge" and "peoplemerge-1"
107+branches owned by 'Sample Person' to test that branch name uniquifying
108+works.
109+
110+Branches with smaller IDs will be processed first, so we create
111+"peoplemerge" first, and it will be renamed "peoplemerge-2". The extant
112+"peoplemerge-1" branch will be renamed "peoplemerge-1-1". The
113+"peoplemerge-0" branch will not be renamed since it will not conflict.
114+
115+That is not a particularly sensible way of renaming branches, but it is
116+simple to implement, and it be should extremely rare for the case to
117+occur.
118
119 >>> peoplemerge = factory.makePersonalBranch(
120 ... name='peoplemerge', owner=sample)
121@@ -81,12 +88,13 @@
122 >>> peoplemerge11 = factory.makePersonalBranch(
123 ... name='peoplemerge-1', owner=marilize)
124
125-'Sample Person' is a deactivated member of the 'Ubuntu Translators' team,
126-while marilize is an active member. After the merge, 'Sample Person' will be an
127-active member of that team.
128+'Sample Person' is a deactivated member of the 'Ubuntu Translators'
129+team, while marilize is an active member. After the merge, 'Sample
130+Person' will be an active member of that team.
131
132 >>> sample in ubuntu_translators.inactivemembers
133 True
134+
135 >>> marilize in ubuntu_translators.activemembers
136 True
137
138@@ -102,11 +110,13 @@
139 u'Marilize Coetzee'
140
141
142-== Do the merge! ==
143+Do the merge!
144+-------------
145
146 # Now we remove the only email address marilize had, so that we can merge
147 # it. First we need to change its status, though, because we can't delete
148 # a person's preferred email.
149+
150 >>> from canonical.launchpad.interfaces.emailaddress import (
151 ... EmailAddressStatus)
152 >>> from canonical.launchpad.interfaces.lpstorm import IMasterObject
153@@ -119,13 +129,15 @@
154 >>> personset.merge(marilize, sample)
155
156
157-== Merge results ==
158+Merge results
159+-------------
160
161 Check that 'Sample Person' has indeed become an active member of 'Ubuntu
162 Translators'
163
164 >>> sample in ubuntu_translators.activemembers
165 True
166+
167 >>> sample.inTeam(ubuntu_translators)
168 True
169
170@@ -136,27 +148,32 @@
171 >>> sample_junk = get_branch_namespace(sample)
172 >>> sample_junk.getByName('peoplemerge') == peoplemerge
173 True
174+
175 >>> sample_junk.getByName('peoplemerge-0') == peoplemerge0
176 True
177+
178 >>> sample_junk.getByName('peoplemerge-1') == peoplemerge1
179 True
180+
181 >>> sample_junk.getByName('peoplemerge-2') == peoplemerge2
182 True
183+
184 >>> sample_junk.getByName('peoplemerge-1-1') == peoplemerge11
185 True
186
187-The Karma that was previously assigned to marilize is now assigned to name12
188-(Sample Person).
189+The Karma that was previously assigned to marilize is now assigned to
190+name12 (Sample Person).
191
192 >>> from canonical.database.sqlbase import flush_database_caches
193 >>> flush_database_caches()
194 >>> saved_marilize_karma_id == marilize_karma.id
195 True
196+
197 >>> print marilize_karma.person.name
198 name12
199
200-Note that we don't bother migrating karma caches - it will just be reset next
201-time the caches are rebuilt.
202+Note that we don't bother migrating karma caches - it will just be reset
203+next time the caches are rebuilt.
204
205 >>> sample.karma == sampleperson_old_karma
206 True
207@@ -199,8 +216,8 @@
208 >>> results.get_one()[0] is None
209 True
210
211-An email is sent to the user informing him that he should review his email
212-and mailing list subscription settings.
213+An email is sent to the user informing him that he should review his
214+email and mailing list subscription settings.
215
216 >>> from lp.registry.interfaces.personnotification import (
217 ... IPersonNotificationSet)
218@@ -209,11 +226,14 @@
219 >>> notifications = notification_set.getNotificationsToSend()
220 >>> notifications.count()
221 1
222+
223 >>> notification = notifications[0]
224 >>> print notification.person.name
225 name12
226+
227 >>> print notification.subject
228 Launchpad accounts merged
229+
230 >>> print notification.body
231 The Launchpad account named 'marilize-merged' was merged into the account
232 named 'name12'. ...
233@@ -226,13 +246,13 @@
234 Person decoration
235 -----------------
236
237-Several tables "extend" the Person table by having additional information
238-that is UNIQUEly keyed to Person.id. We have a utility function that merges
239-information in those tables, we test it here.
240+Several tables "extend" the Person table by having additional
241+information that is UNIQUEly keyed to Person.id. We have a utility
242+function that merges information in those tables, we test it here.
243
244-We will use PersonLocation as an example. There are many permutations and
245-combinations, we will exercise them all, and in each case we'll create, and
246-then delete, the needed two people.
247+We will use PersonLocation as an example. There are many permutations
248+and combinations, we will exercise them all, and in each case we'll
249+create, and then delete, the needed two people.
250
251 >>> from lp.registry.model.person import PersonSet, Person
252 >>> from lp.registry.interfaces.person import PersonCreationRationale
253@@ -272,11 +292,12 @@
254 >>> winner, loser = endless_supply_of_players.next()
255 >>> print decorator_refs(store, winner, loser)
256 <BLANKLINE>
257+
258 >>> personset._merge_person_decoration(winner, loser, skip,
259 ... 'PersonLocation', 'person', ['last_modified_by',])
260
261-"Skip" should have been updated with the table and unique reference column
262-name.
263+"Skip" should have been updated with the table and unique reference
264+column name.
265
266 >>> print skip
267 [('personlocation', 'person')]
268@@ -286,42 +307,44 @@
269 >>> print decorator_refs(store, winner, loser)
270 <BLANKLINE>
271
272-OK, now, this time, we will add some decorator information to the winner but
273-not the loser.
274+OK, now, this time, we will add some decorator information to the winner
275+but not the loser.
276
277 >>> winner, loser = endless_supply_of_players.next()
278 >>> winner.setLocation(None, None, 'America/Santiago', winner)
279 >>> print decorator_refs(store, winner, loser)
280 winner, winner,
281+
282 >>> personset._merge_person_decoration(winner, loser, skip,
283 ... 'PersonLocation', 'person', ['last_modified_by',])
284
285-There should now still be one decorator, with all columns pointing to the
286-winner:
287+There should now still be one decorator, with all columns pointing to
288+the winner:
289
290 >>> print decorator_refs(store, winner, loser)
291 winner, winner,
292
293-This time, we will have a decorator for the person that is being merged INTO
294-another person, but nothing on the target person.
295+This time, we will have a decorator for the person that is being merged
296+INTO another person, but nothing on the target person.
297
298 >>> winner, loser = endless_supply_of_players.next()
299 >>> loser.setLocation(None, None, 'America/Santiago', loser)
300 >>> print decorator_refs(store, winner, loser)
301 loser, loser,
302+
303 >>> personset._merge_person_decoration(winner, loser, skip,
304 ... 'PersonLocation', 'person', ['last_modified_by',])
305
306-There should now still be one decorator, with all columns pointing to the
307-winner:
308+There should now still be one decorator, with all columns pointing to
309+the winner:
310
311 >>> print decorator_refs(store, winner, loser)
312 winner, winner,
313
314 Now, we want to show what happens when there is a decorator for both the
315-to_person and the from_person. We expect that the from_person record will
316-remain as noise but non-unique columns will have been updated to point to
317-the winner, and the to_person will be unaffected.
318+to_person and the from_person. We expect that the from_person record
319+will remain as noise but non-unique columns will have been updated to
320+point to the winner, and the to_person will be unaffected.
321
322 >>> winner, loser = endless_supply_of_players.next()
323 >>> winner.setLocation(None, None, 'America/Santiago', winner)
324@@ -329,6 +352,7 @@
325 >>> print decorator_refs(store, winner, loser)
326 winner, winner,
327 loser, loser,
328+
329 >>> personset._merge_person_decoration(winner, loser, skip,
330 ... 'PersonLocation', 'person', ['last_modified_by',])
331 >>> print decorator_refs(store, winner, loser)
332@@ -336,12 +360,13 @@
333 loser, winner,
334
335
336-== Merging teams ==
337+Merging teams
338+-------------
339
340 Merging of teams is also possible and uses the same API used for merging
341 people. Note, though, that when merging teams, its polls will not be
342-carried over to the remaining team. Team memberships, on the other hand,
343-are carried over just like when merging people.
344+carried over to the remaining team. Team memberships, on the other
345+hand, are carried over just like when merging people.
346
347 >>> from datetime import datetime, timedelta
348 >>> import pytz
349@@ -357,37 +382,45 @@
350 ... PollSecrecy.OPEN, allowspoilt=True)
351
352 # test_team has a superteam, one active member and a poll.
353+
354 >>> [team.name for team in test_team.super_teams]
355 [u'launchpad']
356+
357 >>> test_team.teamowner.name
358 u'name12'
359+
360 >>> [member.name for member in test_team.allmembers]
361 [u'name12']
362+
363 >>> list(IPollSubset(test_team).getAll())
364 [<Poll at ...]
365
366 # Landscape-developers has no super teams, two members and no polls.
367+
368 >>> landscape = personset.getByName('landscape-developers')
369 >>> [team.name for team in landscape.super_teams]
370 []
371+
372 >>> landscape.teamowner.name
373 u'name12'
374+
375 >>> [member.name for member in landscape.allmembers]
376 [u'salgado', u'name12']
377+
378 >>> list(IPollSubset(landscape).getAll())
379 []
380
381-Now we try to merge them, but since test_team has active members it can't be
382-merged.
383+Now we try to merge them, but since test_team has active members it
384+can't be merged.
385
386 >>> personset.merge(test_team, landscape)
387 Traceback (most recent call last):
388 ...
389 AssertionError: Only teams without active members can be merged
390
391-A team with a mailing list can only be merged if has no mailing list or if the
392-mailing list is in the PURGED state. This state indicates that there are no
393-artifacts of the list on the Mailman side.
394+A team with a mailing list can only be merged if has no mailing list or
395+if the mailing list is in the PURGED state. This state indicates that
396+there are no artifacts of the list on the Mailman side.
397
398 >>> from lp.registry.interfaces.mailinglist import IMailingListSet
399 >>> mailing_list = getUtility(IMailingListSet).new(landscape)
400@@ -395,21 +428,23 @@
401 >>> mailman.act()
402 >>> print mailing_list.status.name
403 ACTIVE
404+
405 >>> ubuntu_team = personset.getByName('ubuntu-team')
406 >>> personset.merge(landscape, ubuntu_team)
407 Traceback (most recent call last):
408 ...
409 AssertionError: Can't merge teams which have mailing lists...
410
411-Purging the associated mailing list doesn't delete it, but puts it into a
412-state that essentially treats it as nonexistent. It still can't be merged
413-until all the members are deactivated though.
414+Purging the associated mailing list doesn't delete it, but puts it into
415+a state that essentially treats it as nonexistent. It still can't be
416+merged until all the members are deactivated though.
417
418 >>> mailing_list.deactivate()
419 >>> mailman.act()
420 >>> mailing_list.purge()
421 >>> print mailing_list.status.name
422 PURGED
423+
424 >>> personset.merge(landscape, ubuntu_team)
425 Traceback (most recent call last):
426 ...
427@@ -418,26 +453,34 @@
428 For cases like this we have an API which takes care of deactivating all
429 active members of the team so that we can perform the merge.
430
431+ >>> from lp.registry.interfaces.teammembership import ITeamMembershipSet
432+
433 # Only admins can use that API, though, so we need to login as an admin.
434+
435 >>> login('foo.bar@canonical.com')
436
437 >>> comment = "We're merging this team into another one."
438- >>> test_team.deactivateAllMembers(comment, personset.getByName('name16'))
439+ >>> membershipset = getUtility(ITeamMembershipSet)
440+ >>> membershipset.deactivateActiveMemberships(
441+ ... test_team, comment, personset.getByName('name16'))
442 >>> for team in test_team.super_teams:
443 ... test_team.retractTeamMembership(team, test_team.teamowner)
444
445-
446 >>> personset.merge(test_team, landscape)
447
448 # The resulting Landscape-developers no new super teams, has
449 # no polls and its members are still the same two from before the
450 # merge.
451+
452 >>> landscape.teamowner.name
453 u'name12'
454+
455 >>> [member.name for member in landscape.allmembers]
456 [u'salgado', u'name12']
457+
458 >>> [team.name for team in landscape.super_teams]
459 []
460+
461 >>> list(IPollSubset(landscape).getAll())
462 []
463
464
465=== modified file 'lib/lp/registry/doc/teammembership.txt'
466--- lib/lp/registry/doc/teammembership.txt 2011-02-28 23:25:52 +0000
467+++ lib/lp/registry/doc/teammembership.txt 2011-03-17 20:19:01 +0000
468@@ -895,7 +895,8 @@
469 If a team is merged it will not show up in the set of administered teams.
470
471 >>> login('foo.bar@canonical.com')
472- >>> cprov_team.deactivateAllMembers("Merging", foobar)
473+ >>> membershipset.deactivateActiveMemberships(
474+ ... cprov_team, "Merging", foobar)
475 >>> personset.merge(cprov_team, guadamen_team)
476 >>> [team.name for team in cprov.getAdministratedTeams()]
477 [u'canonical-partner-dev', u'guadamen', u'launchpad-buildd-admins']
478
479=== modified file 'lib/lp/registry/doc/vocabularies.txt'
480--- lib/lp/registry/doc/vocabularies.txt 2010-12-21 21:59:19 +0000
481+++ lib/lp/registry/doc/vocabularies.txt 2011-03-17 20:19:01 +0000
482@@ -1,4 +1,5 @@
483-= Registry vocabularies =
484+Registry vocabularies
485+=====================
486
487 >>> from canonical.database.sqlbase import flush_database_updates
488 >>> from canonical.launchpad.webapp.interfaces import IOpenLaunchBag
489@@ -21,16 +22,18 @@
490 >>> product_vocabulary = get_naked_vocab(None, "Product")
491
492
493-== ActiveMailingList ==
494+ActiveMailingList
495+-----------------
496
497-The active mailing lists vocabulary matches and returns only those mailing
498-lists which are active.
499+The active mailing lists vocabulary matches and returns only those
500+mailing lists which are active.
501
502 >>> list_vocabulary = get_naked_vocab(None, 'ActiveMailingList')
503 >>> from canonical.launchpad.webapp.testing import verifyObject
504 >>> from canonical.launchpad.webapp.vocabulary import IHugeVocabulary
505 >>> verifyObject(IHugeVocabulary, list_vocabulary)
506 True
507+
508 >>> list_vocabulary.displayname
509 'Select an active mailing list.'
510
511@@ -38,17 +41,15 @@
512
513 >>> list(list_vocabulary)
514 []
515+
516 >>> len(list_vocabulary)
517 0
518+
519 >>> list(list_vocabulary.search())
520 []
521
522 Mailing lists are not active when they are first registered.
523
524- >>> # These are the convoluted steps to create some mailing lists. We
525- >>> # can't use the shortcuts that other related tests use because those
526- >>> # leave the list in the ACTIVE state and we want to check things
527- >>> # before they get to that state.
528 >>> personset = getUtility(IPersonSet)
529 >>> ddaa = personset.getByName('ddaa')
530 >>> carlos = personset.getByName('carlos')
531@@ -70,13 +71,15 @@
532 >>> list_three = listset.new(team_three)
533 >>> list(list_vocabulary)
534 []
535+
536 >>> len(list_vocabulary)
537 0
538+
539 >>> list(list_vocabulary.search())
540 []
541
542-Mailing lists become active once they have been constructed by Mailman (which
543-indicates so by transitioning the state to ACTIVE).
544+Mailing lists become active once they have been constructed by Mailman
545+(which indicates so by transitioning the state to ACTIVE).
546
547 >>> list_one.startConstructing()
548 >>> list_two.startConstructing()
549@@ -88,11 +91,12 @@
550 >>> sorted(mailing_list.team.displayname
551 ... for mailing_list in list_vocabulary)
552 [u'Bass Players', u'Drummers', u'Guitar Players']
553+
554 >>> len(list_vocabulary)
555 3
556
557-Searching for active lists is done through the vocabulary as well. With a
558-search term of None, all active lists are returned.
559+Searching for active lists is done through the vocabulary as well. With
560+a search term of None, all active lists are returned.
561
562 >>> sorted(mailing_list.team.displayname
563 ... for mailing_list in list_vocabulary.search(None))
564@@ -104,27 +108,31 @@
565 ... for mailing_list in list_vocabulary.search('player'))
566 [u'Bass Players', u'Guitar Players']
567
568-The IHugeVocabulary interface also requires a search method that returns a
569-CountableIterator.
570+The IHugeVocabulary interface also requires a search method that returns
571+a CountableIterator.
572
573 >>> iter = list_vocabulary.searchForTerms('player')
574 >>> from canonical.launchpad.webapp.vocabulary import CountableIterator
575 >>> isinstance(iter, CountableIterator)
576 True
577+
578 >>> sorted((term.value.team.name, term.token, term.title)
579 ... for term in iter)
580 [(u'bass-players', 'bass-players', u'Bass Players'),
581 (u'guitar-players', 'guitar-players', u'Guitar Players')]
582
583-The vocabulary supports accessing mailing lists by 'term', where the term must
584-be a mailing list. The returned term's value is the mailing list object, the
585-token is the team name and the title is the team's display name.
586+The vocabulary supports accessing mailing lists by 'term', where the
587+term must be a mailing list. The returned term's value is the mailing
588+list object, the token is the team name and the title is the team's
589+display name.
590
591 >>> term_1 = list_vocabulary.getTerm(list_two)
592 >>> term_1.value.team.displayname
593 u'Guitar Players'
594+
595 >>> term_1.token
596 'guitar-players'
597+
598 >>> term_1.title
599 u'Guitar Players'
600
601@@ -140,13 +148,14 @@
602 >>> term_2 = list_vocabulary.getTermByToken(term_1.token)
603 >>> term_2.value.team.displayname
604 u'Guitar Players'
605+
606 >>> term_3 = list_vocabulary.getTerm(list_one)
607 >>> term_4 = list_vocabulary.getTermByToken(term_3.token)
608 >>> term_4.value.team.displayname
609 u'Bass Players'
610
611-If you try to get the term by a token not represented in the vocabulary, you
612-get an exception.
613+If you try to get the term by a token not represented in the vocabulary,
614+you get an exception.
615
616 >>> list_vocabulary.getTermByToken('turntablists')
617 Traceback (most recent call last):
618@@ -158,8 +167,8 @@
619 >>> list_three in list_vocabulary
620 True
621
622-You are not allowed to ask whether a non-mailing list object is contained in
623-this vocabulary.
624+You are not allowed to ask whether a non-mailing list object is
625+contained in this vocabulary.
626
627 >>> team_three in list_vocabulary
628 Traceback (most recent call last):
629@@ -179,11 +188,13 @@
630
631 >>> list(list_vocabulary.search('flautists'))
632 []
633+
634 >>> list(list_vocabulary.search('cellists'))
635 []
636
637
638-=== DistroSeriesVocabulary ===
639+DistroSeriesVocabulary
640+......................
641
642 Reflects the available distribution series. Results are ordered by
643 `name`
644@@ -227,7 +238,8 @@
645 []
646
647
648-=== PersonActiveMembership ===
649+PersonActiveMembership
650+......................
651
652 All the teams the person is an active member of.
653
654@@ -236,6 +248,7 @@
655 ... foo_bar, 'PersonActiveMembership')
656 >>> len(person_active_membership)
657 10
658+
659 >>> for term in person_active_membership:
660 ... print term.token, term.value.displayname, term.title
661 canonical-partner-dev Canonical Partner Developers
662@@ -253,6 +266,7 @@
663 >>> launchpad_team = person_set.getByName('launchpad')
664 >>> launchpad_team in person_active_membership
665 True
666+
667 >>> mirrors_admins = person_set.getByName('mirrors-admins')
668 >>> mirrors_admins in person_active_membership
669 False
670@@ -313,7 +327,8 @@
671 LookupError:...
672
673
674-=== PersonTeamParticipations ===
675+PersonTeamParticipations
676+........................
677
678 This vocabulary contains all the teams a person participates in. Either
679 through direct or indirect participations.
680@@ -322,6 +337,7 @@
681 >>> [membership.team.name
682 ... for membership in sample_person.team_memberships]
683 [u'hwdb-team', u'landscape-developers', u'launchpad-users', u'name20']
684+
685 >>> [team.name for team in sample_person.teams_participated_in]
686 [u'hwdb-team', u'landscape-developers', u'launchpad-users', u'name18',
687 u'name20']
688@@ -338,16 +354,17 @@
689 name20: Warty Security Team (name20)
690
691
692-=== Milestone ===
693+Milestone
694+.........
695
696 All the milestone in a context.
697
698 A MilestoneVolcabulary contains different milestones, depending on the
699 current context. It is pointless to present the large number of all
700 active milestones known in Launchpad in a vocabulary. Hence a
701-MilestoneVolcabulary contains only those milestones that are related
702-to the current context. If no context is given, or if the context does
703-not have any milestones, a MilestoneVocabulary is empty...
704+MilestoneVolcabulary contains only those milestones that are related to
705+the current context. If no context is given, or if the context does not
706+have any milestones, a MilestoneVocabulary is empty...
707
708 >>> milestones = get_naked_vocab(None, 'Milestone')
709 >>> len(milestones)
710@@ -359,14 +376,15 @@
711 >>> len(milestones)
712 0
713
714-...but if the context is an IPerson, the MilestoneVocabulary contains all
715-milestones. IPerson related pages showing milestone lists retrieve the
716-milestones from RelevantMilestonesMixin.getMilestoneWidgetValues()
717+...but if the context is an IPerson, the MilestoneVocabulary contains
718+all milestones. IPerson related pages showing milestone lists retrieve
719+the milestones from RelevantMilestonesMixin.getMilestoneWidgetValues()
720 but we need the big default vocabulary for form input validation.
721
722 >>> all_milestones = get_naked_vocab(sample_person, 'Milestone')
723 >>> len(all_milestones)
724 3
725+
726 >>> for term in all_milestones:
727 ... print "%s: %s" % (term.value.target.name, term.value.name)
728 debian: 3.1
729@@ -403,6 +421,7 @@
730 >>> firefox_task = bug_one.bugtasks[0]
731 >>> firefox_task.bugtargetdisplayname
732 u'Mozilla Firefox'
733+
734 >>> firefox_task_milestones = get_naked_vocab(
735 ... firefox_task, 'Milestone')
736 >>> for term in firefox_task_milestones:
737@@ -414,14 +433,15 @@
738 >>> debian_woody_task = bug_two.bugtasks[-1]
739 >>> debian_woody_task.bugtargetdisplayname
740 u'mozilla-firefox (Debian Woody)'
741+
742 >>> debian_woody_milestones = get_naked_vocab(
743 ... debian_woody_task, 'Milestone')
744 >>> debian_woody = debian_woody_task.distroseries
745 >>> len(debian_woody_milestones)
746 2
747
748-If one of the milestones is disabled, it won't be included in the vocabulary
749-anymore.
750+If one of the milestones is disabled, it won't be included in the
751+vocabulary anymore.
752
753 >>> milestone = debian_woody.milestones[0]
754 >>> milestone.active = False
755@@ -429,8 +449,9 @@
756 >>> len(get_naked_vocab(debian_woody_task, 'Milestone'))
757 1
758
759-If the milestone was used in a bugtask before it was marked inactive, though,
760-it'll still show up on the vocabulary so that users can change it.
761+If the milestone was used in a bugtask before it was marked inactive,
762+though, it'll still show up on the vocabulary so that users can change
763+it.
764
765 >>> debian_woody_task.milestone = milestone
766 >>> flush_database_updates()
767@@ -466,8 +487,8 @@
768 firefox: 1.0
769 firefox: firefox-milestone-no-series
770
771-If the context is a specification, only milestones from that specification
772-target are in the vocabulary.
773+If the context is a specification, only milestones from that
774+specification target are in the vocabulary.
775
776 >>> canvas_spec = firefox.getSpecification('canvas')
777 >>> spec_target_milestones = get_naked_vocab(
778@@ -485,18 +506,21 @@
779 >>> one_dot_o = firefox.milestones[0]
780 >>> one_dot_o.name
781 u'1.0'
782+
783 >>> one_dot_o.active = False
784
785 >>> firefox_milestones = get_naked_vocab(firefox, 'Milestone')
786 >>> len(firefox_milestones)
787 0
788+
789 >>> firefox_task_milestones = get_naked_vocab(
790 ... firefox_task, 'Milestone')
791 >>> len(firefox_task_milestones)
792 0
793
794
795-=== ProjectProductsVocabulary ===
796+ProjectProductsVocabulary
797+.........................
798
799 All the products in a project.
800
801@@ -510,7 +534,8 @@
802 thunderbird: Mozilla Thunderbird
803
804
805-=== ProjectGroupVocabulary ===
806+ProjectGroupVocabulary
807+......................
808
809 The list of selectable projects. The results are ordered by displayname.
810
811@@ -520,6 +545,7 @@
812
813 >>> [p.title for p in project_vocabulary.search('mozilla')]
814 [u'The Mozilla Project']
815+
816 >>> mozilla = project_vocabulary.getTermByToken('mozilla')
817 >>> mozilla.title
818 u'The Mozilla Project'
819@@ -533,6 +559,7 @@
820
821 >>> [p.title for p in project_vocabulary.search('mozilla')]
822 [u'The Mozilla Project']
823+
824 >>> moz_project.active = False
825 >>> flush_database_updates()
826 >>> moz_project in project_vocabulary
827@@ -540,11 +567,13 @@
828
829 >>> [p.title for p in project_vocabulary.search('mozilla')]
830 []
831+
832 >>> moz_project.active = True
833 >>> flush_database_updates()
834
835
836-=== ProductReleaseVocabulary ===
837+ProductReleaseVocabulary
838+........................
839
840 The list of selectable products releases.
841
842@@ -554,6 +583,7 @@
843
844 >>> list(productrelease_vocabulary.search(None))
845 []
846+
847 >>> evolution_releases = productrelease_vocabulary.search("evolution")
848 >>> l = [release_term.title for release_term in evolution_releases]
849 >>> release = productrelease_vocabulary.getTermByToken(
850@@ -562,10 +592,11 @@
851 u'evolution trunk 2.1.6'
852
853
854-=== PersonAccountToMergeVocabulary ===
855+PersonAccountToMergeVocabulary
856+..............................
857
858-All non-merged people with at least one email address. This vocabulary is
859-meant to be used only in the people merge form.
860+All non-merged people with at least one email address. This vocabulary
861+is meant to be used only in the people merge form.
862
863 >>> vocab = get_naked_vocab(None, "PersonAccountToMerge")
864 >>> vocab.displayname
865@@ -576,8 +607,8 @@
866 >>> list(vocab.search(None))
867 []
868
869-Searching for 'Launchpad Administrators' will return an empty list, because
870-teams are not part of this vocabulary.
871+Searching for 'Launchpad Administrators' will return an empty list,
872+because teams are not part of this vocabulary.
873
874 >>> [item.name for item in list(vocab.search('Launchpad Administrators'))]
875 []
876@@ -632,6 +663,7 @@
877 True
878
879 # Here we cheat because IPerson.merged is a readonly attribute.
880+
881 >>> naked_cprov = removeSecurityProxy(cprov)
882 >>> naked_cprov.merged = 1
883 >>> naked_cprov.syncUpdate()
884@@ -653,7 +685,8 @@
885 True
886
887
888-== AdminMergeablePerson ==
889+AdminMergeablePerson
890+--------------------
891
892 The set of non-merged people.
893
894@@ -661,18 +694,21 @@
895 >>> vocab.displayname
896 'Select a Person to Merge'
897
898-Unlike PersonAccountToMerge, this vocabulary includes people who don't have a
899-single email address, as it's fine for admins to merge them.
900+Unlike PersonAccountToMerge, this vocabulary includes people who don't
901+have a single email address, as it's fine for admins to merge them.
902
903 >>> print fooperson.preferredemail
904 None
905+
906 >>> list(fooperson.validatedemails) + list(fooperson.guessedemails)
907 []
908+
909 >>> fooperson in vocab
910 True
911
912
913-=== NonMergedPeopleAndTeams ===
914+NonMergedPeopleAndTeams
915+.......................
916
917 All non-merged people and teams.
918
919@@ -683,8 +719,8 @@
920 >>> list(vocab.search(None))
921 []
922
923-This vocabulary includes both validated and unvalidated profiles, as well
924-as teams:
925+This vocabulary includes both validated and unvalidated profiles, as
926+well as teams:
927
928 >>> [(p.name, p.is_valid_person) for p in vocab.search('matsubara')]
929 [(u'matsubara', False)]
930@@ -704,12 +740,13 @@
931 False
932
933
934-=== ValidPersonOrTeam ===
935+ValidPersonOrTeam
936+.................
937
938 All 'valid' persons or teams. This is currently defined as people with a
939 password, a preferred email address and not merged (Person.merged is
940-None). It also includes all public teams and private teams the
941-user has permission to view.
942+None). It also includes all public teams and private teams the user has
943+permission to view.
944
945 >>> vocab = get_naked_vocab(None, "ValidPersonOrTeam")
946 >>> vocab.displayname
947@@ -723,11 +760,12 @@
948
949 >>> vocab.getTermByToken('name16').value.displayname
950 u'Foo Bar'
951+
952 >>> vocab.getTermByToken('foo.bar@canonical.com').value.displayname
953 u'Foo Bar'
954
955-Almost all teams have the word 'team' as part of their names, so a search
956-for 'team' should give us some of them. Notice that the
957+Almost all teams have the word 'team' as part of their names, so a
958+search for 'team' should give us some of them. Notice that the
959 PRIVATE_TEAM 'myteam' is not included in the results.
960
961 >>> login_person(sample_person)
962@@ -740,8 +778,11 @@
963
964 Valid teams do not include teams that have been merged.
965
966+ >>> from lp.registry.interfaces.teammembership import ITeamMembershipSet
967 >>> login_person(foo_bar)
968- >>> ephemeral.deactivateAllMembers("Merging", foo_bar)
969+ >>> membershipset = getUtility(ITeamMembershipSet)
970+ >>> membershipset.deactivateActiveMemberships(
971+ ... ephemeral, "Merging", foo_bar)
972 >>> person_set.merge(ephemeral, foo_bar)
973 >>> login_person(sample_person)
974 >>> sorted(person.name for person in vocab.search('team'))
975@@ -750,7 +791,8 @@
976 u'testing-spanish-team', u'ubuntu-security', u'ubuntu-team',
977 u'warty-gnome']
978
979-A PRIVATE team is displayed when the logged in user is a member of the team.
980+A PRIVATE team is displayed when the logged in user is a member of the
981+team.
982
983 >>> commercial = person_set.getByEmail('commercial-member@canonical.com')
984 >>> vocab = get_naked_vocab(commercial, "ValidPersonOrTeam")
985@@ -776,7 +818,8 @@
986 u'testing-spanish-team', u'ubuntu-security', u'ubuntu-team',
987 u'warty-gnome']
988
989-The PRIVATE team can be looked up via getTermByToken for a member of the team.
990+The PRIVATE team can be looked up via getTermByToken for a member of the
991+team.
992
993 >>> term = vocab.getTermByToken('private-team')
994 >>> print term.title
995@@ -818,15 +861,16 @@
996 [...u'private-team'...]
997
998 A search for 'support' will give us only the persons which have support
999-as part of their name or displayname, or the beginning of
1000-one of its email addresses.
1001+as part of their name or displayname, or the beginning of one of its
1002+email addresses.
1003
1004 >>> login('foo.bar@canonical.com')
1005 >>> vocab = get_naked_vocab(None, "ValidPersonOrTeam")
1006 >>> sorted(person.name for person in vocab.search('support'))
1007 [u'ubuntu-team']
1008
1009-Matsubara doesn't have a preferred email address; he's not a valid Person.
1010+Matsubara doesn't have a preferred email address; he's not a valid
1011+Person.
1012
1013 >>> sorted(person.name for person in vocab.search('matsubara'))
1014 []
1015@@ -841,13 +885,14 @@
1016 >>> [cjwatson] = vocab.search('cjwatson')
1017 >>> cjwatson.name, cjwatson.preferredemail.email
1018 (u'kamion', u'colin.watson@ubuntulinux.com')
1019+
1020 >>> [ircid.nickname for ircid in cjwatson.ircnicknames]
1021 [u'cjwatson']
1022
1023-Since there are so many people and teams a vocabulary that includes
1024-them all is not very useful when displaying in the user interface. So
1025-we limit the number of results. The results are ordered by
1026-displayname and the first set of those are the ones returned
1027+Since there are so many people and teams a vocabulary that includes them
1028+all is not very useful when displaying in the user interface. So we
1029+limit the number of results. The results are ordered by displayname and
1030+the first set of those are the ones returned
1031
1032 >>> login(ANONYMOUS)
1033 >>> [person.displayname for person in vocab.search('team')]
1034@@ -859,10 +904,12 @@
1035 >>> login(ANONYMOUS)
1036 >>> vocab.LIMIT
1037 100
1038+
1039 >>> vocab.LIMIT = 4
1040
1041 # The limit gets applied in multiple subselects, so the result
1042 # can actually be less than the limit.
1043+
1044 >>> [person.displayname for person in vocab.search('team')]
1045 [u'HWDB Team', u'No Team Memberships', u'testing Spanish team']
1046
1047@@ -910,15 +957,17 @@
1048 u'ubuntu-team', u'warty-gnome']
1049
1050
1051-=== ValidTeam ===
1052+ValidTeam
1053+.........
1054
1055 The valid team vocabulary is just like the ValidPersonOrTeam vocabulary,
1056-except that its terms are limited only to teams. No non-team Persons will be
1057-returned.
1058+except that its terms are limited only to teams. No non-team Persons
1059+will be returned.
1060
1061 >>> vocab = get_naked_vocab(None, 'ValidTeam')
1062 >>> vocab.displayname
1063 'Select a Team'
1064+
1065 >>> sorted((team.displayname, team.teamowner.displayname)
1066 ... for team in vocab.search(None))
1067 [(u'Bass Players', u'David Allouche'),
1068@@ -956,8 +1005,9 @@
1069 (u'Warty Security Team', u'Mark Shuttleworth'),
1070 (u'testing Spanish team', u'Carlos Perell\xf3 Mar\xedn')]
1071
1072-Like with ValidPersonOrTeam, you can narrow your search down by providing some
1073-text to match against the team name. Still, you only get teams back.
1074+Like with ValidPersonOrTeam, you can narrow your search down by
1075+providing some text to match against the team name. Still, you only get
1076+teams back.
1077
1078 >>> sorted((team.displayname, team.teamowner.displayname)
1079 ... for team in vocab.search('spanish'))
1080@@ -987,7 +1037,8 @@
1081 (u'Warty Security Team', u'Mark Shuttleworth'),
1082 (u'testing Spanish team', u'Carlos Perell\xf3 Mar\xedn')]
1083
1084-A user who is a member of a private team will see that team in his search.
1085+A user who is a member of a private team will see that team in his
1086+search.
1087
1088 >>> login('commercial-member@canonical.com')
1089 >>> sorted((team.displayname, team.teamowner.displayname)
1090@@ -1011,7 +1062,8 @@
1091 [(u'Ubuntu Team', u'Mark Shuttleworth')]
1092
1093
1094-=== ValidPerson ===
1095+ValidPerson
1096+...........
1097
1098 All 'valid' persons who are not a team.
1099
1100@@ -1019,19 +1071,23 @@
1101 >>> vocab = get_naked_vocab(None, "ValidPerson")
1102 >>> vocab.displayname
1103 'Select a Person'
1104+
1105 >>> people = vocab.search(None)
1106 >>> people.count() > 0
1107 True
1108+
1109 >>> invalid_people = [
1110 ... person for person in people if not person.is_valid_person]
1111 >>> print len(invalid_people)
1112 0
1113
1114-There are two 'Carlos' in the sample data but only one is a valid person.
1115+There are two 'Carlos' in the sample data but only one is a valid
1116+person.
1117
1118 >>> carlos_people = vocab.search('Carlos')
1119 >>> print len(list(carlos_people))
1120 1
1121+
1122 >>> invalid_carlos = [
1123 ... person for person in carlos_people if not person.is_valid_person]
1124 >>> print len(invalid_carlos)
1125@@ -1039,29 +1095,27 @@
1126
1127 ValidPerson does not include teams.
1128
1129- >>> # Create a new team.
1130 >>> carlos = getUtility(IPersonSet).getByName('carlos')
1131 >>> carlos_team = factory.makeTeam(
1132 ... owner=carlos, name='carlos-team')
1133 >>> person_or_team_vocab = get_naked_vocab(None, "ValidPersonOrTeam")
1134 >>> carlos_people_or_team = person_or_team_vocab.search('carlos')
1135- >>> # The people or team search yields our one Carlos person and
1136- >>> # the new team.
1137 >>> print len(list(carlos_people_or_team))
1138 2
1139+
1140 >>> carlos_team in carlos_people_or_team
1141 True
1142
1143- >>> # But the ValidPersonVocabulary only has the original Carlos
1144- >>> # person, not the new team.
1145 >>> carlos_people = vocab.search('carlos')
1146 >>> print len(list(carlos_people))
1147 1
1148+
1149 >>> carlos_team in carlos_people
1150 False
1151
1152
1153-=== DistributionOrProductVocabulary ===
1154+DistributionOrProductVocabulary
1155+...............................
1156
1157 All products and distributions. Note that the value type is
1158 heterogeneous.
1159@@ -1077,12 +1131,14 @@
1160
1161 >>> vocab.getTermByToken('firefox').token
1162 'firefox'
1163+
1164 >>> login('mark@example.com')
1165 >>> product_set['firefox'].setAliases(['iceweasel'])
1166 >>> current_user = launchbag.user
1167 >>> login_person(current_user)
1168 >>> vocab.getTermByToken('iceweasel').token
1169 'firefox'
1170+
1171 >>> [term.token for term in vocab.searchForTerms(query='iceweasel')]
1172 ['firefox']
1173
1174@@ -1101,14 +1157,17 @@
1175 ... if 'Tomcat' in term.title:
1176 ... print term.title, '- class', term.value.__class__.__name__
1177 Tomcat (Product) - class Product
1178+
1179 >>> tomcat = product_set.getByName('tomcat')
1180 >>> tomcat in vocab
1181 True
1182+
1183 >>> tomcat.active = False
1184 >>> flush_database_updates()
1185 >>> vocab = get_naked_vocab(None, "DistributionOrProduct")
1186 >>> tomcat in vocab
1187 False
1188+
1189 >>> tomcat.active = True
1190 >>> flush_database_updates()
1191 >>> vocab = get_naked_vocab(None, "DistributionOrProduct")
1192@@ -1122,10 +1181,11 @@
1193 False
1194
1195
1196-=== DistributionOrProductOrProjectGroupVocabulary ===
1197+DistributionOrProductOrProjectGroupVocabulary
1198+.............................................
1199
1200-All products, project groups and distributions. Note that the value type is
1201-heterogeneous.
1202+All products, project groups and distributions. Note that the value type
1203+is heterogeneous.
1204
1205 >>> vocab = get_naked_vocab(None, "DistributionOrProductOrProjectGroup")
1206 >>> for term in vocab:
1207@@ -1138,6 +1198,7 @@
1208
1209 >>> vocab.getTermByToken('ubuntu').token
1210 'ubuntu'
1211+
1212 >>> from lp.registry.interfaces.distribution import (
1213 ... IDistributionSet)
1214 >>> login('mark@example.com')
1215@@ -1145,6 +1206,7 @@
1216 >>> login_person(current_user)
1217 >>> vocab.getTermByToken('ubantoo').token
1218 'ubuntu'
1219+
1220 >>> [term.token for term in vocab.searchForTerms(query='ubantoo')]
1221 ['ubuntu']
1222
1223@@ -1153,6 +1215,7 @@
1224 >>> tomcat = product_set.getByName('tomcat')
1225 >>> tomcat in vocab
1226 True
1227+
1228 >>> tomcat.active = False
1229 >>> tomcat in vocab
1230 False
1231@@ -1160,6 +1223,7 @@
1232 >>> apache = getUtility(IProjectGroupSet).getByName('apache')
1233 >>> apache in vocab
1234 True
1235+
1236 >>> apache.active = False
1237 >>> apache in vocab
1238 False
1239@@ -1179,13 +1243,15 @@
1240 ... if 'Apache' in term.title:
1241 ... print term.title, '- class', term.value.__class__.__name__
1242 Apache (ProjectGroup) - class ProjectGroup
1243+
1244 >>> for term in vocab:
1245 ... if 'Tomcat' in term.title:
1246 ... print term.title, '- class', term.value.__class__.__name__
1247 Tomcat (Product) - class Product
1248
1249
1250-== FeaturedProjectVocabulary ==
1251+FeaturedProjectVocabulary
1252+-------------------------
1253
1254 The featured project vocabulary contains all the projects that are
1255 featured on Launchpad. It is a subset of the
1256@@ -1220,19 +1286,20 @@
1257 False
1258
1259
1260-== CommercialProjectsVocabulary ==
1261+CommercialProjectsVocabulary
1262+----------------------------
1263
1264 The commercial projects vocabulary contains all commercial projects,
1265-ordered by displayname. Note: a project is considered commercial if
1266-it has a proprietary license or no license. That's why some of these
1267+ordered by displayname. Note: a project is considered commercial if it
1268+has a proprietary license or no license. That's why some of these
1269 clearly FOSS project in our test data show up as commercial.
1270
1271 For a normal user (one who does not have launchpad.Commercial
1272-permission) the owned commercial project vocabulary is a list of
1273-project the user either owns or manages.
1274+permission) the owned commercial project vocabulary is a list of project
1275+the user either owns or manages.
1276
1277-The test data has one project with a proprietary license. Let's
1278-change bzr's so we will get more interesting results.
1279+The test data has one project with a proprietary license. Let's change
1280+bzr's so we will get more interesting results.
1281
1282 >>> from lp.registry.interfaces.product import License
1283 >>> bzr = product_set.getByName('bzr')
1284@@ -1284,3 +1351,5 @@
1285 ... print term.value.displayname
1286 Bazaar
1287 Mega Money Maker
1288+
1289+
1290
1291=== modified file 'lib/lp/registry/interfaces/person.py'
1292--- lib/lp/registry/interfaces/person.py 2011-03-01 09:41:39 +0000
1293+++ lib/lp/registry/interfaces/person.py 2011-03-17 20:19:01 +0000
1294@@ -1716,13 +1716,6 @@
1295 """
1296
1297
1298-class IPersonModerate(Interface):
1299- """IPerson attributes that require launchpad.Moderate."""
1300-
1301- def deactivateAllMembers(comment, reviewer):
1302- """Deactivate all the members of this team."""
1303-
1304-
1305 class IPersonCommAdminWriteRestricted(Interface):
1306 """IPerson attributes that require launchpad.Admin permission to set."""
1307
1308@@ -1771,7 +1764,7 @@
1309
1310 class IPerson(IPersonPublic, IPersonViewRestricted, IPersonEditRestricted,
1311 IPersonCommAdminWriteRestricted, IPersonSpecialRestricted,
1312- IPersonModerate, IHasStanding, ISetLocation, IRootContext):
1313+ IHasStanding, ISetLocation, IRootContext):
1314 """A Person."""
1315 export_as_webservice_entry(plural_name='people')
1316
1317
1318=== modified file 'lib/lp/registry/interfaces/teammembership.py'
1319--- lib/lp/registry/interfaces/teammembership.py 2011-03-01 03:52:30 +0000
1320+++ lib/lp/registry/interfaces/teammembership.py 2011-03-17 20:19:01 +0000
1321@@ -302,6 +302,16 @@
1322 TeamMembership and I'll return None.
1323 """
1324
1325+ def deactivateActiveMemberships(team, comment, reviewer):
1326+ """Deactivate all team members in ACTIVE_STATES.
1327+
1328+ This is a convenience method used before teams are deleted.
1329+
1330+ :param team: The team to deactivate.
1331+ :param comment: An explanation for the deactivation.
1332+ :param reviewer: The user doing the deactivation.
1333+ """
1334+
1335
1336 class ITeamParticipation(Interface):
1337 """A TeamParticipation.
1338
1339=== modified file 'lib/lp/registry/model/person.py'
1340--- lib/lp/registry/model/person.py 2011-03-09 18:18:02 +0000
1341+++ lib/lp/registry/model/person.py 2011-03-17 20:19:01 +0000
1342@@ -246,10 +246,7 @@
1343 SSHKeyCompromisedError,
1344 SSHKeyType,
1345 )
1346-from lp.registry.interfaces.teammembership import (
1347- ACTIVE_STATES,
1348- TeamMembershipStatus,
1349- )
1350+from lp.registry.interfaces.teammembership import TeamMembershipStatus
1351 from lp.registry.interfaces.wikiname import (
1352 IWikiName,
1353 IWikiNameSet,
1354@@ -1547,89 +1544,6 @@
1355 tm.dateexpires += timedelta(days=team.defaultrenewalperiod)
1356 tm.sendSelfRenewalNotification()
1357
1358- def deactivateAllMembers(self, comment, reviewer):
1359- """Deactivate all members of this team.
1360-
1361- This method circuments the TeamMembership.setStatus() method
1362- to improve performance; therefore, it does not send out any
1363- status change noticiations to the team admins.
1364-
1365- :param comment: Explanation of the change.
1366- :param reviewer: Person who made the change.
1367- """
1368- assert self.is_team, "This method is only available for teams."
1369- now = datetime.now(pytz.timezone('UTC'))
1370- store = Store.of(self)
1371- cur = cursor()
1372-
1373- # Deactivate the approved/admin team members.
1374- # XXX: EdwinGrubbs 2009-07-08 bug=397072
1375- # There are problems using storm to write an update
1376- # statement using DBITems in the comparison.
1377- cur.execute("""
1378- UPDATE TeamMembership
1379- SET status=%(status)s,
1380- last_changed_by=%(last_changed_by)s,
1381- last_change_comment=%(comment)s,
1382- date_last_changed=%(date_last_changed)s
1383- WHERE
1384- TeamMembership.team = %(team)s
1385- AND TeamMembership.status IN %(original_statuses)s
1386- """,
1387- dict(
1388- status=TeamMembershipStatus.DEACTIVATED,
1389- last_changed_by=reviewer.id,
1390- comment=comment,
1391- date_last_changed=now,
1392- team=self.id,
1393- original_statuses=(
1394- TeamMembershipStatus.ADMIN.value,
1395- TeamMembershipStatus.APPROVED.value)))
1396-
1397- # Since we've updated the database behind Storm's back,
1398- # flush its caches.
1399- store.invalidate()
1400-
1401- # Remove all indirect TeamParticipation entries resulting from this
1402- # team. If this were just a select, it would be a complicated but
1403- # feasible set of joins. Since it's a delete, we have to use
1404- # some sub selects.
1405- cur.execute('''
1406- DELETE FROM TeamParticipation
1407- WHERE
1408- -- The person needs to be a member of the team in question
1409- person IN
1410- (SELECT person from TeamParticipation WHERE
1411- team = %(team)s) AND
1412-
1413- -- The teams being deleted should be teams that this team
1414- -- is a member of.
1415- team IN
1416- (SELECT team from TeamMembership WHERE
1417- person = %(team)s) AND
1418-
1419- -- The person needs to not have direct membership in the
1420- -- team.
1421- NOT EXISTS
1422- (SELECT tm1.person from TeamMembership tm1
1423- WHERE
1424- tm1.person = TeamParticipation.person and
1425- tm1.team = TeamParticipation.team and
1426- tm1.status IN %(active_states)s);
1427- ''', dict(team=self.id, active_states=ACTIVE_STATES))
1428-
1429- # Since we've updated the database behind Storm's back yet again,
1430- # we need to flush its caches, again.
1431- store.invalidate()
1432-
1433- # Remove all members from the TeamParticipation table
1434- # except for the team, itself.
1435- participants = store.find(
1436- TeamParticipation,
1437- TeamParticipation.teamID == self.id,
1438- TeamParticipation.personID != self.id)
1439- participants.remove()
1440-
1441 def setMembershipData(self, person, status, reviewer, expires=None,
1442 comment=None):
1443 """See `IPerson`."""
1444
1445=== modified file 'lib/lp/registry/model/teammembership.py'
1446--- lib/lp/registry/model/teammembership.py 2011-03-09 18:18:02 +0000
1447+++ lib/lp/registry/model/teammembership.py 2011-03-17 20:19:01 +0000
1448@@ -30,6 +30,7 @@
1449 from canonical.database.datetimecol import UtcDateTimeCol
1450 from canonical.database.enumcol import EnumCol
1451 from canonical.database.sqlbase import (
1452+ cursor,
1453 flush_database_updates,
1454 SQLBase,
1455 sqlvalues,
1456@@ -493,6 +494,33 @@
1457 TeamMembershipRenewalPolicy.AUTOMATIC)
1458 return IStore(TeamMembership).find(TeamMembership, *conditions)
1459
1460+ def deactivateActiveMemberships(self, team, comment, reviewer):
1461+ """See `ITeamMembershipSet`."""
1462+ now = datetime.now(pytz.timezone('UTC'))
1463+ store = Store.of(team)
1464+ cur = cursor()
1465+ all_members = list(team.activemembers)
1466+ cur.execute("""
1467+ UPDATE TeamMembership
1468+ SET status=%(status)s,
1469+ last_changed_by=%(last_changed_by)s,
1470+ last_change_comment=%(comment)s,
1471+ date_last_changed=%(date_last_changed)s
1472+ WHERE
1473+ TeamMembership.team = %(team)s
1474+ AND TeamMembership.status IN %(original_statuses)s
1475+ """,
1476+ dict(
1477+ status=TeamMembershipStatus.DEACTIVATED,
1478+ last_changed_by=reviewer.id,
1479+ comment=comment,
1480+ date_last_changed=now,
1481+ team=team.id,
1482+ original_statuses=ACTIVE_STATES))
1483+ for member in all_members:
1484+ # store.invalidate() is called for each iteration.
1485+ _cleanTeamParticipation(member, team)
1486+
1487
1488 class TeamParticipation(SQLBase):
1489 implements(ITeamParticipation)
1490
1491=== modified file 'lib/lp/registry/tests/test_person.py'
1492--- lib/lp/registry/tests/test_person.py 2011-03-08 22:31:58 +0000
1493+++ lib/lp/registry/tests/test_person.py 2011-03-17 20:19:01 +0000
1494@@ -55,6 +55,7 @@
1495 PersonVisibility,
1496 )
1497 from lp.registry.interfaces.product import IProductSet
1498+from lp.registry.interfaces.teammembership import ITeamMembershipSet
1499 from lp.registry.model.karma import (
1500 KarmaCategory,
1501 KarmaTotalCache,
1502@@ -684,8 +685,9 @@
1503 self.assertEqual(oldest_date, person.datecreated)
1504
1505 def _doMerge(self, test_team, target_team):
1506- test_team.deactivateAllMembers(
1507- comment='',
1508+ membershipset = getUtility(ITeamMembershipSet)
1509+ membershipset.deactivateActiveMemberships(
1510+ test_team, comment='',
1511 reviewer=test_team.teamowner)
1512 self.person_set.merge(test_team, target_team)
1513
1514
1515=== modified file 'lib/lp/registry/tests/test_team.py'
1516--- lib/lp/registry/tests/test_team.py 2011-02-01 19:16:43 +0000
1517+++ lib/lp/registry/tests/test_team.py 2011-03-17 20:19:01 +0000
1518@@ -425,68 +425,3 @@
1519 members = list(team.approvedmembers)
1520 self.assertEqual(1, len(members))
1521 self.assertEqual(user, members[0])
1522-
1523-
1524-class TestMembershipManagement(TestCaseWithFactory):
1525-
1526- layer = DatabaseFunctionalLayer
1527-
1528- def test_deactivateAllMembers_cleans_up_teamparticipation_deactivated(
1529- self):
1530- superteam = self.factory.makeTeam(name='super')
1531- targetteam = self.factory.makeTeam(name='target')
1532- login_celebrity('admin')
1533- targetteam.join(superteam, targetteam.teamowner)
1534-
1535- # Now we create a deactivated link for the target team's teamowner.
1536- targetteam.teamowner.join(superteam, targetteam.teamowner)
1537- targetteam.teamowner.leave(superteam)
1538-
1539- self.assertEqual(
1540- sorted([superteam, targetteam]),
1541- sorted([team for team in
1542- targetteam.teamowner.teams_participated_in]))
1543- targetteam.deactivateAllMembers(
1544- comment='test',
1545- reviewer=targetteam.teamowner)
1546- self.assertEqual(
1547- [],
1548- sorted([team for team in
1549- targetteam.teamowner.teams_participated_in]))
1550-
1551- def test_deactivateAllMembers_cleans_up_teamparticipation_teamowner(self):
1552- superteam = self.factory.makeTeam(name='super')
1553- targetteam = self.factory.makeTeam(name='target')
1554- login_celebrity('admin')
1555- targetteam.join(superteam, targetteam.teamowner)
1556- self.assertEqual(
1557- sorted([superteam, targetteam]),
1558- sorted([team for team
1559- in targetteam.teamowner.teams_participated_in]))
1560- targetteam.deactivateAllMembers(
1561- comment='test',
1562- reviewer=targetteam.teamowner)
1563- self.assertEqual(
1564- [],
1565- sorted([team for team
1566- in targetteam.teamowner.teams_participated_in]))
1567-
1568- def test_deactivateAllMembers_cleans_up_team_participation(self):
1569- superteam = self.factory.makeTeam(name='super')
1570- sharedteam = self.factory.makeTeam(name='shared')
1571- anotherteam = self.factory.makeTeam(name='another')
1572- targetteam = self.factory.makeTeam(name='target')
1573- person = self.factory.makePerson()
1574- login_celebrity('admin')
1575- person.join(targetteam)
1576- person.join(sharedteam)
1577- person.join(anotherteam)
1578- targetteam.join(superteam, targetteam.teamowner)
1579- targetteam.join(sharedteam, targetteam.teamowner)
1580- self.assertTrue(superteam in person.teams_participated_in)
1581- targetteam.deactivateAllMembers(
1582- comment='test',
1583- reviewer=targetteam.teamowner)
1584- self.assertEqual(
1585- sorted([sharedteam, anotherteam]),
1586- sorted([team for team in person.teams_participated_in]))
1587
1588=== modified file 'lib/lp/registry/tests/test_teammembership.py'
1589--- lib/lp/registry/tests/test_teammembership.py 2011-03-01 03:52:30 +0000
1590+++ lib/lp/registry/tests/test_teammembership.py 2011-03-17 20:19:01 +0000
1591@@ -51,16 +51,18 @@
1592 TeamParticipation,
1593 )
1594 from lp.testing import (
1595+ login_celebrity,
1596 person_logged_in,
1597 TestCaseWithFactory,
1598 )
1599 from lp.testing.mail_helpers import pop_notifications
1600
1601
1602-class TestTeamMembershipSet(TestCase):
1603+class TestTeamMembershipSet(TestCaseWithFactory):
1604 layer = DatabaseFunctionalLayer
1605
1606 def setUp(self):
1607+ super(TestTeamMembershipSet, self).setUp()
1608 login('test@canonical.com')
1609 self.membershipset = getUtility(ITeamMembershipSet)
1610 self.personset = getUtility(IPersonSet)
1611@@ -166,6 +168,24 @@
1612 sample_person_on_motu.status, TeamMembershipStatus.EXPIRED)
1613 self.failIf(sample_person.inTeam(motu))
1614
1615+ def test_deactivateActiveMemberships(self):
1616+ superteam = self.factory.makeTeam(name='super')
1617+ targetteam = self.factory.makeTeam(name='target')
1618+ member = self.factory.makePerson()
1619+ login_celebrity('admin')
1620+ targetteam.join(superteam, targetteam.teamowner)
1621+ targetteam.addMember(member, targetteam.teamowner)
1622+ targetteam.teamowner.join(superteam, targetteam.teamowner)
1623+ self.membershipset.deactivateActiveMemberships(
1624+ targetteam, comment='test', reviewer=targetteam.teamowner)
1625+ membership = self.membershipset.getByPersonAndTeam(member, targetteam)
1626+ self.assertEqual('test', membership.last_change_comment)
1627+ self.assertEqual(targetteam.teamowner, membership.last_changed_by)
1628+ self.assertEqual([], list(targetteam.allmembers))
1629+ self.assertEqual(
1630+ [superteam], list(targetteam.teamowner.teams_participated_in))
1631+ self.assertEqual([], list(member.teams_participated_in))
1632+
1633
1634 class TeamParticipationTestCase(TestCaseWithFactory):
1635 """Tests for team participation using 5 teams."""
1636@@ -465,6 +485,31 @@
1637 previous_count-10,
1638 self.getTeamParticipationCount())
1639
1640+ def testTeam3_deactivateActiveMemberships(self):
1641+ # Removing all the members of team2 will not remove memberships
1642+ # to super teams from other paths.
1643+ non_member = self.factory.makePerson()
1644+ self.team3.addMember(non_member, self.foo_bar, force_team_add=True)
1645+ previous_count = self.getTeamParticipationCount()
1646+ membershipset = getUtility(ITeamMembershipSet)
1647+ membershipset.deactivateActiveMemberships(
1648+ self.team3, 'gone', self.foo_bar)
1649+ self.assertEqual([], list(self.team3.allmembers))
1650+ self.assertParticipantsEquals(
1651+ ['name16', 'no-priv', 'team2', 'team3', 'team4', 'team5'],
1652+ self.team1)
1653+ self.assertParticipantsEquals(
1654+ ['name16', 'no-priv', 'team3', 'team4', 'team5'], self.team2)
1655+ self.assertParticipantsEquals(
1656+ [], self.team3)
1657+ self.assertParticipantsEquals(
1658+ ['name16', 'no-priv', 'team5'], self.team4)
1659+ self.assertParticipantsEquals(['name16', 'no-priv'], self.team5)
1660+ self.assertParticipantsEquals(
1661+ ['name16', 'no-priv', 'team2', 'team3', 'team4', 'team5'],
1662+ self.team6)
1663+ self.assertEqual(previous_count - 8, self.getTeamParticipationCount())
1664+
1665
1666 class TestTeamMembership(TestCaseWithFactory):
1667 layer = DatabaseFunctionalLayer