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
=== modified file 'lib/canonical/launchpad/browser/logintoken.py'
--- lib/canonical/launchpad/browser/logintoken.py 2011-02-16 17:31:11 +0000
+++ lib/canonical/launchpad/browser/logintoken.py 2011-04-12 02:08:36 +0000
@@ -605,10 +605,11 @@
605 if emailset.getByPerson(self.dupe):605 if emailset.getByPerson(self.dupe):
606 self.mergeCompleted = False606 self.mergeCompleted = False
607 return607 return
608608 getUtility(IPersonSet).mergeAsync(
609 # Call Stuart's magic function which will reassign all of the dupe609 self.dupe, requester, reviewer=requester)
610 # account's stuff to the user account.610 merge_message = _(
611 getUtility(IPersonSet).merge(self.dupe, requester)611 'A merge is queued and is expected to complete in a few minutes.')
612 self.request.response.addInfoNotification(merge_message)
612 self.mergeCompleted = True613 self.mergeCompleted = True
613614
614615
615616
=== modified file 'lib/lp/registry/browser/peoplemerge.py'
--- lib/lp/registry/browser/peoplemerge.py 2011-03-25 23:42:33 +0000
+++ lib/lp/registry/browser/peoplemerge.py 2011-04-12 02:08:36 +0000
@@ -56,14 +56,14 @@
56 def validate(self, data):56 def validate(self, data):
57 """Check that user is not attempting to merge a person into itself."""57 """Check that user is not attempting to merge a person into itself."""
58 dupe_person = data.get('dupe_person')58 dupe_person = data.get('dupe_person')
59 target_person = data.get('target_person')59 target_person = data.get('target_person') or self.user
6060 if dupe_person is None:
61 if dupe_person == target_person and dupe_person is not None:61 self.setFieldError(
62 self.addError(_("You can't merge ${name} into itself.",62 'dupe_person', 'The duplicate is not a valid person or team.')
63 mapping=dict(name=dupe_person.name)))63 else:
64 # We cannot merge if there is a PPA with published64 if dupe_person == target_person:
65 # packages on the duplicate, unless that PPA is removed.65 self.addError(_("You can't merge ${name} into itself.",
66 if dupe_person is not None:66 mapping=dict(name=dupe_person.name)))
67 dupe_person_ppas = getUtility(IArchiveSet).getPPAOwnedByPerson(67 dupe_person_ppas = getUtility(IArchiveSet).getPPAOwnedByPerson(
68 dupe_person, statuses=[ArchiveStatus.ACTIVE,68 dupe_person, statuses=[ArchiveStatus.ACTIVE,
69 ArchiveStatus.DELETING])69 ArchiveStatus.DELETING])
@@ -73,6 +73,12 @@
73 "can be merged. It may take ten minutes to remove the "73 "can be merged. It may take ten minutes to remove the "
74 "deleted PPA's files.",74 "deleted PPA's files.",
75 mapping=dict(name=dupe_person.name)))75 mapping=dict(name=dupe_person.name)))
76 if dupe_person.is_merge_pending:
77 self.addError(_("${name} is already queued for merging.",
78 mapping=dict(name=dupe_person.name)))
79 if target_person is not None and target_person.is_merge_pending:
80 self.addError(_("${name} is already queued for merging.",
81 mapping=dict(name=target_person.name)))
7682
7783
78class AdminMergeBaseView(ValidatingMergeView):84class AdminMergeBaseView(ValidatingMergeView):
@@ -84,7 +90,8 @@
84 # subclasses.90 # subclasses.
85 should_confirm_email_reassignment = False91 should_confirm_email_reassignment = False
86 should_confirm_member_deactivation = False92 should_confirm_member_deactivation = False
87 merge_message = _('Merge completed successfully.')93 merge_message = _(
94 'A merge is queued and is expected to complete in a few minutes.')
8895
89 dupe_person_emails = ()96 dupe_person_emails = ()
90 dupe_person = None97 dupe_person = None
@@ -98,25 +105,6 @@
98 def success_url(self):105 def success_url(self):
99 return canonical_url(self.target_person)106 return canonical_url(self.target_person)
100107
101 def validate(self, data):
102 """Check that user is not attempting to merge a person into itself."""
103 dupe_person = data.get('dupe_person')
104 target_person = data.get('target_person')
105 if dupe_person == target_person and dupe_person is not None:
106 self.addError(_("You can't merge ${name} into itself.",
107 mapping=dict(name=dupe_person.name)))
108 # We cannot merge the teams if there is a PPA with published
109 # packages on the duplicate person, unless that PPA is removed.
110 dupe_person_ppas = getUtility(IArchiveSet).getPPAOwnedByPerson(
111 dupe_person, statuses=[ArchiveStatus.ACTIVE,
112 ArchiveStatus.DELETING])
113 if dupe_person_ppas is not None:
114 self.addError(_(
115 "${name} has a PPA that must be deleted before it "
116 "can be merged. It may take ten minutes to remove the "
117 "deleted PPA's files.",
118 mapping=dict(name=dupe_person.name)))
119
120 def render(self):108 def render(self):
121 # Subclasses may define other actions that they will render manually109 # Subclasses may define other actions that they will render manually
122 # only in certain circunstances, so don't include them in the list of110 # only in certain circunstances, so don't include them in the list of
@@ -151,7 +139,7 @@
151 naked_email.personID = self.target_person.id139 naked_email.personID = self.target_person.id
152 naked_email.accountID = self.target_person.accountID140 naked_email.accountID = self.target_person.accountID
153 naked_email.status = EmailAddressStatus.NEW141 naked_email.status = EmailAddressStatus.NEW
154 getUtility(IPersonSet).merge(142 job = getUtility(IPersonSet).mergeAsync(
155 self.dupe_person, self.target_person, reviewer=self.user)143 self.dupe_person, self.target_person, reviewer=self.user)
156 self.request.response.addInfoNotification(self.merge_message)144 self.request.response.addInfoNotification(self.merge_message)
157 self.next_url = self.success_url145 self.next_url = self.success_url
@@ -264,7 +252,7 @@
264252
265 page_title = 'Delete'253 page_title = 'Delete'
266 field_names = ['dupe_person', 'target_person']254 field_names = ['dupe_person', 'target_person']
267 merge_message = _('Team deleted.')255 merge_message = _('The team is queued to be deleted.')
268256
269 @property257 @property
270 def label(self):258 def label(self):
271259
=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py 2011-03-11 00:06:42 +0000
+++ lib/lp/registry/browser/person.py 2011-04-12 02:08:36 +0000
@@ -3391,6 +3391,14 @@
33913391
3392 def initialize(self):3392 def initialize(self):
3393 super(PersonIndexView, self).initialize()3393 super(PersonIndexView, self).initialize()
3394 if self.context.is_merge_pending:
3395 if self.context.is_team:
3396 merge_action = 'merged or deleted'
3397 else:
3398 merge_action = 'merged'
3399 self.request.response.addInfoNotification(
3400 "%s is queued to be be %s in a few minutes." % (
3401 self.context.displayname, merge_action))
3394 if self.request.method == "POST":3402 if self.request.method == "POST":
3395 self.processForm()3403 self.processForm()
33963404
33973405
=== modified file 'lib/lp/registry/browser/tests/peoplemerge-views.txt'
--- lib/lp/registry/browser/tests/peoplemerge-views.txt 2011-03-25 13:44:19 +0000
+++ lib/lp/registry/browser/tests/peoplemerge-views.txt 2011-04-12 02:08:36 +0000
@@ -31,13 +31,10 @@
31 ... person_set, '+adminteammerge', form=form)31 ... person_set, '+adminteammerge', form=form)
32 >>> len(view.errors)32 >>> len(view.errors)
33 033 0
3434 >>> for notification in view.request.response.notifications:
35The old team is now renamed but the target team remains.35 ... print notification.message
3636 A merge is queued and is expected to complete in a few minutes.
37 >>> print person_set.getByName('name21')37
38 None
39 >>> print person_set.getByName('ubuntu-team').displayname
40 Ubuntu Team
4138
42Attempting to merge a non-existent team results in an error.39Attempting to merge a non-existent team results in an error.
4340
@@ -76,12 +73,9 @@
76 >>> view = create_initialized_view(73 >>> view = create_initialized_view(
77 ... person_set, '+requestmerge', form=form)74 ... person_set, '+requestmerge', form=form)
78 >>> len(view.errors)75 >>> len(view.errors)
79 176 2
80
81 # XXX Salgado, 2009-07-08, bug=396410: There should be a meaningful
82 # error message here instead of this one.
83 >>> print view.getFieldError('dupe_person')77 >>> print view.getFieldError('dupe_person')
84 Constraint not satisfied78 The duplicate is not a valid person or team.
8579
86Admins, on the other hand, are allowed to merge people without a single email80Admins, on the other hand, are allowed to merge people without a single email
87address.81address.
@@ -181,18 +175,15 @@
181175
182 >>> for notification in view.request.response.notifications:176 >>> for notification in view.request.response.notifications:
183 ... print notification.message177 ... print notification.message
184 Team deleted.178 The team is queued to be deleted.
185179
186 >>> print view.next_url180 >>> print view.next_url
187 http://launchpad.dev/people181 http://launchpad.dev/people
188182
189 >>> print person_set.getByName('deletable')
190 None
191
192The delete team view cannot be hacked to delete another team, or change183The delete team view cannot be hacked to delete another team, or change
193the team that the merge operation is performed with.184the team that the merge operation is performed with.
194185
195 >>> deletable_team = factory.makeTeam(owner=team_owner, name='deletable')186 >>> deletable_team = factory.makeTeam(owner=team_owner, name='delete-me')
196 >>> transaction.commit()187 >>> transaction.commit()
197 >>> form = {188 >>> form = {
198 ... 'field.target_person': 'rosetta-admins',189 ... 'field.target_person': 'rosetta-admins',
@@ -206,9 +197,6 @@
206 >>> view.request.response.notifications197 >>> view.request.response.notifications
207 []198 []
208199
209 >>> print deletable_team.name
210 deletable
211
212A team with a mailing list cannot be deleted.200A team with a mailing list cannot be deleted.
213201
214 >>> team, mailing_list = factory.makeTeamAndMailingList(202 >>> team, mailing_list = factory.makeTeamAndMailingList(
@@ -246,4 +234,4 @@
246 []234 []
247 >>> for notification in view.request.response.notifications:235 >>> for notification in view.request.response.notifications:
248 ... print notification.message236 ... print notification.message
249 Team deleted.237 The team is queued to be deleted.
250238
=== modified file 'lib/lp/registry/browser/tests/person-views.txt'
--- lib/lp/registry/browser/tests/person-views.txt 2010-12-06 23:18:20 +0000
+++ lib/lp/registry/browser/tests/person-views.txt 2011-04-12 02:08:36 +0000
@@ -121,6 +121,7 @@
121email addresses state is LOGIN_REQUIRED, there is no description, nor121email addresses state is LOGIN_REQUIRED, there is no description, nor
122are there any email addresses.122are there any email addresses.
123123
124 >>> login(ANONYMOUS)
124 >>> view = create_initialized_view(mark, '+index')125 >>> view = create_initialized_view(mark, '+index')
125 >>> view.email_address_visibility.is_login_required126 >>> view.email_address_visibility.is_login_required
126 True127 True
127128
=== modified file 'lib/lp/registry/browser/tests/test_peoplemerge.py'
--- lib/lp/registry/browser/tests/test_peoplemerge.py 2011-03-25 13:44:19 +0000
+++ lib/lp/registry/browser/tests/test_peoplemerge.py 2011-04-12 02:08:36 +0000
@@ -6,15 +6,12 @@
66
7from zope.component import getUtility7from zope.component import getUtility
88
9from canonical.launchpad.interfaces.emailaddress import (
10 EmailAddressStatus,
11 IEmailAddressSet,
12 )
13from canonical.testing.layers import DatabaseFunctionalLayer9from canonical.testing.layers import DatabaseFunctionalLayer
14from lp.registry.interfaces.person import (10from lp.registry.interfaces.person import (
15 IPersonSet,11 IPersonSet,
16 TeamSubscriptionPolicy,12 TeamSubscriptionPolicy,
17 )13 )
14from lp.registry.interfaces.persontransferjob import IPersonMergeJobSource
18from lp.testing import (15from lp.testing import (
19 login_celebrity,16 login_celebrity,
20 login_person,17 login_person,
@@ -27,33 +24,70 @@
27 )24 )
2825
2926
30class TestRequestPeopleMergeView(TestCaseWithFactory):27class TestValidatingMergeView(TestCaseWithFactory):
3128
32 layer = DatabaseFunctionalLayer29 layer = DatabaseFunctionalLayer
3330
34 def setUp(self):31 def setUp(self):
35 super(TestRequestPeopleMergeView, self).setUp()32 super(TestValidatingMergeView, self).setUp()
36 self.person_set = getUtility(IPersonSet)33 self.person_set = getUtility(IPersonSet)
37 self.dupe = self.factory.makePerson(name='dupe')34 self.dupe = self.factory.makePerson(name='dupe')
38 self.target = self.factory.makeTeam(name='target')35 self.target = self.factory.makePerson(name='target')
36
37 def getForm(self, dupe_name=None):
38 if dupe_name is None:
39 dupe_name = self.dupe.name
40 return {
41 'field.dupe_person': dupe_name,
42 'field.target_person': self.target.name,
43 'field.actions.continue': 'Continue',
44 }
3945
40 def test_cannot_merge_person_with_ppas(self):46 def test_cannot_merge_person_with_ppas(self):
41 # A team with a PPA cannot be merged.47 # A team with a PPA cannot be merged.
42 login_celebrity('admin')48 login_celebrity('admin')
43 archive = self.dupe.createPPA()49 archive = self.dupe.createPPA()
44 login_celebrity('registry_experts')50 login_celebrity('registry_experts')
45 form = {51 view = create_initialized_view(
46 'field.dupe_person': self.dupe.name,52 self.person_set, '+requestmerge', form=self.getForm())
47 'field.target_person': self.target.name,53 self.assertEqual(
48 'field.actions.continue': 'Continue',54 [u"dupe has a PPA that must be deleted before it can be "
49 }55 "merged. It may take ten minutes to remove the deleted PPA's "
56 "files."],
57 view.errors)
58
59 def test_cannot_merge_person_with_itself(self):
60 # A IPerson cannot be merged with itself.
61 login_person(self.target)
62 form = self.getForm(dupe_name=self.target.name)
50 view = create_initialized_view(63 view = create_initialized_view(
51 self.person_set, '+requestmerge', form=form)64 self.person_set, '+requestmerge', form=form)
52 self.assertEqual(65 self.assertEqual(
53 [u"dupe has a PPA that must be deleted before it can be "66 ["You can't merge target into itself."], view.errors)
54 "merged. It may take ten minutes to remove the deleted PPA's "67
55 "files."],68 def test_cannot_merge_dupe_person_with_an_existing_merge_job(self):
56 view.errors)69 # A merge cannot be requested for an IPerson if it there is a job
70 # queued to merge it into another IPerson.
71 job_source = getUtility(IPersonMergeJobSource)
72 duplicate_job = job_source.create(
73 from_person=self.dupe, to_person=self.target)
74 login_person(self.target)
75 view = create_initialized_view(
76 self.person_set, '+requestmerge', form=self.getForm())
77 self.assertEqual(
78 ["dupe is already queued for merging."], view.errors)
79
80 def test_cannot_merge_target_person_with_an_existing_merge_job(self):
81 # A merge cannot be requested for an IPerson if it there is a job
82 # queued to merge it into another IPerson.
83 job_source = getUtility(IPersonMergeJobSource)
84 duplicate_job = job_source.create(
85 from_person=self.target, to_person=self.dupe)
86 login_person(self.target)
87 view = create_initialized_view(
88 self.person_set, '+requestmerge', form=self.getForm())
89 self.assertEqual(
90 ["target is already queued for merging."], view.errors)
5791
5892
59class TestRequestPeopleMergeMultipleEmailsView(TestCaseWithFactory):93class TestRequestPeopleMergeMultipleEmailsView(TestCaseWithFactory):
@@ -135,16 +169,6 @@
135 return create_initialized_view(169 return create_initialized_view(
136 self.person_set, '+adminteammerge', form=form)170 self.person_set, '+adminteammerge', form=form)
137171
138 def test_merge_team_with_email_address(self):
139 # Team email addresses are not transferred.
140 self.factory.makeEmail(
141 "del@ex.dom", self.dupe_team, email_status=EmailAddressStatus.NEW)
142 view = self.getView()
143 self.assertEqual([], view.errors)
144 self.assertEqual(self.target_team, self.dupe_team.merged)
145 emails = getUtility(IEmailAddressSet).getByPerson(self.target_team)
146 self.assertEqual(0, emails.count())
147
148 def test_cannot_merge_team_with_ppa(self):172 def test_cannot_merge_team_with_ppa(self):
149 # A team with a PPA cannot be merged.173 # A team with a PPA cannot be merged.
150 login_celebrity('admin')174 login_celebrity('admin')
151175
=== modified file 'lib/lp/registry/browser/tests/test_person_view.py'
--- lib/lp/registry/browser/tests/test_person_view.py 2011-03-08 23:06:21 +0000
+++ lib/lp/registry/browser/tests/test_person_view.py 2011-04-12 02:08:36 +0000
@@ -6,10 +6,7 @@
6import transaction6import transaction
7from storm.expr import LeftJoin7from storm.expr import LeftJoin
8from storm.store import Store8from storm.store import Store
9from testtools.matchers import (9from testtools.matchers import LessThan
10 Equals,
11 LessThan,
12 )
13from zope.component import getUtility10from zope.component import getUtility
1411
15from canonical.config import config12from canonical.config import config
@@ -43,6 +40,7 @@
43 PersonVisibility,40 PersonVisibility,
44 IPersonSet,41 IPersonSet,
45 )42 )
43from lp.registry.interfaces.persontransferjob import IPersonMergeJobSource
46from lp.registry.interfaces.teammembership import (44from lp.registry.interfaces.teammembership import (
47 ITeamMembershipSet,45 ITeamMembershipSet,
48 TeamMembershipStatus,46 TeamMembershipStatus,
@@ -69,6 +67,22 @@
69 )67 )
7068
7169
70class TestPersonIndexView(TestCaseWithFactory):
71
72 layer = DatabaseFunctionalLayer
73
74 def test_is_merge_pending(self):
75 dupe_person = self.factory.makePerson(name='finch')
76 target_person = self.factory.makePerson()
77 job_source = getUtility(IPersonMergeJobSource)
78 job_source.create(from_person=dupe_person, to_person=target_person)
79 view = create_initialized_view(dupe_person, name="+index")
80 notifications = view.request.response.notifications
81 message = 'Finch is queued to be be merged in a few minutes.'
82 self.assertEqual(1, len(notifications))
83 self.assertEqual(message, notifications[0].message)
84
85
72class TestPersonViewKarma(TestCaseWithFactory):86class TestPersonViewKarma(TestCaseWithFactory):
7387
74 layer = LaunchpadZopelessLayer88 layer = LaunchpadZopelessLayer
7589
=== modified file 'lib/lp/registry/browser/tests/test_team.py'
--- lib/lp/registry/browser/tests/test_team.py 2010-12-03 00:03:15 +0000
+++ lib/lp/registry/browser/tests/test_team.py 2011-04-12 02:08:36 +0000
@@ -3,9 +3,12 @@
33
4__metaclass__ = type4__metaclass__ = type
55
6from zope.component import getUtility
7
6from canonical.launchpad.webapp.publisher import canonical_url8from canonical.launchpad.webapp.publisher import canonical_url
7from canonical.testing.layers import DatabaseFunctionalLayer9from canonical.testing.layers import DatabaseFunctionalLayer
8from lp.registry.browser.person import TeamOverviewMenu10from lp.registry.browser.person import TeamOverviewMenu
11from lp.registry.interfaces.persontransferjob import IPersonMergeJobSource
9from lp.testing import (12from lp.testing import (
10 login_person,13 login_person,
11 TestCaseWithFactory,14 TestCaseWithFactory,
@@ -149,3 +152,17 @@
149 def test_add_member_step_title(self):152 def test_add_member_step_title(self):
150 view = create_initialized_view(self.team, '+index')153 view = create_initialized_view(self.team, '+index')
151 self.assertEqual('Search', view.add_member_step_title)154 self.assertEqual('Search', view.add_member_step_title)
155
156 def test_is_merge_pending(self):
157 target_team = self.factory.makeTeam()
158 job_source = getUtility(IPersonMergeJobSource)
159 job_source.create(
160 from_person=self.team, to_person=target_team,
161 reviewer=target_team.teamowner)
162 view = create_initialized_view(self.team, name="+index")
163 notifications = view.request.response.notifications
164 message = (
165 'Test Team is queued to be be merged or deleted '
166 'in a few minutes.')
167 self.assertEqual(1, len(notifications))
168 self.assertEqual(message, notifications[0].message)
152169
=== modified file 'lib/lp/registry/interfaces/person.py'
--- lib/lp/registry/interfaces/person.py 2011-03-23 16:28:51 +0000
+++ lib/lp/registry/interfaces/person.py 2011-04-12 02:08:36 +0000
@@ -1428,12 +1428,14 @@
1428 "The number of real people who are members of this team.")1428 "The number of real people who are members of this team.")
1429 # activemembers.value_type.schema will be set to IPerson once1429 # activemembers.value_type.schema will be set to IPerson once
1430 # IPerson is defined.1430 # IPerson is defined.
1431 activemembers = Attribute('List of direct members with ADMIN or APPROVED status')1431 activemembers = Attribute(
1432 'List of direct members with ADMIN or APPROVED status')
1432 # For the API we need eager loading1433 # For the API we need eager loading
1433 api_activemembers = exported(1434 api_activemembers = exported(
1434 doNotSnapshot(1435 doNotSnapshot(
1435 CollectionField(1436 CollectionField(
1436 title=_("List of direct members with ADMIN or APPROVED status"),1437 title=_(
1438 "List of direct members with ADMIN or APPROVED status"),
1437 value_type=Reference(schema=Interface))),1439 value_type=Reference(schema=Interface))),
1438 exported_as='members')1440 exported_as='members')
1439 adminmembers = exported(1441 adminmembers = exported(
@@ -2170,7 +2172,7 @@
2170 def latest_teams(limit=5):2172 def latest_teams(limit=5):
2171 """Return the latest teams registered, up to the limit specified."""2173 """Return the latest teams registered, up to the limit specified."""
21722174
2173 def mergeAsync(from_person, to_person):2175 def mergeAsync(from_person, to_person, reviewer=None):
2174 """Merge a person/team into another asynchronously.2176 """Merge a person/team into another asynchronously.
21752177
2176 This schedules a call to `merge()` to happen outside of the current2178 This schedules a call to `merge()` to happen outside of the current
@@ -2179,10 +2181,13 @@
2179 guaranteed to succeed. If either user is in a pending person merge2181 guaranteed to succeed. If either user is in a pending person merge
2180 job, None is returned.2182 job, None is returned.
21812183
2184 :param from_person: An IPerson or ITeam that is a duplicate.
2185 :param to_person: An IPerson or ITeam that is a master.
2186 :param reviewer: An IPerson who approved the ITeam merger.
2182 :return: A `PersonMergeJob` or None.2187 :return: A `PersonMergeJob` or None.
2183 """2188 """
21842189
2185 def merge(from_person, to_person):2190 def merge(from_person, to_person, reviewer=None):
2186 """Merge a person/team into another.2191 """Merge a person/team into another.
21872192
2188 The old person/team (from_person) will be left as an atavism.2193 The old person/team (from_person) will be left as an atavism.
@@ -2196,9 +2201,9 @@
2196 passing deactivate_members=True. In that case the user who's2201 passing deactivate_members=True. In that case the user who's
2197 performing the merge must be provided as well.2202 performing the merge must be provided as well.
21982203
2199 We are not yet game to delete the `from_person` entry from the2204 :param from_person: An IPerson or ITeam that is a duplicate.
2200 database yet. We will let it roll for a while and see what cruft2205 :param to_person: An IPerson or ITeam that is a master.
2201 develops. -- StuartBishop 200508122206 :param reviewer: An IPerson who approved the ITeam merger.
2202 """2207 """
22032208
2204 def getValidPersons(persons):2209 def getValidPersons(persons):
22052210
=== modified file 'lib/lp/registry/interfaces/persontransferjob.py'
--- lib/lp/registry/interfaces/persontransferjob.py 2011-03-26 21:50:45 +0000
+++ lib/lp/registry/interfaces/persontransferjob.py 2011-04-12 02:08:36 +0000
@@ -102,7 +102,7 @@
102class IPersonMergeJobSource(IJobSource):102class IPersonMergeJobSource(IJobSource):
103 """An interface for acquiring IPersonMergeJobs."""103 """An interface for acquiring IPersonMergeJobs."""
104104
105 def create(from_person, to_person, review=None):105 def create(from_person, to_person, reviewer=None):
106 """Create a new IPersonMergeJob.106 """Create a new IPersonMergeJob.
107107
108 None is returned if either the from_person or to_person are already108 None is returned if either the from_person or to_person are already
@@ -111,7 +111,7 @@
111111
112 :param from_person: An IPerson or ITeam that is a duplicate.112 :param from_person: An IPerson or ITeam that is a duplicate.
113 :param to_person: An IPerson or ITeam that is a master.113 :param to_person: An IPerson or ITeam that is a master.
114 :param review: An IPerson who approved ITeam merger.114 :param reviewer: An IPerson who approved ITeam merger.
115 """115 """
116116
117 def find(from_person=None, to_person=None, any_person=False):117 def find(from_person=None, to_person=None, any_person=False):
118118
=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py 2011-04-06 17:08:45 +0000
+++ lib/lp/registry/model/person.py 2011-04-12 02:08:36 +0000
@@ -2076,8 +2076,7 @@
2076 def is_merge_pending(self):2076 def is_merge_pending(self):
2077 """See `IPublicPerson`."""2077 """See `IPublicPerson`."""
2078 return not getUtility(2078 return not getUtility(
2079 IPersonMergeJobSource).find(2079 IPersonMergeJobSource).find(from_person=self).is_empty()
2080 from_person=self, to_person=self, any_person=True).is_empty()
20812080
2082 def visibilityConsistencyWarning(self, new_value):2081 def visibilityConsistencyWarning(self, new_value):
2083 """Warning used when changing the team's visibility.2082 """Warning used when changing the team's visibility.
@@ -3836,10 +3835,10 @@
3836 naked_from_team.retractTeamMembership(team, reviewer)3835 naked_from_team.retractTeamMembership(team, reviewer)
3837 IStore(from_team).flush()3836 IStore(from_team).flush()
38383837
3839 def mergeAsync(self, from_person, to_person):3838 def mergeAsync(self, from_person, to_person, reviewer=None):
3840 """See `IPersonSet`."""3839 """See `IPersonSet`."""
3841 return getUtility(IPersonMergeJobSource).create(3840 return getUtility(IPersonMergeJobSource).create(
3842 from_person=from_person, to_person=to_person)3841 from_person=from_person, to_person=to_person, reviewer=reviewer)
38433842
3844 def merge(self, from_person, to_person, reviewer=None):3843 def merge(self, from_person, to_person, reviewer=None):
3845 """See `IPersonSet`."""3844 """See `IPersonSet`."""
38463845
=== modified file 'lib/lp/registry/stories/person/merge-people.txt'
--- lib/lp/registry/stories/person/merge-people.txt 2010-01-12 00:19:20 +0000
+++ lib/lp/registry/stories/person/merge-people.txt 2011-04-12 02:08:36 +0000
@@ -12,12 +12,6 @@
12 >>> foo = factory.makePerson(name='foo', email='foo@baz.com')12 >>> foo = factory.makePerson(name='foo', email='foo@baz.com')
13 >>> logout()13 >>> logout()
1414
15Workaround while https://launchpad.net/launchpad/+bug/39016 is not
16fixed.
17
18 >>> from lp.services.mail import stub
19 >>> stub.test_emails[:] = []
20
21First we have to go to the +requestmerge page.15First we have to go to the +requestmerge page.
2216
23 >>> browser.addHeader('Authorization', 'Basic no-priv@canonical.com:test')17 >>> browser.addHeader('Authorization', 'Basic no-priv@canonical.com:test')
@@ -25,23 +19,6 @@
25 >>> print browser.title19 >>> print browser.title
26 Merge Launchpad accounts20 Merge Launchpad accounts
2721
28If we try to merge a nonexistent account, we'll get an error page.
29
30 >>> browser.getControl('Duplicated Account').value = 'bar'
31 >>> browser.getControl('Continue').click()
32 >>> print "\n".join(get_feedback_messages(browser.contents))
33 There is 1 error.
34 Invalid value
35
36The same will happen if we try to merge a team.
37
38 >>> browser.getControl(
39 ... 'Duplicated Account').value = 'support@canonical.com'
40 >>> browser.getControl('Continue').click()
41 >>> print "\n".join(get_feedback_messages(browser.contents))
42 There is 1 error.
43 Invalid value
44
45In preparation for the actual merge (below), we add an extra email22In preparation for the actual merge (below), we add an extra email
46address to the 'foo' account, to show how things work when the dupe23address to the 'foo' account, to show how things work when the dupe
47account has more than one email address.24account has more than one email address.
@@ -70,6 +47,7 @@
7047
71Make sure we haven't got leftovers from a previous test48Make sure we haven't got leftovers from a previous test
7249
50 >>> from lp.services.mail import stub
73 >>> len(stub.test_emails)51 >>> len(stub.test_emails)
74 052 0
7553
@@ -164,18 +142,6 @@
164 >>> assert not stub.test_emails142 >>> assert not stub.test_emails
165 >>> token_url = get_token_url_from_email(raw_msg)143 >>> token_url = get_token_url_from_email(raw_msg)
166144
167 # XXX: Steve Alexander 2006-10-21: To prevent spurious failures in PQM.
168 #>>> browser.open(token_url)
169 #>>> browser.getControl('Confirm').click()
170 #>>> 'The accounts have been merged successfully' in browser.contents
171 #True
172
173Revisiting the mergerequest-sent page now safely redirects the user:
174
175 #>>> browser.open('http://launchpad.dev/people/+mergerequest-sent?dupe=55')
176 #>>> browser.url
177 #'http://launchpad.dev/~no-priv'
178
179If the duplicate account has multiple email addresses and has chosen145If the duplicate account has multiple email addresses and has chosen
180to hide them the process is slightly different. We cannot display the146to hide them the process is slightly different. We cannot display the
181hidden addresses so instead we just inform the user to check all of147hidden addresses so instead we just inform the user to check all of
182148
=== modified file 'lib/lp/registry/stories/person/xx-adminpeoplemerge.txt'
--- lib/lp/registry/stories/person/xx-adminpeoplemerge.txt 2010-10-10 15:39:28 +0000
+++ lib/lp/registry/stories/person/xx-adminpeoplemerge.txt 2011-04-12 02:08:36 +0000
@@ -1,4 +1,5 @@
1= Merging people or teams =1Merging people or teams
2=======================
23
3Launchpad admins can merge any two people or teams, without the need of4Launchpad admins can merge any two people or teams, without the need of
4any email address confirmation or something like that. There's one page5any email address confirmation or something like that. There's one page
@@ -35,17 +36,6 @@
35 >>> admin_browser.url36 >>> admin_browser.url
36 'http://launchpad.dev/~salgado'37 'http://launchpad.dev/~salgado'
3738
38 >>> from zope.component import getUtility39 >>> print get_feedback_messages(admin_browser.contents)[0]
39 >>> from canonical.launchpad.ftests import ANONYMOUS, login, logout40 A merge is queued and is expected to complete in a few minutes.
40 >>> from lp.registry.interfaces.person import IPersonSet
41 >>> login(ANONYMOUS)
42 >>> person_set = getUtility(IPersonSet)
43 >>> person_set.getByEmail('andrew.bennetts@ubuntulinux.com').name
44 u'salgado'
45
46 >>> spiv = person_set.getByName('spiv-merged', ignore_merged=False)
47 >>> spiv.merged.name
48 u'salgado'
49
50 >>> logout()
5141
5242
=== removed file 'lib/lp/registry/stories/person/xx-merge-person-with-hidden-email.txt'
--- lib/lp/registry/stories/person/xx-merge-person-with-hidden-email.txt 2009-09-10 23:58:31 +0000
+++ lib/lp/registry/stories/person/xx-merge-person-with-hidden-email.txt 1970-01-01 00:00:00 +0000
@@ -1,45 +0,0 @@
1= Merging people with hidden email addresses =
2
3In order to merge FooBarDupe into FooBar we need to send an email to
4FooBarDupe's email address with instructions for FooBar to complete the
5merge. The merge should succeed even if FooBarDupe has chosen to hide his
6email addresses.
7
8 # First we'll create profiles for FooBar and FooBarDupe, the latter with
9 # hidden email addresses.
10 >>> login(ANONYMOUS)
11 >>> foobar = factory.makePerson(
12 ... email='foobar@baz.com', name='foobar', password='test')
13 >>> foobar_dupe = factory.makePerson(name='foobar-dupe')
14 >>> login_person(foobar_dupe)
15 >>> foobar_dupe.hide_email_addresses = True
16 >>> logout()
17
18FooBar will now request that FooBarDupe be merged into his account.
19
20 >>> browser = setupBrowser(auth="Basic foobar@baz.com:test")
21 >>> browser.open('http://launchpad.dev/people/+requestmerge')
22 >>> browser.getControl('Duplicated Account').value = 'foobar-dupe'
23 >>> browser.getControl('Continue').click()
24 >>> browser.url
25 'http://launchpad.dev/people/+mergerequest-sent?dupe=...
26 >>> print extract_text(find_main_content(browser.contents))
27 An email message was sent to...
28 Please follow the instructions on that message to complete the merge.
29
30Following the link included in the email sent to FooBarDupe's email he'll
31be able to complete the merge.
32
33 >>> from lp.services.mail import stub
34 >>> from canonical.launchpad.ftests.logintoken import (
35 ... get_token_url_from_email)
36 >>> len(stub.test_emails)
37 1
38 >>> from_addr, to_addrs, raw_msg = stub.test_emails.pop()
39 >>> token_url = get_token_url_from_email(raw_msg)
40 >>> browser.open(token_url)
41 >>> browser.url
42 'http://launchpad.dev/token/.../+accountmerge'
43 >>> browser.getControl('Confirm').click()
44 >>> print "\n".join(get_feedback_messages(browser.contents))
45 The accounts have been merged successfully...
460
=== modified file 'lib/lp/registry/stories/team/xx-adminteammerge.txt'
--- lib/lp/registry/stories/team/xx-adminteammerge.txt 2010-10-18 22:24:59 +0000
+++ lib/lp/registry/stories/team/xx-adminteammerge.txt 2011-04-12 02:08:36 +0000
@@ -1,4 +1,5 @@
1= Merging Teams =1Merging Teams
2=============
23
3There's a separate page for merging teams. We can't merge teams with4There's a separate page for merging teams. We can't merge teams with
4active members, so the user will first have to confirm that the5active members, so the user will first have to confirm that the
@@ -32,70 +33,5 @@
32 >>> registry_browser.url33 >>> registry_browser.url
33 'http://launchpad.dev/~guadamen'34 'http://launchpad.dev/~guadamen'
3435
35 >>> from canonical.launchpad.ftests import ANONYMOUS, login, logout36 >>> print get_feedback_messages(registry_browser.contents)[0]
36 >>> from zope.component import getUtility37 A merge is queued and is expected to complete in a few minutes.
37 >>> from lp.registry.interfaces.mailinglist import IMailingListSet
38 >>> login(ANONYMOUS)
39 >>> new_team.merged.name
40 u'guadamen'
41 >>> logout()
42
43
44== Non-display of merged teams ==
45
46Merged teams are not visible anymore.
47
48 >>> browser.open("http://launchpad.dev/~new-team-merged")
49 Traceback (most recent call last):
50 ...
51 NotFound:...
52
53
54== Corner cases ==
55
56If the duplicated team is associated with a mailing list, it can't be
57merged, though.
58
59 >>> login(ANONYMOUS)
60 >>> guadamen = getUtility(IPersonSet).getByName('guadamen')
61 >>> mailing_list = getUtility(IMailingListSet).new(guadamen)
62 >>> logout()
63
64 >>> registry_browser.open('http://launchpad.dev/people/+adminteammerge')
65 >>> registry_browser.getControl('Duplicated Team').value = 'guadamen'
66 >>> registry_browser.getControl('Target Team').value = 'ubuntu-team'
67 >>> registry_browser.getControl('Merge').click()
68
69 >>> registry_browser.url
70 'http://launchpad.dev/people/+adminteammerge'
71
72 >>> print get_feedback_messages(registry_browser.contents)
73 [u'There is 1 error.',
74 u"guadamen is associated with a Launchpad mailing list;
75 we can't merge it."]
76
77We also can't (for obvious reasons) merge any person/team into itself.
78
79 >>> registry_browser.getControl('Duplicated Team').value = 'shipit-admins'
80 >>> registry_browser.getControl('Target Team').value = 'shipit-admins'
81 >>> registry_browser.getControl('Merge').click()
82
83 >>> registry_browser.url
84 'http://launchpad.dev/people/+adminteammerge'
85
86 >>> print get_feedback_messages(registry_browser.contents)
87 [u'There is 1 error.', u"You can't merge shipit-admins into itself."]
88
89Nor can we merge a person into a team or a team into a person.
90
91 >>> registry_browser.getControl('Duplicated Team').value = 'ubuntu-team'
92 >>> registry_browser.getControl('Target Team').value = 'salgado'
93 >>> registry_browser.getControl('Merge').click()
94
95 >>> registry_browser.url
96 'http://launchpad.dev/people/+adminteammerge'
97
98 # Yes, the error message is not of much help, but this UI is only for
99 # admins and they're supposed to know what they're doing.
100 >>> print get_feedback_messages(registry_browser.contents)
101 [u'There is 1 error.', u'Constraint not satisfied']
10238
=== modified file 'lib/lp/registry/tests/test_person.py'
--- lib/lp/registry/tests/test_person.py 2011-04-05 12:10:01 +0000
+++ lib/lp/registry/tests/test_person.py 2011-04-12 02:08:36 +0000
@@ -289,14 +289,14 @@
289 person = self.factory.makePerson()289 person = self.factory.makePerson()
290 self.assertFalse(person.is_merge_pending)290 self.assertFalse(person.is_merge_pending)
291291
292 def test_merge_pending(self):292 def test_is_merge_pending(self):
293 # is_merge_pending returns True when this person is being merged with293 # is_merge_pending returns True when this person is being merged with
294 # another person in an active merge job.294 # another person in an active merge job.
295 from_person = self.factory.makePerson()295 from_person = self.factory.makePerson()
296 to_person = self.factory.makePerson()296 to_person = self.factory.makePerson()
297 getUtility(IPersonSet).mergeAsync(from_person, to_person)297 getUtility(IPersonSet).mergeAsync(from_person, to_person)
298 self.assertTrue(from_person.is_merge_pending)298 self.assertTrue(from_person.is_merge_pending)
299 self.assertTrue(to_person.is_merge_pending)299 self.assertFalse(to_person.is_merge_pending)
300300
301 def test_mergeAsync_success(self):301 def test_mergeAsync_success(self):
302 # mergeAsync returns a job with the from and to persons.302 # mergeAsync returns a job with the from and to persons.