Merge lp:~sinzui/launchpad/person-merge-job-4 into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Merged at revision: 12803
Proposed branch: lp:~sinzui/launchpad/person-merge-job-4
Merge into: lp:launchpad
Diff against target: 782 lines (+154/-262)
16 files modified
lib/canonical/launchpad/browser/logintoken.py (+5/-4)
lib/lp/registry/browser/peoplemerge.py (+18/-30)
lib/lp/registry/browser/person.py (+8/-0)
lib/lp/registry/browser/tests/peoplemerge-views.txt (+9/-21)
lib/lp/registry/browser/tests/person-views.txt (+1/-0)
lib/lp/registry/browser/tests/test_peoplemerge.py (+50/-26)
lib/lp/registry/browser/tests/test_person_view.py (+18/-4)
lib/lp/registry/browser/tests/test_team.py (+17/-0)
lib/lp/registry/interfaces/person.py (+12/-7)
lib/lp/registry/interfaces/persontransferjob.py (+2/-2)
lib/lp/registry/model/person.py (+3/-4)
lib/lp/registry/stories/person/merge-people.txt (+1/-35)
lib/lp/registry/stories/person/xx-adminpeoplemerge.txt (+4/-14)
lib/lp/registry/stories/person/xx-merge-person-with-hidden-email.txt (+0/-45)
lib/lp/registry/stories/team/xx-adminteammerge.txt (+4/-68)
lib/lp/registry/tests/test_person.py (+2/-2)
To merge this branch: bzr merge lp:~sinzui/launchpad/person-merge-job-4
Reviewer Review Type Date Requested Status
Benji York (community) code Approve
Review via email: mp+57037@code.launchpad.net

Description of the change

Enable async person/team merges from the UI.

    Launchpad bug:
        https://bugs.launchpad.net/bugs/736421
        https://bugs.launchpad.net/bugs/697530
    Pre-implementation: allenap
    Test command: ./bin/test -vv \
      -t test_is_merge_pending -t peoplemerge
      -t /xx-adminpeoplemerge -t xx-adminteammerge

- Update call-sites to use mergeAsync() where appropriate.
- UI to show if a user is the subject of a scheduled merge request.

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

RULES

    * Add an info notification to the person/team index page that states
      the team is involved in a merge.
    * Ensure a merge cannot be requested is the user/team is involved
      in a queued merge
    * Change the view the call Person.merge_async() and show a message
      explaining that the merge is queued.
    * ADDENDUM. Fix the ambiguous "Constraint not satisfied" message.

QA

    * Using one of the unmergeable users, perform a merge.
    * Verify (quickly) you see the notification that the user is being merged.
    * Verify the user is merged after a few minutes.
    * Using and undeletable teams, perform a delete.
    * Verify (quickly) you see the notification that the team is being merged.
    * Verify the team is merged after a few minutes.

LINT

    lib/canonical/launchpad/browser/logintoken.py
    lib/lp/registry/browser/peoplemerge.py
    lib/lp/registry/browser/person.py
    lib/lp/registry/browser/tests/peoplemerge-views.txt
    lib/lp/registry/browser/tests/test_peoplemerge.py
    lib/lp/registry/browser/tests/test_person_view.py
    lib/lp/registry/browser/tests/test_team.py
    lib/lp/registry/interfaces/person.py
    lib/lp/registry/interfaces/persontransferjob.py
    lib/lp/registry/model/person.py
    lib/lp/registry/stories/person/xx-adminpeoplemerge.txt
    lib/lp/registry/stories/team/xx-adminteammerge.txt
    lib/lp/registry/tests/test_person.py

IMPLEMENTATION

Fixed the signature of mergeAsync() and create() methods and updated the
documentation. Added reviewer to Merge() to be compatible with the
aforementioned methods.
    lib/lp/registry/interfaces/person.py
    lib/lp/registry/interfaces/persontransferjob.py

Revised the rule for is_merge_pending because we care about merges in
the duplicate role. It is okay for a person or team to have several
dupes pending to merge. We expect this to be the case for team deletions
which are merges into ~registry.
    lib/lp/registry/model/person.py
    lib/lp/registry/tests/test_person.py

I deleted the duplicate validate() from the direct subclass. I think it
was left over from a refactoring. I discovered that the common validate()
is used by forms that do not provide a target user since it is assumed to
be the requester. If fixed it to fix the tests I was writing.
Added a check to ensure the duplicate and target do not have a pending merge.
Updated the logintoken and peoplemerge views to use merge_async().
Changed the messages to explain the merge is queued.
I was startled by the "Constraint not satisfied" in a test, since that is
a UI problem. I found a bug was reported about it so I fixed the message.
    lib/canonical/launchpad/browser/logintoken.py
    lib/lp/registry/browser/peoplemerge.py
    lib/lp/registry/browser/tests/peoplemerge-views.txt
    lib/lp/registry/browser/tests/test_peoplemerge.py

I deleted corner cases from these story tests because there are testing in
the view tests. I updated the stories to verify the messages users see.
    lib/lp/registry/stories/person/xx-adminpeoplemerge.txt
    lib/lp/registry/stories/team/xx-adminteammerge.txt

Added a notification to explain that the team or person is being merged.
    lib/lp/registry/browser/person.py
    lib/lp/registry/browser/tests/test_person_view.py
    lib/lp/registry/browser/tests/test_team.py

To post a comment you must log in.
Revision history for this message
Benji York (benji) wrote :

This branch looks good.

One thing you might want to look at, I /think/ there is a hole in the
testing around the "is_merge_pending" property. I don't see that there
are any tests that the property is ever true.

review: Approve (code)
Revision history for this message
Curtis Hovey (sinzui) wrote :

There is a False test and a True test in lib/lp/registry/tests/test_person.py:

    def test_no_merge_pending(self):
        # is_merge_pending returns False when this person is not the "from"
        # person of an active merge job.
        person = self.factory.makePerson()
        self.assertFalse(person.is_merge_pending)

    def test_is_merge_pending(self):
        # is_merge_pending returns True when this person is being merged with
        # another person in an active merge job.
        from_person = self.factory.makePerson()
        to_person = self.factory.makePerson()
        getUtility(IPersonSet).mergeAsync(from_person, to_person)
        self.assertTrue(from_person.is_merge_pending)
        self.assertFalse(to_person.is_merge_pending)

Revision history for this message
Benji York (benji) wrote :

On Mon, Apr 11, 2011 at 5:00 PM, Curtis Hovey
<email address hidden> wrote:
> There is a False test and a True test in lib/lp/registry/tests/test_person.py:

Ah. I missed that. Thanks.
--
Benji York

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/browser/logintoken.py'
2--- lib/canonical/launchpad/browser/logintoken.py 2011-02-16 17:31:11 +0000
3+++ lib/canonical/launchpad/browser/logintoken.py 2011-04-12 02:08:36 +0000
4@@ -605,10 +605,11 @@
5 if emailset.getByPerson(self.dupe):
6 self.mergeCompleted = False
7 return
8-
9- # Call Stuart's magic function which will reassign all of the dupe
10- # account's stuff to the user account.
11- getUtility(IPersonSet).merge(self.dupe, requester)
12+ getUtility(IPersonSet).mergeAsync(
13+ self.dupe, requester, reviewer=requester)
14+ merge_message = _(
15+ 'A merge is queued and is expected to complete in a few minutes.')
16+ self.request.response.addInfoNotification(merge_message)
17 self.mergeCompleted = True
18
19
20
21=== modified file 'lib/lp/registry/browser/peoplemerge.py'
22--- lib/lp/registry/browser/peoplemerge.py 2011-03-25 23:42:33 +0000
23+++ lib/lp/registry/browser/peoplemerge.py 2011-04-12 02:08:36 +0000
24@@ -56,14 +56,14 @@
25 def validate(self, data):
26 """Check that user is not attempting to merge a person into itself."""
27 dupe_person = data.get('dupe_person')
28- target_person = data.get('target_person')
29-
30- if dupe_person == target_person and dupe_person is not None:
31- self.addError(_("You can't merge ${name} into itself.",
32- mapping=dict(name=dupe_person.name)))
33- # We cannot merge if there is a PPA with published
34- # packages on the duplicate, unless that PPA is removed.
35- if dupe_person is not None:
36+ target_person = data.get('target_person') or self.user
37+ if dupe_person is None:
38+ self.setFieldError(
39+ 'dupe_person', 'The duplicate is not a valid person or team.')
40+ else:
41+ if dupe_person == target_person:
42+ self.addError(_("You can't merge ${name} into itself.",
43+ mapping=dict(name=dupe_person.name)))
44 dupe_person_ppas = getUtility(IArchiveSet).getPPAOwnedByPerson(
45 dupe_person, statuses=[ArchiveStatus.ACTIVE,
46 ArchiveStatus.DELETING])
47@@ -73,6 +73,12 @@
48 "can be merged. It may take ten minutes to remove the "
49 "deleted PPA's files.",
50 mapping=dict(name=dupe_person.name)))
51+ if dupe_person.is_merge_pending:
52+ self.addError(_("${name} is already queued for merging.",
53+ mapping=dict(name=dupe_person.name)))
54+ if target_person is not None and target_person.is_merge_pending:
55+ self.addError(_("${name} is already queued for merging.",
56+ mapping=dict(name=target_person.name)))
57
58
59 class AdminMergeBaseView(ValidatingMergeView):
60@@ -84,7 +90,8 @@
61 # subclasses.
62 should_confirm_email_reassignment = False
63 should_confirm_member_deactivation = False
64- merge_message = _('Merge completed successfully.')
65+ merge_message = _(
66+ 'A merge is queued and is expected to complete in a few minutes.')
67
68 dupe_person_emails = ()
69 dupe_person = None
70@@ -98,25 +105,6 @@
71 def success_url(self):
72 return canonical_url(self.target_person)
73
74- def validate(self, data):
75- """Check that user is not attempting to merge a person into itself."""
76- dupe_person = data.get('dupe_person')
77- target_person = data.get('target_person')
78- if dupe_person == target_person and dupe_person is not None:
79- self.addError(_("You can't merge ${name} into itself.",
80- mapping=dict(name=dupe_person.name)))
81- # We cannot merge the teams if there is a PPA with published
82- # packages on the duplicate person, unless that PPA is removed.
83- dupe_person_ppas = getUtility(IArchiveSet).getPPAOwnedByPerson(
84- dupe_person, statuses=[ArchiveStatus.ACTIVE,
85- ArchiveStatus.DELETING])
86- if dupe_person_ppas is not None:
87- self.addError(_(
88- "${name} has a PPA that must be deleted before it "
89- "can be merged. It may take ten minutes to remove the "
90- "deleted PPA's files.",
91- mapping=dict(name=dupe_person.name)))
92-
93 def render(self):
94 # Subclasses may define other actions that they will render manually
95 # only in certain circunstances, so don't include them in the list of
96@@ -151,7 +139,7 @@
97 naked_email.personID = self.target_person.id
98 naked_email.accountID = self.target_person.accountID
99 naked_email.status = EmailAddressStatus.NEW
100- getUtility(IPersonSet).merge(
101+ job = getUtility(IPersonSet).mergeAsync(
102 self.dupe_person, self.target_person, reviewer=self.user)
103 self.request.response.addInfoNotification(self.merge_message)
104 self.next_url = self.success_url
105@@ -264,7 +252,7 @@
106
107 page_title = 'Delete'
108 field_names = ['dupe_person', 'target_person']
109- merge_message = _('Team deleted.')
110+ merge_message = _('The team is queued to be deleted.')
111
112 @property
113 def label(self):
114
115=== modified file 'lib/lp/registry/browser/person.py'
116--- lib/lp/registry/browser/person.py 2011-03-11 00:06:42 +0000
117+++ lib/lp/registry/browser/person.py 2011-04-12 02:08:36 +0000
118@@ -3391,6 +3391,14 @@
119
120 def initialize(self):
121 super(PersonIndexView, self).initialize()
122+ if self.context.is_merge_pending:
123+ if self.context.is_team:
124+ merge_action = 'merged or deleted'
125+ else:
126+ merge_action = 'merged'
127+ self.request.response.addInfoNotification(
128+ "%s is queued to be be %s in a few minutes." % (
129+ self.context.displayname, merge_action))
130 if self.request.method == "POST":
131 self.processForm()
132
133
134=== modified file 'lib/lp/registry/browser/tests/peoplemerge-views.txt'
135--- lib/lp/registry/browser/tests/peoplemerge-views.txt 2011-03-25 13:44:19 +0000
136+++ lib/lp/registry/browser/tests/peoplemerge-views.txt 2011-04-12 02:08:36 +0000
137@@ -31,13 +31,10 @@
138 ... person_set, '+adminteammerge', form=form)
139 >>> len(view.errors)
140 0
141-
142-The old team is now renamed but the target team remains.
143-
144- >>> print person_set.getByName('name21')
145- None
146- >>> print person_set.getByName('ubuntu-team').displayname
147- Ubuntu Team
148+ >>> for notification in view.request.response.notifications:
149+ ... print notification.message
150+ A merge is queued and is expected to complete in a few minutes.
151+
152
153 Attempting to merge a non-existent team results in an error.
154
155@@ -76,12 +73,9 @@
156 >>> view = create_initialized_view(
157 ... person_set, '+requestmerge', form=form)
158 >>> len(view.errors)
159- 1
160-
161- # XXX Salgado, 2009-07-08, bug=396410: There should be a meaningful
162- # error message here instead of this one.
163+ 2
164 >>> print view.getFieldError('dupe_person')
165- Constraint not satisfied
166+ The duplicate is not a valid person or team.
167
168 Admins, on the other hand, are allowed to merge people without a single email
169 address.
170@@ -181,18 +175,15 @@
171
172 >>> for notification in view.request.response.notifications:
173 ... print notification.message
174- Team deleted.
175+ The team is queued to be deleted.
176
177 >>> print view.next_url
178 http://launchpad.dev/people
179
180- >>> print person_set.getByName('deletable')
181- None
182-
183 The delete team view cannot be hacked to delete another team, or change
184 the team that the merge operation is performed with.
185
186- >>> deletable_team = factory.makeTeam(owner=team_owner, name='deletable')
187+ >>> deletable_team = factory.makeTeam(owner=team_owner, name='delete-me')
188 >>> transaction.commit()
189 >>> form = {
190 ... 'field.target_person': 'rosetta-admins',
191@@ -206,9 +197,6 @@
192 >>> view.request.response.notifications
193 []
194
195- >>> print deletable_team.name
196- deletable
197-
198 A team with a mailing list cannot be deleted.
199
200 >>> team, mailing_list = factory.makeTeamAndMailingList(
201@@ -246,4 +234,4 @@
202 []
203 >>> for notification in view.request.response.notifications:
204 ... print notification.message
205- Team deleted.
206+ The team is queued to be deleted.
207
208=== modified file 'lib/lp/registry/browser/tests/person-views.txt'
209--- lib/lp/registry/browser/tests/person-views.txt 2010-12-06 23:18:20 +0000
210+++ lib/lp/registry/browser/tests/person-views.txt 2011-04-12 02:08:36 +0000
211@@ -121,6 +121,7 @@
212 email addresses state is LOGIN_REQUIRED, there is no description, nor
213 are there any email addresses.
214
215+ >>> login(ANONYMOUS)
216 >>> view = create_initialized_view(mark, '+index')
217 >>> view.email_address_visibility.is_login_required
218 True
219
220=== modified file 'lib/lp/registry/browser/tests/test_peoplemerge.py'
221--- lib/lp/registry/browser/tests/test_peoplemerge.py 2011-03-25 13:44:19 +0000
222+++ lib/lp/registry/browser/tests/test_peoplemerge.py 2011-04-12 02:08:36 +0000
223@@ -6,15 +6,12 @@
224
225 from zope.component import getUtility
226
227-from canonical.launchpad.interfaces.emailaddress import (
228- EmailAddressStatus,
229- IEmailAddressSet,
230- )
231 from canonical.testing.layers import DatabaseFunctionalLayer
232 from lp.registry.interfaces.person import (
233 IPersonSet,
234 TeamSubscriptionPolicy,
235 )
236+from lp.registry.interfaces.persontransferjob import IPersonMergeJobSource
237 from lp.testing import (
238 login_celebrity,
239 login_person,
240@@ -27,33 +24,70 @@
241 )
242
243
244-class TestRequestPeopleMergeView(TestCaseWithFactory):
245+class TestValidatingMergeView(TestCaseWithFactory):
246
247 layer = DatabaseFunctionalLayer
248
249 def setUp(self):
250- super(TestRequestPeopleMergeView, self).setUp()
251+ super(TestValidatingMergeView, self).setUp()
252 self.person_set = getUtility(IPersonSet)
253 self.dupe = self.factory.makePerson(name='dupe')
254- self.target = self.factory.makeTeam(name='target')
255+ self.target = self.factory.makePerson(name='target')
256+
257+ def getForm(self, dupe_name=None):
258+ if dupe_name is None:
259+ dupe_name = self.dupe.name
260+ return {
261+ 'field.dupe_person': dupe_name,
262+ 'field.target_person': self.target.name,
263+ 'field.actions.continue': 'Continue',
264+ }
265
266 def test_cannot_merge_person_with_ppas(self):
267 # A team with a PPA cannot be merged.
268 login_celebrity('admin')
269 archive = self.dupe.createPPA()
270 login_celebrity('registry_experts')
271- form = {
272- 'field.dupe_person': self.dupe.name,
273- 'field.target_person': self.target.name,
274- 'field.actions.continue': 'Continue',
275- }
276+ view = create_initialized_view(
277+ self.person_set, '+requestmerge', form=self.getForm())
278+ self.assertEqual(
279+ [u"dupe has a PPA that must be deleted before it can be "
280+ "merged. It may take ten minutes to remove the deleted PPA's "
281+ "files."],
282+ view.errors)
283+
284+ def test_cannot_merge_person_with_itself(self):
285+ # A IPerson cannot be merged with itself.
286+ login_person(self.target)
287+ form = self.getForm(dupe_name=self.target.name)
288 view = create_initialized_view(
289 self.person_set, '+requestmerge', form=form)
290 self.assertEqual(
291- [u"dupe has a PPA that must be deleted before it can be "
292- "merged. It may take ten minutes to remove the deleted PPA's "
293- "files."],
294- view.errors)
295+ ["You can't merge target into itself."], view.errors)
296+
297+ def test_cannot_merge_dupe_person_with_an_existing_merge_job(self):
298+ # A merge cannot be requested for an IPerson if it there is a job
299+ # queued to merge it into another IPerson.
300+ job_source = getUtility(IPersonMergeJobSource)
301+ duplicate_job = job_source.create(
302+ from_person=self.dupe, to_person=self.target)
303+ login_person(self.target)
304+ view = create_initialized_view(
305+ self.person_set, '+requestmerge', form=self.getForm())
306+ self.assertEqual(
307+ ["dupe is already queued for merging."], view.errors)
308+
309+ def test_cannot_merge_target_person_with_an_existing_merge_job(self):
310+ # A merge cannot be requested for an IPerson if it there is a job
311+ # queued to merge it into another IPerson.
312+ job_source = getUtility(IPersonMergeJobSource)
313+ duplicate_job = job_source.create(
314+ from_person=self.target, to_person=self.dupe)
315+ login_person(self.target)
316+ view = create_initialized_view(
317+ self.person_set, '+requestmerge', form=self.getForm())
318+ self.assertEqual(
319+ ["target is already queued for merging."], view.errors)
320
321
322 class TestRequestPeopleMergeMultipleEmailsView(TestCaseWithFactory):
323@@ -135,16 +169,6 @@
324 return create_initialized_view(
325 self.person_set, '+adminteammerge', form=form)
326
327- def test_merge_team_with_email_address(self):
328- # Team email addresses are not transferred.
329- self.factory.makeEmail(
330- "del@ex.dom", self.dupe_team, email_status=EmailAddressStatus.NEW)
331- view = self.getView()
332- self.assertEqual([], view.errors)
333- self.assertEqual(self.target_team, self.dupe_team.merged)
334- emails = getUtility(IEmailAddressSet).getByPerson(self.target_team)
335- self.assertEqual(0, emails.count())
336-
337 def test_cannot_merge_team_with_ppa(self):
338 # A team with a PPA cannot be merged.
339 login_celebrity('admin')
340
341=== modified file 'lib/lp/registry/browser/tests/test_person_view.py'
342--- lib/lp/registry/browser/tests/test_person_view.py 2011-03-08 23:06:21 +0000
343+++ lib/lp/registry/browser/tests/test_person_view.py 2011-04-12 02:08:36 +0000
344@@ -6,10 +6,7 @@
345 import transaction
346 from storm.expr import LeftJoin
347 from storm.store import Store
348-from testtools.matchers import (
349- Equals,
350- LessThan,
351- )
352+from testtools.matchers import LessThan
353 from zope.component import getUtility
354
355 from canonical.config import config
356@@ -43,6 +40,7 @@
357 PersonVisibility,
358 IPersonSet,
359 )
360+from lp.registry.interfaces.persontransferjob import IPersonMergeJobSource
361 from lp.registry.interfaces.teammembership import (
362 ITeamMembershipSet,
363 TeamMembershipStatus,
364@@ -69,6 +67,22 @@
365 )
366
367
368+class TestPersonIndexView(TestCaseWithFactory):
369+
370+ layer = DatabaseFunctionalLayer
371+
372+ def test_is_merge_pending(self):
373+ dupe_person = self.factory.makePerson(name='finch')
374+ target_person = self.factory.makePerson()
375+ job_source = getUtility(IPersonMergeJobSource)
376+ job_source.create(from_person=dupe_person, to_person=target_person)
377+ view = create_initialized_view(dupe_person, name="+index")
378+ notifications = view.request.response.notifications
379+ message = 'Finch is queued to be be merged in a few minutes.'
380+ self.assertEqual(1, len(notifications))
381+ self.assertEqual(message, notifications[0].message)
382+
383+
384 class TestPersonViewKarma(TestCaseWithFactory):
385
386 layer = LaunchpadZopelessLayer
387
388=== modified file 'lib/lp/registry/browser/tests/test_team.py'
389--- lib/lp/registry/browser/tests/test_team.py 2010-12-03 00:03:15 +0000
390+++ lib/lp/registry/browser/tests/test_team.py 2011-04-12 02:08:36 +0000
391@@ -3,9 +3,12 @@
392
393 __metaclass__ = type
394
395+from zope.component import getUtility
396+
397 from canonical.launchpad.webapp.publisher import canonical_url
398 from canonical.testing.layers import DatabaseFunctionalLayer
399 from lp.registry.browser.person import TeamOverviewMenu
400+from lp.registry.interfaces.persontransferjob import IPersonMergeJobSource
401 from lp.testing import (
402 login_person,
403 TestCaseWithFactory,
404@@ -149,3 +152,17 @@
405 def test_add_member_step_title(self):
406 view = create_initialized_view(self.team, '+index')
407 self.assertEqual('Search', view.add_member_step_title)
408+
409+ def test_is_merge_pending(self):
410+ target_team = self.factory.makeTeam()
411+ job_source = getUtility(IPersonMergeJobSource)
412+ job_source.create(
413+ from_person=self.team, to_person=target_team,
414+ reviewer=target_team.teamowner)
415+ view = create_initialized_view(self.team, name="+index")
416+ notifications = view.request.response.notifications
417+ message = (
418+ 'Test Team is queued to be be merged or deleted '
419+ 'in a few minutes.')
420+ self.assertEqual(1, len(notifications))
421+ self.assertEqual(message, notifications[0].message)
422
423=== modified file 'lib/lp/registry/interfaces/person.py'
424--- lib/lp/registry/interfaces/person.py 2011-03-23 16:28:51 +0000
425+++ lib/lp/registry/interfaces/person.py 2011-04-12 02:08:36 +0000
426@@ -1428,12 +1428,14 @@
427 "The number of real people who are members of this team.")
428 # activemembers.value_type.schema will be set to IPerson once
429 # IPerson is defined.
430- activemembers = Attribute('List of direct members with ADMIN or APPROVED status')
431+ activemembers = Attribute(
432+ 'List of direct members with ADMIN or APPROVED status')
433 # For the API we need eager loading
434 api_activemembers = exported(
435 doNotSnapshot(
436 CollectionField(
437- title=_("List of direct members with ADMIN or APPROVED status"),
438+ title=_(
439+ "List of direct members with ADMIN or APPROVED status"),
440 value_type=Reference(schema=Interface))),
441 exported_as='members')
442 adminmembers = exported(
443@@ -2170,7 +2172,7 @@
444 def latest_teams(limit=5):
445 """Return the latest teams registered, up to the limit specified."""
446
447- def mergeAsync(from_person, to_person):
448+ def mergeAsync(from_person, to_person, reviewer=None):
449 """Merge a person/team into another asynchronously.
450
451 This schedules a call to `merge()` to happen outside of the current
452@@ -2179,10 +2181,13 @@
453 guaranteed to succeed. If either user is in a pending person merge
454 job, None is returned.
455
456+ :param from_person: An IPerson or ITeam that is a duplicate.
457+ :param to_person: An IPerson or ITeam that is a master.
458+ :param reviewer: An IPerson who approved the ITeam merger.
459 :return: A `PersonMergeJob` or None.
460 """
461
462- def merge(from_person, to_person):
463+ def merge(from_person, to_person, reviewer=None):
464 """Merge a person/team into another.
465
466 The old person/team (from_person) will be left as an atavism.
467@@ -2196,9 +2201,9 @@
468 passing deactivate_members=True. In that case the user who's
469 performing the merge must be provided as well.
470
471- We are not yet game to delete the `from_person` entry from the
472- database yet. We will let it roll for a while and see what cruft
473- develops. -- StuartBishop 20050812
474+ :param from_person: An IPerson or ITeam that is a duplicate.
475+ :param to_person: An IPerson or ITeam that is a master.
476+ :param reviewer: An IPerson who approved the ITeam merger.
477 """
478
479 def getValidPersons(persons):
480
481=== modified file 'lib/lp/registry/interfaces/persontransferjob.py'
482--- lib/lp/registry/interfaces/persontransferjob.py 2011-03-26 21:50:45 +0000
483+++ lib/lp/registry/interfaces/persontransferjob.py 2011-04-12 02:08:36 +0000
484@@ -102,7 +102,7 @@
485 class IPersonMergeJobSource(IJobSource):
486 """An interface for acquiring IPersonMergeJobs."""
487
488- def create(from_person, to_person, review=None):
489+ def create(from_person, to_person, reviewer=None):
490 """Create a new IPersonMergeJob.
491
492 None is returned if either the from_person or to_person are already
493@@ -111,7 +111,7 @@
494
495 :param from_person: An IPerson or ITeam that is a duplicate.
496 :param to_person: An IPerson or ITeam that is a master.
497- :param review: An IPerson who approved ITeam merger.
498+ :param reviewer: An IPerson who approved ITeam merger.
499 """
500
501 def find(from_person=None, to_person=None, any_person=False):
502
503=== modified file 'lib/lp/registry/model/person.py'
504--- lib/lp/registry/model/person.py 2011-04-06 17:08:45 +0000
505+++ lib/lp/registry/model/person.py 2011-04-12 02:08:36 +0000
506@@ -2076,8 +2076,7 @@
507 def is_merge_pending(self):
508 """See `IPublicPerson`."""
509 return not getUtility(
510- IPersonMergeJobSource).find(
511- from_person=self, to_person=self, any_person=True).is_empty()
512+ IPersonMergeJobSource).find(from_person=self).is_empty()
513
514 def visibilityConsistencyWarning(self, new_value):
515 """Warning used when changing the team's visibility.
516@@ -3836,10 +3835,10 @@
517 naked_from_team.retractTeamMembership(team, reviewer)
518 IStore(from_team).flush()
519
520- def mergeAsync(self, from_person, to_person):
521+ def mergeAsync(self, from_person, to_person, reviewer=None):
522 """See `IPersonSet`."""
523 return getUtility(IPersonMergeJobSource).create(
524- from_person=from_person, to_person=to_person)
525+ from_person=from_person, to_person=to_person, reviewer=reviewer)
526
527 def merge(self, from_person, to_person, reviewer=None):
528 """See `IPersonSet`."""
529
530=== modified file 'lib/lp/registry/stories/person/merge-people.txt'
531--- lib/lp/registry/stories/person/merge-people.txt 2010-01-12 00:19:20 +0000
532+++ lib/lp/registry/stories/person/merge-people.txt 2011-04-12 02:08:36 +0000
533@@ -12,12 +12,6 @@
534 >>> foo = factory.makePerson(name='foo', email='foo@baz.com')
535 >>> logout()
536
537-Workaround while https://launchpad.net/launchpad/+bug/39016 is not
538-fixed.
539-
540- >>> from lp.services.mail import stub
541- >>> stub.test_emails[:] = []
542-
543 First we have to go to the +requestmerge page.
544
545 >>> browser.addHeader('Authorization', 'Basic no-priv@canonical.com:test')
546@@ -25,23 +19,6 @@
547 >>> print browser.title
548 Merge Launchpad accounts
549
550-If we try to merge a nonexistent account, we'll get an error page.
551-
552- >>> browser.getControl('Duplicated Account').value = 'bar'
553- >>> browser.getControl('Continue').click()
554- >>> print "\n".join(get_feedback_messages(browser.contents))
555- There is 1 error.
556- Invalid value
557-
558-The same will happen if we try to merge a team.
559-
560- >>> browser.getControl(
561- ... 'Duplicated Account').value = 'support@canonical.com'
562- >>> browser.getControl('Continue').click()
563- >>> print "\n".join(get_feedback_messages(browser.contents))
564- There is 1 error.
565- Invalid value
566-
567 In preparation for the actual merge (below), we add an extra email
568 address to the 'foo' account, to show how things work when the dupe
569 account has more than one email address.
570@@ -70,6 +47,7 @@
571
572 Make sure we haven't got leftovers from a previous test
573
574+ >>> from lp.services.mail import stub
575 >>> len(stub.test_emails)
576 0
577
578@@ -164,18 +142,6 @@
579 >>> assert not stub.test_emails
580 >>> token_url = get_token_url_from_email(raw_msg)
581
582- # XXX: Steve Alexander 2006-10-21: To prevent spurious failures in PQM.
583- #>>> browser.open(token_url)
584- #>>> browser.getControl('Confirm').click()
585- #>>> 'The accounts have been merged successfully' in browser.contents
586- #True
587-
588-Revisiting the mergerequest-sent page now safely redirects the user:
589-
590- #>>> browser.open('http://launchpad.dev/people/+mergerequest-sent?dupe=55')
591- #>>> browser.url
592- #'http://launchpad.dev/~no-priv'
593-
594 If the duplicate account has multiple email addresses and has chosen
595 to hide them the process is slightly different. We cannot display the
596 hidden addresses so instead we just inform the user to check all of
597
598=== modified file 'lib/lp/registry/stories/person/xx-adminpeoplemerge.txt'
599--- lib/lp/registry/stories/person/xx-adminpeoplemerge.txt 2010-10-10 15:39:28 +0000
600+++ lib/lp/registry/stories/person/xx-adminpeoplemerge.txt 2011-04-12 02:08:36 +0000
601@@ -1,4 +1,5 @@
602-= Merging people or teams =
603+Merging people or teams
604+=======================
605
606 Launchpad admins can merge any two people or teams, without the need of
607 any email address confirmation or something like that. There's one page
608@@ -35,17 +36,6 @@
609 >>> admin_browser.url
610 'http://launchpad.dev/~salgado'
611
612- >>> from zope.component import getUtility
613- >>> from canonical.launchpad.ftests import ANONYMOUS, login, logout
614- >>> from lp.registry.interfaces.person import IPersonSet
615- >>> login(ANONYMOUS)
616- >>> person_set = getUtility(IPersonSet)
617- >>> person_set.getByEmail('andrew.bennetts@ubuntulinux.com').name
618- u'salgado'
619-
620- >>> spiv = person_set.getByName('spiv-merged', ignore_merged=False)
621- >>> spiv.merged.name
622- u'salgado'
623-
624- >>> logout()
625+ >>> print get_feedback_messages(admin_browser.contents)[0]
626+ A merge is queued and is expected to complete in a few minutes.
627
628
629=== removed file 'lib/lp/registry/stories/person/xx-merge-person-with-hidden-email.txt'
630--- lib/lp/registry/stories/person/xx-merge-person-with-hidden-email.txt 2009-09-10 23:58:31 +0000
631+++ lib/lp/registry/stories/person/xx-merge-person-with-hidden-email.txt 1970-01-01 00:00:00 +0000
632@@ -1,45 +0,0 @@
633-= Merging people with hidden email addresses =
634-
635-In order to merge FooBarDupe into FooBar we need to send an email to
636-FooBarDupe's email address with instructions for FooBar to complete the
637-merge. The merge should succeed even if FooBarDupe has chosen to hide his
638-email addresses.
639-
640- # First we'll create profiles for FooBar and FooBarDupe, the latter with
641- # hidden email addresses.
642- >>> login(ANONYMOUS)
643- >>> foobar = factory.makePerson(
644- ... email='foobar@baz.com', name='foobar', password='test')
645- >>> foobar_dupe = factory.makePerson(name='foobar-dupe')
646- >>> login_person(foobar_dupe)
647- >>> foobar_dupe.hide_email_addresses = True
648- >>> logout()
649-
650-FooBar will now request that FooBarDupe be merged into his account.
651-
652- >>> browser = setupBrowser(auth="Basic foobar@baz.com:test")
653- >>> browser.open('http://launchpad.dev/people/+requestmerge')
654- >>> browser.getControl('Duplicated Account').value = 'foobar-dupe'
655- >>> browser.getControl('Continue').click()
656- >>> browser.url
657- 'http://launchpad.dev/people/+mergerequest-sent?dupe=...
658- >>> print extract_text(find_main_content(browser.contents))
659- An email message was sent to...
660- Please follow the instructions on that message to complete the merge.
661-
662-Following the link included in the email sent to FooBarDupe's email he'll
663-be able to complete the merge.
664-
665- >>> from lp.services.mail import stub
666- >>> from canonical.launchpad.ftests.logintoken import (
667- ... get_token_url_from_email)
668- >>> len(stub.test_emails)
669- 1
670- >>> from_addr, to_addrs, raw_msg = stub.test_emails.pop()
671- >>> token_url = get_token_url_from_email(raw_msg)
672- >>> browser.open(token_url)
673- >>> browser.url
674- 'http://launchpad.dev/token/.../+accountmerge'
675- >>> browser.getControl('Confirm').click()
676- >>> print "\n".join(get_feedback_messages(browser.contents))
677- The accounts have been merged successfully...
678
679=== modified file 'lib/lp/registry/stories/team/xx-adminteammerge.txt'
680--- lib/lp/registry/stories/team/xx-adminteammerge.txt 2010-10-18 22:24:59 +0000
681+++ lib/lp/registry/stories/team/xx-adminteammerge.txt 2011-04-12 02:08:36 +0000
682@@ -1,4 +1,5 @@
683-= Merging Teams =
684+Merging Teams
685+=============
686
687 There's a separate page for merging teams. We can't merge teams with
688 active members, so the user will first have to confirm that the
689@@ -32,70 +33,5 @@
690 >>> registry_browser.url
691 'http://launchpad.dev/~guadamen'
692
693- >>> from canonical.launchpad.ftests import ANONYMOUS, login, logout
694- >>> from zope.component import getUtility
695- >>> from lp.registry.interfaces.mailinglist import IMailingListSet
696- >>> login(ANONYMOUS)
697- >>> new_team.merged.name
698- u'guadamen'
699- >>> logout()
700-
701-
702-== Non-display of merged teams ==
703-
704-Merged teams are not visible anymore.
705-
706- >>> browser.open("http://launchpad.dev/~new-team-merged")
707- Traceback (most recent call last):
708- ...
709- NotFound:...
710-
711-
712-== Corner cases ==
713-
714-If the duplicated team is associated with a mailing list, it can't be
715-merged, though.
716-
717- >>> login(ANONYMOUS)
718- >>> guadamen = getUtility(IPersonSet).getByName('guadamen')
719- >>> mailing_list = getUtility(IMailingListSet).new(guadamen)
720- >>> logout()
721-
722- >>> registry_browser.open('http://launchpad.dev/people/+adminteammerge')
723- >>> registry_browser.getControl('Duplicated Team').value = 'guadamen'
724- >>> registry_browser.getControl('Target Team').value = 'ubuntu-team'
725- >>> registry_browser.getControl('Merge').click()
726-
727- >>> registry_browser.url
728- 'http://launchpad.dev/people/+adminteammerge'
729-
730- >>> print get_feedback_messages(registry_browser.contents)
731- [u'There is 1 error.',
732- u"guadamen is associated with a Launchpad mailing list;
733- we can't merge it."]
734-
735-We also can't (for obvious reasons) merge any person/team into itself.
736-
737- >>> registry_browser.getControl('Duplicated Team').value = 'shipit-admins'
738- >>> registry_browser.getControl('Target Team').value = 'shipit-admins'
739- >>> registry_browser.getControl('Merge').click()
740-
741- >>> registry_browser.url
742- 'http://launchpad.dev/people/+adminteammerge'
743-
744- >>> print get_feedback_messages(registry_browser.contents)
745- [u'There is 1 error.', u"You can't merge shipit-admins into itself."]
746-
747-Nor can we merge a person into a team or a team into a person.
748-
749- >>> registry_browser.getControl('Duplicated Team').value = 'ubuntu-team'
750- >>> registry_browser.getControl('Target Team').value = 'salgado'
751- >>> registry_browser.getControl('Merge').click()
752-
753- >>> registry_browser.url
754- 'http://launchpad.dev/people/+adminteammerge'
755-
756- # Yes, the error message is not of much help, but this UI is only for
757- # admins and they're supposed to know what they're doing.
758- >>> print get_feedback_messages(registry_browser.contents)
759- [u'There is 1 error.', u'Constraint not satisfied']
760+ >>> print get_feedback_messages(registry_browser.contents)[0]
761+ A merge is queued and is expected to complete in a few minutes.
762
763=== modified file 'lib/lp/registry/tests/test_person.py'
764--- lib/lp/registry/tests/test_person.py 2011-04-05 12:10:01 +0000
765+++ lib/lp/registry/tests/test_person.py 2011-04-12 02:08:36 +0000
766@@ -289,14 +289,14 @@
767 person = self.factory.makePerson()
768 self.assertFalse(person.is_merge_pending)
769
770- def test_merge_pending(self):
771+ def test_is_merge_pending(self):
772 # is_merge_pending returns True when this person is being merged with
773 # another person in an active merge job.
774 from_person = self.factory.makePerson()
775 to_person = self.factory.makePerson()
776 getUtility(IPersonSet).mergeAsync(from_person, to_person)
777 self.assertTrue(from_person.is_merge_pending)
778- self.assertTrue(to_person.is_merge_pending)
779+ self.assertFalse(to_person.is_merge_pending)
780
781 def test_mergeAsync_success(self):
782 # mergeAsync returns a job with the from and to persons.